From b7deabb8bcf098ac4d22f137bc03a050545b2c38 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:20:00 +0000 Subject: [PATCH 001/108] Build(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 (#8213) * Build(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1 Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.0.1. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/v9.0.0...20cfd1bf945f4377ade1205e4dbc17946fc9a30d) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * Change cache setting to auto in devflow PR review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Change cache enable option to auto in workflow Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/devflow-pr-review.yml | 4 ++-- .github/workflows/issue-triage.yml | 2 +- .github/workflows/python-docs.yml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/devflow-pr-review.yml b/.github/workflows/devflow-pr-review.yml index 592ef3caa34..b15ba1482cc 100644 --- a/.github/workflows/devflow-pr-review.yml +++ b/.github/workflows/devflow-pr-review.yml @@ -246,10 +246,10 @@ jobs: python-version: "3.13" - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version: "0.11.x" - enable-cache: true + enable-cache: auto - name: Install DevFlow dependencies working-directory: ${{ env.DEVFLOW_PATH }} diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index d6636a78034..840f65eb1d7 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -190,7 +190,7 @@ jobs: python-version: "3.13" - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version: "0.11.x" enable-cache: true diff --git a/.github/workflows/python-docs.yml b/.github/workflows/python-docs.yml index 66de5fcc6c5..50fb14ad91b 100644 --- a/.github/workflows/python-docs.yml +++ b/.github/workflows/python-docs.yml @@ -26,10 +26,10 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: version-file: "python/pyproject.toml" - enable-cache: true + enable-cache: auto cache-suffix: ${{ runner.os }}-${{ env.UV_PYTHON }} cache-dependency-glob: "python/uv.lock" - name: Install dependencies From dcd1e62715caab9219eba7de77977e8cbb935afc Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Thu, 10 Sep 2026 12:34:38 +0000 Subject: [PATCH 002/108] Python: fix(core): authenticate MCP connection-lifetime requests with the run's kwargs (#8225) * Added failing test * seeding run header value for auth to mcp connect * WIP: added connection_kwargs and tests * Python: fix(core): seed run kwargs into MCP connection-header resolution Constructor-supplied MCP tools now seed the run's function_invocation_kwargs before connecting, matching the run-supplied path. Adds coverage for the connect-time credential matrix and fixes the API-key sample to close over a construction-time credential. * Python: fix(core): fail the MCP handshake when seeded kwargs miss the provider's key Ambient header resolution previously swallowed every KeyError from header_provider, so a run whose kwargs did not carry the key the provider reads would silently send the initialize handshake unauthenticated and surface as a 401. Only tolerate the KeyError when no connection kwargs were seeded at all, which is the case a caller cannot avoid; a key missing from seeded kwargs is a misconfiguration and now propagates. * Python: fix(core): distinguish unseeded MCP connections and release kwargs on failed connect Addresses review on #8225. An empty mapping could not express the difference between a run that seeded no kwargs and a connection no run ever seeded, so a lazy run with empty kwargs still swallowed the provider's KeyError and sent an unauthenticated handshake. _connection_kwargs is now None until a run seeds it. The seeded kwargs were also only released through close(); a rejected handshake unwinds via _close_and_check_cancelled instead, leaving the failed run's credential for a later unseeded reconnect, so the release now happens there. Documents the connection-lifetime semantics on the public header_provider parameter. * Python: fix(core): make the MCP connection kwargs seed a first-writer claim Addresses review on #8225. is_connected only turns true after initialize returns, so two concurrent runs could both pass the guard and the second would swap the credential out from under the first run's in-flight handshake, authenticating a shared tool as the wrong caller. The seed is now a first-writer claim, released when the connection closes or its setup fails. * Update python/samples/02-agents/mcp/mcp_api_key_auth.py Co-authored-by: Eduard van Valkenburg --------- Co-authored-by: Eduard van Valkenburg --- .../packages/core/agent_framework/_agents.py | 15 +- python/packages/core/agent_framework/_mcp.py | 64 +++++- python/packages/core/tests/core/test_mcp.py | 214 ++++++++++++++++++ .../core/tests/core/test_mcp_http_auth.py | 187 +++++++++++++++ .../samples/02-agents/mcp/mcp_api_key_auth.py | 33 ++- 5 files changed, 479 insertions(+), 34 deletions(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index a115f600f3d..3fa71ed9689 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -1455,11 +1455,20 @@ async def _prepare_run_context( # Normalize tools normalized_tools = _normalize_tools(tools_) + # Extract additional function arguments + effective_function_invocation_kwargs = ( + dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} + ) + additional_function_arguments = {**effective_function_invocation_kwargs, **existing_additional_args} + # Resolve final tool list (configured tools + runtime provided tools + local MCP server tools) final_tools = list(base_tools) for tool in normalized_tools: if isinstance(tool, MCPTool): if not tool.is_connected: + # The handshake and discovery requests are issued before any tool call, so the run's + # kwargs must reach header_provider here or those requests go out unauthenticated. + tool._seed_connection_kwargs(additional_function_arguments) # pyright: ignore[reportPrivateUsage] await self._async_exit_stack.enter_async_context(tool) _append_unique_tools( final_tools, @@ -1471,6 +1480,7 @@ async def _prepare_run_context( for mcp_server in self.mcp_tools: if not mcp_server.is_connected: + mcp_server._seed_connection_kwargs(additional_function_arguments) # pyright: ignore[reportPrivateUsage] await self._async_exit_stack.enter_async_context(mcp_server) _append_unique_tools( final_tools, @@ -1478,11 +1488,6 @@ async def _prepare_run_context( duplicate_error_message=mcp_duplicate_message, ) - effective_function_invocation_kwargs = ( - dict(function_invocation_kwargs) if function_invocation_kwargs is not None else {} - ) - additional_function_arguments = {**effective_function_invocation_kwargs, **existing_additional_args} - model = opts.pop("model", None) # Build options dict from run() options merged with provided options diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index cbdb414317f..0197d9a3535 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -1699,6 +1699,11 @@ async def _close_and_check_cancelled(self, ex: BaseException) -> tuple[bool, Bas error path holding only a bare cancellation can still describe it. """ cleanup_error = await self._safe_close_exit_stack() + # Every abandoned connection attempt lands here, so a rejected handshake cannot + # leave one run's credentials visible to a later unseeded reconnect. Deliberately + # not in _safe_close_exit_stack: connect(reset=True) closes through that path and + # must keep its kwargs to re-authenticate the new connection. + self._release_connection_kwargs() return _should_propagate_cancelled_error(ex), cleanup_error def _reset_session_state(self) -> None: @@ -1890,6 +1895,14 @@ async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool self._tool_param_names_by_name = param_names_before_discovery raise + def _seed_connection_kwargs(self, kwargs: Mapping[str, Any]) -> None: + """Offer run-scoped kwargs to connection-lifetime header resolution.""" + return + + def _release_connection_kwargs(self) -> None: + """Drop any run-scoped kwargs held for connection-lifetime header resolution.""" + return + async def _sampling_request_approved(self, params: types.CreateMessageRequestParams) -> bool: """Run the configured sampling approval gate. @@ -3549,6 +3562,18 @@ def __init__( of HTTP headers to inject into every outbound request to the MCP server. Use this to forward per-request context (e.g. authentication tokens set in agent middleware) without creating a separate ``httpx.AsyncClient``. + Only tool calls carry a run's kwargs. Connection-lifetime requests - the + ``initialize`` handshake, tool and prompt discovery, and background pings - + belong to no call, so they reuse the kwargs of the run that established the + connection until the tool is closed; a later run's kwargs do not reach them. + A tool connected outside any run (eagerly via ``async with``, or standalone) + has no kwargs to reuse and the provider is called with an empty mapping, in + which case a ``KeyError`` from the provider is tolerated and the request is + sent without headers. Once a run has supplied kwargs, a ``KeyError`` is + raised instead, since a key missing there is a misconfiguration rather than + an unavoidable gap. A credential that must authenticate the handshake should + therefore come from somewhere the provider can read without a run - a closure + or a ``ContextVar`` - rather than from run kwargs alone. The framework attaches these headers only to requests whose origin (scheme, host, port) matches the configured ``url``, so they are not leaked to other origins on cross-origin redirects; headers injected this way are also removed @@ -3626,6 +3651,9 @@ def __init__( # when a header_provider is set: parallel invocations on the same instance would # otherwise overwrite each other's snapshot and attach the wrong per-call headers. self._active_call_headers: dict[str, str] | None = None + # None means no run seeded this connection, which an empty mapping cannot express: + # a run that supplies no kwargs still expects a missing provider key to be an error. + self._connection_kwargs: dict[str, Any] | None = None self._call_headers_lock = asyncio.Lock() self._header_request_owner = object() self._header_hook_client: AsyncClient | None = None @@ -3689,22 +3717,24 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async if headers is None: # Ambient request made outside call_tool (the initialize handshake, # load_tools/load_prompts discovery, or background pings). Invoke the - # provider with empty kwargs so static providers can authenticate these - # requests too. A provider that indexes a required per-call kwarg (e.g. - # kwargs["api_key"]) raises KeyError on the empty dict; that specific - # case is tolerated so connect still succeeds. Any other error is a - # genuine provider failure and is left to propagate, matching the - # call_tool path which does not catch header_provider exceptions. + # provider with the kwargs seeded by the run that established this + # connection, so static providers and run-supplied credentials both + # authenticate these requests. Provider failures propagate, matching the + # call_tool path, except the one case below that no caller can avoid. if self._header_provider is None: raise RuntimeError("Header injection hook invoked without a header_provider.") try: - headers = self._header_provider({}) + headers = self._header_provider(self._connection_kwargs or {}) except KeyError: - # A kwargs-dependent provider raises on every ambient request - # (initialize, discovery, and recurring pings). + # Unavoidable only when no run seeded this connection: the provider + # wants per-call values a connection-lifetime request cannot have. Once + # a run has seeded kwargs a missing key is a misconfiguration, and + # silently dropping it would send the handshake unauthenticated. + if self._connection_kwargs is not None: + raise logger.debug( "header_provider raised KeyError for MCP server %r on an ambient " - "request (missing per-call kwargs); proceeding without headers.", + "request (no connection kwargs available); proceeding without headers.", self.name, exc_info=True, ) @@ -3760,8 +3790,22 @@ async def _close_on_owner(self) -> None: try: await super()._close_on_owner() finally: + self._release_connection_kwargs() self._remove_header_hook() + def _seed_connection_kwargs(self, kwargs: Mapping[str, Any]) -> None: + if self._header_provider is None or self.is_connected: + return + # is_connected stays false until initialize returns, so it alone would let a second + # concurrent run swap the credential out from under the first run's in-flight + # handshake. The claim is released when the connection closes or its setup fails. + if self._connection_kwargs is not None: + return + self._connection_kwargs = dict(kwargs) + + def _release_connection_kwargs(self) -> None: + self._connection_kwargs = None + async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: """Call a tool, injecting headers from the header_provider if configured. diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 39f90079ba1..063ffc288aa 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -21,6 +21,7 @@ from pydantic import AnyUrl, BaseModel from agent_framework import ( + Agent, ChatResponse, ChatResponseUpdate, Content, @@ -31,6 +32,7 @@ MCPStreamableHTTPTool, MCPWebsocketTool, Message, + SupportsChatGetResponse, ) from agent_framework._feature_stage import _WARNED_FEATURES, ExperimentalFeature, ExperimentalWarning from agent_framework._mcp import ( @@ -7569,6 +7571,218 @@ def provider(kwargs): assert call_args.kwargs.get("arguments", {}).get("name") == "Alice" +async def test_agent_run_supplies_mcp_connect_headers( + client: SupportsChatGetResponse, +) -> None: + """Run-time credentials should authenticate implicit MCP initialization. + + The agent receives function_invocation_kwargs before connecting the MCP tool + supplied to run(). This test exercises the real MCP transport against an + in-process mock HTTP endpoint and asserts that header_provider can use those + credentials on the initialize request, before any tool invocation occurs. + """ + import httpx + + captured_requests: list[tuple[str, str, dict[str, str]]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + if request.method == "DELETE": + return httpx.Response(200) + if request.method == "GET": + return httpx.Response(405) + body = json.loads(request.content.decode()) + method = body.get("method", "") + captured_requests.append((request.method, method, {k.lower(): v for k, v in request.headers.items()})) + if method == "initialize": + result = { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "mock-server", "version": "1.0.0"}, + } + return httpx.Response( + 200, + headers={"mcp-session-id": "test-session"}, + json={"jsonrpc": "2.0", "id": body["id"], "result": result}, + ) + if method == "tools/list": + result = { + "tools": [ + { + "name": "greet", + "description": "Says hello", + "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}}}, + } + ] + } + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + if method == "tools/call": + result = {"content": [{"type": "text", "text": "Hello!"}], "isError": False} + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + if "id" in body: + # Any other request (e.g. ping) gets an empty result so the session doesn't block on it. + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + # Notifications (e.g. notifications/initialized) + return httpx.Response(202) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + tool = MCPStreamableHTTPTool( + name="test", + url="http://127.0.0.1:8000/mcp", + load_prompts=False, + http_client=http_client, + header_provider=lambda kw: {"x-api-key": kw["api_key"]}, # failing scenario + # header_provider=lambda _: {"x-api-key": "connect-token"} # working scenario (no lambda) + ) + try: + async with Agent(client=client) as agent: + # placement of tools, matters as we defer the resolution until the agent calls `run` + await agent.run("Hello", tools=[tool], function_invocation_kwargs={"api_key": "connect-token"}) + finally: + await http_client.aclose() + + initialize_headers = [headers for _, method, headers in captured_requests if method == "initialize"] + assert len(initialize_headers) == 1 + assert initialize_headers[0].get("x-api-key") == "connect-token" + + +async def test_agent_context_manager_authenticates_connect_with_closure_provider( + client: SupportsChatGetResponse, +) -> None: + """A constructor-supplied MCP tool authenticates its eager handshake with no run involved. + + Pins that ``header_provider`` already covers construction-time credentials: entering the + agent context connects before any run exists, and the server rejects unauthenticated calls. + """ + import httpx + + captured_requests: list[tuple[str, dict[str, str]]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + if request.method == "DELETE": + return httpx.Response(200) + if request.method == "GET": + return httpx.Response(405) + if request.headers.get("x-api-key") != "constructor-token": + return httpx.Response(401) + body = json.loads(request.content.decode()) + method = body.get("method", "") + captured_requests.append((method, {k.lower(): v for k, v in request.headers.items()})) + if method == "initialize": + return httpx.Response( + 200, + headers={"mcp-session-id": "test-session"}, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "mock-server", "version": "1.0.0"}, + }, + }, + ) + if method == "tools/list": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"tools": [{"name": "greet", "inputSchema": {"type": "object", "properties": {}}}]}, + }, + ) + if "id" in body: + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + return httpx.Response(202) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + # The credential is known at construction, so the provider closes over it and ignores kwargs. + tool = MCPStreamableHTTPTool( + name="test", + url="http://127.0.0.1:8000/mcp", + load_prompts=False, + http_client=http_client, + header_provider=lambda _kwargs: {"x-api-key": "constructor-token"}, + ) + try: + async with Agent(client=client, tools=[tool]): + assert tool.is_connected + finally: + await http_client.aclose() + + assert [method for method, _ in captured_requests].count("initialize") == 1 + assert all(headers.get("x-api-key") == "constructor-token" for _, headers in captured_requests) + + +async def test_constructor_supplied_mcp_tool_uses_run_credentials_on_lazy_connect( + client: SupportsChatGetResponse, +) -> None: + """A constructor-supplied MCP tool connected at first run authenticates with that run's kwargs. + + Without the agent context manager the handshake is deferred to ``run()``, so the run's + credentials are available and must reach ``header_provider``. + """ + import httpx + + captured_requests: list[tuple[str, dict[str, str]]] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + if request.method == "DELETE": + return httpx.Response(200) + if request.method == "GET": + return httpx.Response(405) + if request.headers.get("x-api-key") != "run-token": + return httpx.Response(401) + body = json.loads(request.content.decode()) + method = body.get("method", "") + captured_requests.append((method, {k.lower(): v for k, v in request.headers.items()})) + if method == "initialize": + return httpx.Response( + 200, + headers={"mcp-session-id": "test-session"}, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "mock-server", "version": "1.0.0"}, + }, + }, + ) + if method == "tools/list": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"tools": [{"name": "greet", "inputSchema": {"type": "object", "properties": {}}}]}, + }, + ) + if "id" in body: + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + return httpx.Response(202) + + http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + tool = MCPStreamableHTTPTool( + name="test", + url="http://127.0.0.1:8000/mcp", + load_prompts=False, + http_client=http_client, + header_provider=lambda kw: {"x-api-key": kw["api_key"]}, + ) + agent = Agent(client=client, tools=[tool]) + try: + # No agent context manager, so the tool is still unconnected when the run starts. + assert not tool.is_connected + await agent.run("Hello", function_invocation_kwargs={"api_key": "run-token"}) + finally: + await tool.close() + await http_client.aclose() + + assert [method for method, _ in captured_requests].count("initialize") == 1 + assert all(headers.get("x-api-key") == "run-token" for _, headers in captured_requests) + + async def test_mcp_streamable_http_tool_header_provider_applies_across_transport_tasks(): """Regression test for #7161: header_provider headers must reach tools/call requests. diff --git a/python/packages/core/tests/core/test_mcp_http_auth.py b/python/packages/core/tests/core/test_mcp_http_auth.py index 04c27676060..31bb5afae5c 100644 --- a/python/packages/core/tests/core/test_mcp_http_auth.py +++ b/python/packages/core/tests/core/test_mcp_http_auth.py @@ -732,3 +732,190 @@ async def block_discovery() -> None: finally: release_setup.set() await borrowed.close() + + +def _kwargs_dependent_tool(client: httpx.AsyncClient) -> MCPStreamableHTTPTool: + """A tool whose header_provider can only authenticate when connection kwargs are seeded.""" + return MCPStreamableHTTPTool( + name="seeded", + url="https://mcp.example/mcp", + http_client=client, + load_prompts=False, + header_provider=lambda kwargs: {"Authorization": kwargs["credential"]}, + ) + + +@pytest.mark.parametrize("seeded", [True, False]) +async def test_seeded_connection_kwargs_authenticate_the_handshake( + mcp_http_server: MCPHTTPServer, seeded: bool +) -> None: + client, requests, _ = mcp_http_server + tool = _kwargs_dependent_tool(client) + if seeded: + tool._seed_connection_kwargs({"credential": "token-a"}) + try: + if not seeded: + with pytest.raises(ToolException): + await tool.connect() + return + async with tool: + await tool.call_tool("record", credential="token-a") + initializes = [ + request + for request in requests + if request.method == "POST" and json.loads(request.content).get("method") == "initialize" + ] + assert [request.headers.get("Authorization") for request in initializes] == ["token-a"] + finally: + await tool.close() + + +async def test_connection_kwargs_are_fixed_for_the_connection_and_cleared_on_close( + mcp_http_server: MCPHTTPServer, +) -> None: + client, requests, _ = mcp_http_server + tool = _kwargs_dependent_tool(client) + tool._seed_connection_kwargs({"credential": "token-a"}) + try: + async with tool: + # A later run must not re-authenticate an already-established connection. + tool._seed_connection_kwargs({"credential": "token-b"}) + assert tool._connection_kwargs == {"credential": "token-a"} + # Call scope resolves independently of the connection scope. + await tool.call_tool("record", credential="token-c") + initializes = [ + request + for request in requests + if request.method == "POST" and json.loads(request.content).get("method") == "initialize" + ] + assert [request.headers.get("Authorization") for request in initializes] == ["token-a"] + assert [request.headers.get("Authorization") for request in _calls(requests)] == ["token-c"] + assert tool._connection_kwargs is None + # The credential was released on close, so an unseeded reconnect is rejected. + with pytest.raises(ToolException): + await tool.connect() + finally: + await tool.close() + + +async def test_standalone_static_header_provider_authenticates_without_a_run(mcp_http_server: MCPHTTPServer) -> None: + """A provider that ignores kwargs authenticates the handshake outside any agent run. + + Pins the boundary of the connection-kwargs seeding: standalone use has no per-call kwargs + to seed from, so a closure/token-provider style provider is the supported shape there. + """ + client, requests, _ = mcp_http_server + tool = MCPStreamableHTTPTool( + name="standalone", + url="https://mcp.example/mcp", + http_client=client, + load_prompts=False, + header_provider=lambda _kwargs: {"Authorization": "token-a"}, + ) + try: + async with tool: + await tool.call_tool("record") + authenticated = [ + request + for request in requests + if request.method == "POST" + and json.loads(request.content).get("method") in {"initialize", "tools/list", "tools/call"} + ] + assert authenticated + assert all(request.headers.get("Authorization") == "token-a" for request in authenticated) + finally: + await tool.close() + + +async def test_seeded_kwargs_missing_the_providers_key_fails_the_handshake( + mcp_http_server: MCPHTTPServer, +) -> None: + """A key absent from seeded connection kwargs is a misconfiguration, not a tolerated ambient miss. + + An unseeded connection legitimately has no per-call values, so a KeyError there is tolerated. + Once a run supplies kwargs, a provider asking for an absent key must fail loudly instead of + letting the handshake go out unauthenticated. + """ + client, _, _ = mcp_http_server + tool = MCPStreamableHTTPTool( + name="mismatch", + url="https://mcp.example/mcp", + http_client=client, + load_prompts=False, + header_provider=lambda kwargs: {"Authorization": kwargs["credential"]}, + ) + tool._seed_connection_kwargs({"typo_credential": "token-a"}) + try: + with pytest.raises(ToolException) as error: + await tool.connect() + assert "'credential'" in str(error.value) + finally: + await tool.close() + + +async def test_run_supplying_no_kwargs_still_fails_a_kwargs_dependent_provider( + mcp_http_server: MCPHTTPServer, +) -> None: + """Seeding an empty mapping is still seeding, so the provider's missing key must not be tolerated. + + An empty mapping cannot distinguish a run that supplied no kwargs from a connection no run + ever seeded; only the latter has no way to carry the key and may proceed unauthenticated. + """ + client, _, _ = mcp_http_server + tool = _kwargs_dependent_tool(client) + tool._seed_connection_kwargs({}) + try: + with pytest.raises(ToolException) as error: + await tool.connect() + assert "'credential'" in str(error.value) + finally: + await tool.close() + + +async def test_failed_connect_releases_the_seeded_credential(mcp_http_server: MCPHTTPServer) -> None: + """An abandoned connection attempt must not leave its credential for a later unseeded connect. + + A rejected handshake unwinds without going through close(), so the release has to happen on + the failure path too; otherwise a standalone reconnect re-sends the failed run's credential. + """ + client, requests, _ = mcp_http_server + tool = _kwargs_dependent_tool(client) + tool._seed_connection_kwargs({"credential": "token-rejected"}) + try: + with pytest.raises(ToolException): + await tool.connect() + assert tool._connection_kwargs is None + with pytest.raises(ToolException): + await tool.connect() + initializes = [ + request + for request in requests + if request.method == "POST" and json.loads(request.content).get("method") == "initialize" + ] + assert [request.headers.get("Authorization") for request in initializes] == ["token-rejected", None] + finally: + await tool.close() + + +async def test_a_second_run_cannot_replace_an_unconnected_claim(mcp_http_server: MCPHTTPServer) -> None: + """The first run to seed owns the connection, even before its handshake completes. + + is_connected only turns true after initialize returns, so it cannot by itself stop a + concurrent run from swapping the credential mid-handshake and authenticating the shared + connection as the wrong caller. + """ + client, requests, _ = mcp_http_server + tool = _kwargs_dependent_tool(client) + tool._seed_connection_kwargs({"credential": "token-a"}) + tool._seed_connection_kwargs({"credential": "token-b"}) + try: + async with tool: + pass + initializes = [ + request + for request in requests + if request.method == "POST" and json.loads(request.content).get("method") == "initialize" + ] + assert [request.headers.get("Authorization") for request in initializes] == ["token-a"] + finally: + await tool.close() diff --git a/python/samples/02-agents/mcp/mcp_api_key_auth.py b/python/samples/02-agents/mcp/mcp_api_key_auth.py index 5af7f29009d..4109a0b435d 100644 --- a/python/samples/02-agents/mcp/mcp_api_key_auth.py +++ b/python/samples/02-agents/mcp/mcp_api_key_auth.py @@ -13,10 +13,9 @@ """ MCP API Key Authentication Example -This sample demonstrates the runtime ``header_provider`` pattern for -``MCPStreamableHTTPTool``. The MCP tool derives authentication headers from -``function_invocation_kwargs`` passed to ``Agent.run(...)`` so the API key stays -in runtime context instead of being baked into a shared ``httpx.AsyncClient``. +This sample demonstrates the ``header_provider`` pattern for ``MCPStreamableHTTPTool``. +The MCP tool derives its authentication headers from a provider callable, so the API key +stays in application code instead of being baked into a shared ``httpx.AsyncClient``. Replace the ``url`` parameter in the ``MCPStreamableHTTPTool`` with your authenticated server URL and run the sample with your API key as a command-line argument: @@ -27,17 +26,16 @@ For more complex scenarios, you could implement token refresh logic or support multiple authentication methods within the header provider function. -Note on ``function_invocation_kwargs``: -Values passed this way are shared with every tool in the run, including every attached MCP server, and are -filtered against each tool's server-declared ``inputSchema.properties``. A server that declares ``mcp_api_key`` -therefore receives it as an ordinary tool argument, and ``header_provider`` does not prevent that - it reads the -kwargs without consuming them. This sample targets a server you control, so that is fine here. For a server you -do not control, source the credential outside ``function_invocation_kwargs`` instead, for example by reading a -``ContextVar`` inside the provider:: +Note on where the credential comes from: +The provider below closes over ``api_key`` because the value is known before the agent is built. That is also +what authenticates the MCP handshake: entering the agent context connects the tool before any run, so a provider +that depended on per-run ``function_invocation_kwargs`` would have nothing to read at that point. - header_provider=lambda _kwargs: {"Authorization": f"Bearer {token_var.get()}"} - -which keeps it out of tool arguments while still allowing a different value per request. +When the credential only arrives with the run, pass it via ``function_invocation_kwargs`` and supply the tool +through ``run(tools=[...])`` so the connection happens inside that run. Be aware those values are shared with +every tool in the run and are filtered against each tool's server-declared ``inputSchema.properties``, so a +server that declares ``mcp_api_key`` receives it as an ordinary tool argument - ``header_provider`` reads the +kwargs without consuming them. For more authentication examples including OAuth 2.0 flows, see: - https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/clients/simple-auth-client @@ -56,15 +54,12 @@ async def api_key_auth_example(api_key: str) -> None: name="MCP tool", description="MCP tool description.", url="", - header_provider=lambda kwargs: {"Authorization": f"Bearer {kwargs['mcp_api_key']}"}, + header_provider=lambda _: {"Authorization": f"Bearer {api_key}"}, ), ) as agent: query = "Use your MCP tool to tell me what tools are available to you." print(f"User: {query}") - result = await agent.run( - query, - function_invocation_kwargs={"mcp_api_key": api_key}, - ) + result = await agent.run(query) print(f"Agent: {result.text}") From 501bd5274cf12ceeffb2ec1404fa31169e67931e Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 10 Sep 2026 13:06:50 +0000 Subject: [PATCH 003/108] Python: preserve MCP request ownership on sends (#8246) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_mcp.py | 8 +++++-- python/packages/core/tests/core/test_mcp.py | 23 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 0197d9a3535..83410bd9d56 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -58,7 +58,7 @@ from typing_extensions import Self # pragma: no cover if TYPE_CHECKING: - from httpx import AsyncClient + from httpx import AsyncClient, Request, Response from mcp import types from mcp.client.session import ClientSession from mcp.shared.context import RequestContext @@ -437,6 +437,10 @@ def _tagged_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]: def stream(self, *args: Any, **kwargs: Any) -> Any: return self._client.stream(*args, **self._tagged_kwargs(kwargs)) + async def send(self, request: Request, **kwargs: Any) -> Response: + request.extensions[_MCP_HEADER_OWNER_EXTENSION] = self._owner + return await self._client.send(request, **kwargs) + async def delete(self, *args: Any, **kwargs: Any) -> Any: return await self._client.delete(*args, **self._tagged_kwargs(kwargs)) @@ -3682,7 +3686,7 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: Returns: An async context manager for the streamable HTTP client transport. """ - from httpx import URL, AsyncClient, Request, Timeout + from httpx import URL, AsyncClient, Timeout http_client = self._httpx_client if self._header_provider is not None: diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 063ffc288aa..7102dbd7e35 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -7464,6 +7464,29 @@ async def delayed_close() -> None: await user_client.aclose() +async def test_mcp_header_scoped_client_tags_send_requests(): + """The transport wrapper must identify requests sent through AsyncClient.send.""" + import httpx + + from agent_framework._mcp import _MCP_HEADER_OWNER_EXTENSION, _MCPHeaderScopedClient + + owner = object() + observed_owners: list[object | None] = [] + + async def handle(request: httpx.Request) -> httpx.Response: + observed_owners.append(request.extensions.get(_MCP_HEADER_OWNER_EXTENSION)) + return httpx.Response(200) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as user_client: + wrapper = _MCPHeaderScopedClient(user_client, owner) + request = user_client.build_request("POST", "http://example.com/mcp") + + response = await wrapper.send(request) + + assert response.status_code == 200 + assert observed_owners == [owner] + + async def test_mcp_header_scoped_client_delegates_unwrapped_attributes(): """The transport wrapper must stay a drop-in for the caller's httpx client.""" import httpx From 1b4513dfa2f4291927319ac0a1e779042a540d92 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 10 Sep 2026 13:12:51 +0000 Subject: [PATCH 004/108] .NET: Harden LocalCodeAct OS validation (#8239) * Harden LocalCodeAct OS validation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address LocalCodeAct review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../README.md | 9 +- .../Resources/validator.py | 515 ++++++++++++++++-- ...ocalExecuteCodeFunctionIntegrationTests.cs | 90 ++- 3 files changed, 544 insertions(+), 70 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md index ef212e27115..c8ac9e694d7 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -100,14 +100,19 @@ By default, the package validates Python code against allow-lists before execution. The validator runs in its own short-lived Python subprocess with a dedicated timeout (`ProcessExecutionLimits.ValidationTimeoutSeconds`). -- **Allowed imports**: `math`, `random`, `json`, `datetime`, `pathlib`, `os` - (only `os.environ`, `os.path` attributes are reachable), etc. +- **Allowed imports**: `math`, `random`, `json`, `datetime`, `pathlib`, `os`, + etc. OS access is limited to lexical `os.path` transformations and read-only + access to the scrubbed `os.environ` mapping. - **Blocked imports**: `subprocess`, `sys`, `socket`, `importlib`, network and threading modules, etc. - **Allowed builtins**: `print`, `len`, `str`, type constructors, etc. - **Blocked builtins**: `eval`, `exec`, `compile`, `__import__`, `open`, `getattr`, `setattr`, etc. +OS-derived aliases retain the same restrictions. Filesystem-querying path +helpers, environment mutation, unknown descendants, and reflective access are +rejected by default. + See [`Resources/validator.py`](Resources/validator.py) for the full default allow-lists. diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py index 53b6ca895d0..6b904339552 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/Resources/validator.py @@ -30,7 +30,7 @@ "hashlib", "uuid", "random", - "os", # Limited to os.environ, os.path - validated via attribute access + "os", # Limited to explicit os.environ and lexical os.path chains } # Blocked imports that expose dangerous capabilities. @@ -54,12 +54,106 @@ "__builtin__", } -# Allowed `os` attribute names. Generated code may only touch `os.environ` and -# `os.path`; everything else (file I/O, process control, mutating helpers, etc.) -# is rejected by default. Users may pass a custom allow-list via -# ``allowed_os_attrs`` on the validator entry points. +# Allowed top-level `os` attribute names. Descendants are validated separately +# against complete-chain allow-lists. ALLOWED_OS_ATTRS: set[str] = {"environ", "path"} +# Lexical path helpers that do not query or mutate the filesystem. +ALLOWED_OS_PATH_ATTRS: set[str] = { + "abspath", + "basename", + "commonpath", + "commonprefix", + "dirname", + "expandvars", + "isabs", + "join", + "normcase", + "normpath", + "relpath", + "split", + "splitdrive", + "splitext", + "splitroot", +} + +# Read-only mapping helpers for the scrubbed child-process environment. +ALLOWED_OS_ENVIRON_ATTRS: set[str] = { + "copy", + "get", +} + +# Builtins that consume an OS-derived value without retaining it. +_OS_VALUE_CONSUMER_BUILTINS: frozenset[str] = frozenset({"bool", "len", "print", "repr", "str"}) + +# Collection operations are safe only for os.environ, whose values are strings. +_OS_ENVIRON_COPY_BUILTINS: frozenset[str] = frozenset( + {"dict", "enumerate", "frozenset", "iter", "list", "reversed", "set", "sorted", "tuple"} +) + +_OS_VALUE_BUILTINS: frozenset[str] = _OS_VALUE_CONSUMER_BUILTINS | _OS_ENVIRON_COPY_BUILTINS + +_SAFE_DUNDER_ATTRS: frozenset[str] = frozenset( + { + "__aenter__", + "__aexit__", + "__doc__", + "__enter__", + "__eq__", + "__exit__", + "__file__", + "__hash__", + "__init__", + "__iter__", + "__len__", + "__module__", + "__name__", + "__next__", + "__repr__", + "__str__", + } +) + +_BLOCKED_CAPABILITY_ATTRS: frozenset[str] = frozenset( + { + "__builtins__", + "_sys", + "builtins", + "connect_accepted_socket", + "create_connection", + "create_server", + "create_subprocess_exec", + "create_subprocess_shell", + "create_unix_connection", + "create_unix_server", + "getaddrinfo", + "getnameinfo", + "importlib", + "open_connection", + "open_unix_connection", + "socket", + "sock_accept", + "sock_connect", + "sock_recv", + "sock_recv_into", + "sock_recvfrom", + "sock_recvfrom_into", + "sock_sendall", + "sock_sendfile", + "sock_sendto", + "start_server", + "start_unix_server", + "subprocess", + "subprocess_exec", + "subprocess_shell", + "sys", + } +) + +_OS_ROOT_CHAIN: tuple[str, ...] = ("os",) +_OS_PATH_CHAIN: tuple[str, ...] = ("os", "path") +_OS_ENVIRON_CHAIN: tuple[str, ...] = ("os", "environ") + # Allowed builtin function names that generated code may call. # Note: getattr/setattr/hasattr/delattr are NOT included because they can bypass # AST attribute restrictions (e.g., getattr(os, 'system')('...') avoids os.system check). @@ -116,6 +210,7 @@ # Blocked builtin function names that expose dangerous capabilities. BLOCKED_BUILTINS: set[str] = { + "__builtins__", "eval", "exec", "compile", @@ -271,7 +366,9 @@ def __init__( self._allowed_builtins = allowed_builtins if allowed_builtins is not None else ALLOWED_BUILTINS self._blocked_builtins = blocked_builtins if blocked_builtins is not None else BLOCKED_BUILTINS self._allowed_os_attrs = allowed_os_attrs if allowed_os_attrs is not None else ALLOWED_OS_ATTRS - self._os_aliases: set[str] = {"os"} + self._os_aliases: dict[str, set[tuple[str, ...]]] = {"os": {_OS_ROOT_CHAIN}} + self._os_containers: dict[str, set[tuple[str, ...]]] = {} + self._shadowed_os_value_builtins: set[str] = set() def validate(self, code: str) -> None: """Validate code and raise CodeValidationError if it violates policy.""" @@ -281,7 +378,9 @@ def validate(self, code: str) -> None: raise CodeValidationError(f"Syntax error in generated code: {exc}") from exc self._errors = [] - self._os_aliases = {"os"} + self._os_aliases = {"os": {_OS_ROOT_CHAIN}} + self._os_containers = {} + self._shadowed_os_value_builtins = self._find_shadowed_os_value_builtins(tree) self.visit(tree) if self._errors: @@ -305,10 +404,17 @@ def visit_Import(self, node: ast.Import) -> None: self._errors.append(f"Import of '{alias_node.name}' is not allowed (blocked: {module_name})") elif module_name not in self._allowed_imports: self._errors.append(f"Import of '{alias_node.name}' is not allowed (not in allow-list)") + if alias_node.name == "os": - self._os_aliases.add(alias_node.asname or "os") - elif alias_node.name.startswith("os.") and alias_node.asname is None: - self._os_aliases.add("os") + self._remember_os_alias(alias_node.asname or "os", _OS_ROOT_CHAIN) + elif alias_node.name.startswith("os."): + chain = tuple(alias_node.name.split(".")) + if chain != _OS_PATH_CHAIN: + self._errors.append(f"Import of '{alias_node.name}' is not allowed") + elif alias_node.asname is not None: + self._remember_os_alias(alias_node.asname, chain) + else: + self._remember_os_alias("os", _OS_ROOT_CHAIN) self.generic_visit(node) def visit_ImportFrom(self, node: ast.ImportFrom) -> None: @@ -317,44 +423,307 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: self._errors.append("Relative imports are not allowed") return + if any(alias_node.name == "*" for alias_node in node.names): + self._errors.append(f"Wildcard import from '{node.module}' is not allowed") + module_name = node.module.split(".")[0] if module_name in self._blocked_imports: self._errors.append(f"Import from '{node.module}' is not allowed (blocked: {module_name})") elif module_name not in self._allowed_imports: self._errors.append(f"Import from '{node.module}' is not allowed (not in allow-list)") elif module_name == "os": - # Mirror the os.* attribute allow-list for ``from os import X``, - # otherwise ``from os import system`` would bypass visit_Attribute. for alias_node in node.names: - if alias_node.name not in self._allowed_os_attrs: + chain = (*tuple(node.module.split(".")), alias_node.name) + if node.module != "os" or alias_node.name not in self._allowed_os_attrs: self._errors.append(f"Import from 'os' of '{alias_node.name}' is not allowed") + else: + self._remember_os_alias(alias_node.asname or alias_node.name, chain) + else: + for alias_node in node.names: + if alias_node.name.startswith("__") and alias_node.name.endswith("__"): + self._errors.append( + f"Import from '{node.module}' of reflective attribute '{alias_node.name}' is not allowed" + ) + elif alias_node.name in {"os", "_os"}: + self._remember_os_alias(alias_node.asname or alias_node.name, _OS_ROOT_CHAIN) + elif alias_node.name in _BLOCKED_CAPABILITY_ATTRS: + self._errors.append( + f"Import from '{node.module}' of capability '{alias_node.name}' is not allowed" + ) self.generic_visit(node) + def visit_Name(self, node: ast.Name) -> None: + """Reject access to blocked builtin objects before they can be aliased.""" + if isinstance(node.ctx, ast.Load) and node.id in self._blocked_builtins: + self._errors.append(f"Access to builtin '{node.id}' is not allowed") + def visit_Assign(self, node: ast.Assign) -> None: - """Track re-bindings of the ``os`` module.""" + """Track assignments of OS-derived values.""" for target in node.targets: - self._track_os_alias_targets(target, node.value) + self._track_os_provenance(target, node.value) self.generic_visit(node) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: - """Track annotated re-bindings of the ``os`` module.""" - if ( - isinstance(node.value, ast.Name) - and node.value.id in self._os_aliases - and isinstance(node.target, ast.Name) - ): - self._os_aliases.add(node.target.id) + """Track annotated assignments of OS-derived values.""" + if node.value is not None: + self._track_os_provenance(node.target, node.value) + self.generic_visit(node) + + def visit_AugAssign(self, node: ast.AugAssign) -> None: + """Reject mutation of an OS-derived value.""" + target_direct, target_contained = self._get_os_provenance(node.target) + value_direct, value_contained = self._get_os_provenance(node.value) + if target_direct or target_contained or value_direct or value_contained: + self._errors.append("Mutation of an OS-derived value is not allowed") + self.generic_visit(node) + + def visit_For(self, node: ast.For) -> None: + """Track values extracted from containers during iteration.""" + self._track_os_iteration_target(node.target, node.iter) + self.generic_visit(node) + + def visit_AsyncFor(self, node: ast.AsyncFor) -> None: + """Track values extracted from containers during async iteration.""" + self._track_os_iteration_target(node.target, node.iter) + self.generic_visit(node) + + def visit_comprehension(self, node: ast.comprehension) -> None: + """Reject comprehensions that could extract an OS-derived object.""" + direct, contained = self._get_os_provenance(node.iter) + if contained or any(chain != _OS_ENVIRON_CHAIN for chain in direct): + self._errors.append("Comprehension over an OS-derived value is not allowed") + self.generic_visit(node) + + def visit_ListComp(self, node: ast.ListComp) -> None: + """Reject list comprehensions that retain OS-derived objects.""" + self._reject_os_derived_result(node.elt, "List comprehension") + self.generic_visit(node) + + def visit_SetComp(self, node: ast.SetComp) -> None: + """Reject set comprehensions that retain OS-derived objects.""" + self._reject_os_derived_result(node.elt, "Set comprehension") + self.generic_visit(node) + + def visit_DictComp(self, node: ast.DictComp) -> None: + """Reject dictionary comprehensions that retain OS-derived objects.""" + self._reject_os_derived_result(node.key, "Dictionary comprehension") + self._reject_os_derived_result(node.value, "Dictionary comprehension") + self.generic_visit(node) + + def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: + """Reject generator expressions that retain OS-derived objects.""" + self._reject_os_derived_result(node.elt, "Generator expression") self.generic_visit(node) - def _track_os_alias_targets(self, target: ast.AST, value: ast.AST) -> None: + def visit_Match(self, node: ast.Match) -> None: + """Reject pattern matching that could bind an OS-derived object.""" + direct, contained = self._get_os_provenance(node.subject) + if direct or contained: + self._errors.append("Pattern matching on an OS-derived value is not allowed") + self.generic_visit(node) + + def visit_Return(self, node: ast.Return) -> None: + """Reject returning OS-derived objects from local helpers.""" + if node.value is not None: + direct, contained = self._get_os_provenance(node.value) + if direct or contained: + self._errors.append("Returning an OS-derived value is not allowed") + self.generic_visit(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """Reject OS-derived function defaults.""" + self._validate_function_defaults(node.args) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + """Reject OS-derived async-function defaults.""" + self._validate_function_defaults(node.args) + self.generic_visit(node) + + def visit_Lambda(self, node: ast.Lambda) -> None: + """Reject lambdas that capture or return OS-derived objects.""" + self._validate_function_defaults(node.args) + direct, contained = self._get_os_provenance(node.body) + if direct or contained: + self._errors.append("Returning an OS-derived value is not allowed") + self.generic_visit(node) + + def _remember_os_alias(self, name: str, chain: tuple[str, ...]) -> None: + self._os_aliases.setdefault(name, set()).add(chain) + + def _remember_os_chains( + self, + target: ast.AST, + direct: set[tuple[str, ...]], + contained: set[tuple[str, ...]], + ) -> None: if isinstance(target, ast.Starred): target = target.value - if isinstance(target, ast.Name) and isinstance(value, ast.Name) and value.id in self._os_aliases: - self._os_aliases.add(target.id) - elif isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (ast.Tuple, ast.List)): - for target_item, value_item in zip(target.elts, value.elts): - self._track_os_alias_targets(target_item, value_item) + if isinstance(target, ast.Name): + if direct: + self._os_aliases.setdefault(target.id, set()).update(direct) + if contained: + self._os_containers.setdefault(target.id, set()).update(contained) + elif isinstance(target, (ast.Tuple, ast.List)): + possible = direct | contained + for target_item in target.elts: + self._remember_os_chains(target_item, possible, set()) + elif direct or contained: + self._errors.append("Storing an OS-derived value on an object is not allowed") + + def _track_os_provenance(self, target: ast.AST, value: ast.AST) -> None: + if isinstance(target, ast.Starred): + target = target.value + + if isinstance(target, (ast.Tuple, ast.List)) and isinstance(value, (ast.Tuple, ast.List)): + starred_index = next( + (index for index, target_item in enumerate(target.elts) if isinstance(target_item, ast.Starred)), + None, + ) + if starred_index is None: + for target_item, value_item in zip(target.elts, value.elts): + self._track_os_provenance(target_item, value_item) + return + + trailing_count = len(target.elts) - starred_index - 1 + for target_item, value_item in zip(target.elts[:starred_index], value.elts[:starred_index]): + self._track_os_provenance(target_item, value_item) + if trailing_count: + for target_item, value_item in zip(target.elts[-trailing_count:], value.elts[-trailing_count:]): + self._track_os_provenance(target_item, value_item) + + star_contained: set[tuple[str, ...]] = set() + middle_end = len(value.elts) - trailing_count if trailing_count else len(value.elts) + for value_item in value.elts[starred_index:middle_end]: + item_direct, item_contained = self._get_os_provenance(value_item) + star_contained.update(item_direct) + star_contained.update(item_contained) + self._remember_os_chains(target.elts[starred_index], set(), star_contained) + return + + direct, contained = self._get_os_provenance(value) + self._remember_os_chains(target, direct, contained) + + def _track_os_iteration_target(self, target: ast.AST, iterator: ast.AST) -> None: + direct, contained = self._get_os_provenance(iterator) + extracted = set(contained) + extracted.update(chain for chain in direct if chain != _OS_ENVIRON_CHAIN) + if extracted: + self._remember_os_chains(target, extracted, set()) + + def _validate_function_defaults(self, arguments: ast.arguments) -> None: + defaults = [*arguments.defaults, *(default for default in arguments.kw_defaults if default is not None)] + for default in defaults: + direct, contained = self._get_os_provenance(default) + if direct or contained: + self._errors.append("Using an OS-derived value as a function default is not allowed") + + def _reject_os_derived_result(self, node: ast.AST, expression_name: str) -> None: + direct, contained = self._get_os_provenance(node) + if direct or contained: + self._errors.append(f"{expression_name} retaining an OS-derived value is not allowed") + + def _get_os_provenance( + self, + node: ast.AST, + ) -> tuple[set[tuple[str, ...]], set[tuple[str, ...]]]: + if isinstance(node, ast.Starred): + return self._get_os_provenance(node.value) + + if isinstance(node, ast.Name): + return ( + set(self._os_aliases.get(node.id, set())), + set(self._os_containers.get(node.id, set())), + ) + + if isinstance(node, ast.Attribute): + direct, _ = self._get_os_provenance(node.value) + if not direct and node.attr in {"os", "_os"}: + return ({_OS_ROOT_CHAIN}, set()) + return ({(*chain, node.attr) for chain in direct}, set()) + + if isinstance(node, ast.Subscript): + direct, contained = self._get_os_provenance(node.value) + extracted = set(contained) + extracted.update(chain for chain in direct if chain != _OS_ENVIRON_CHAIN) + return extracted, set() + + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + contained: set[tuple[str, ...]] = set() + for item in node.elts: + item_direct, item_contained = self._get_os_provenance(item) + contained.update(item_direct) + contained.update(item_contained) + return set(), contained + + if isinstance(node, ast.Dict): + contained = set() + for item in [*node.keys, *node.values]: + if item is None: + continue + item_direct, item_contained = self._get_os_provenance(item) + contained.update(item_direct) + contained.update(item_contained) + return set(), contained + + if isinstance(node, ast.IfExp): + body_direct, body_contained = self._get_os_provenance(node.body) + else_direct, else_contained = self._get_os_provenance(node.orelse) + return body_direct | else_direct, body_contained | else_contained + + if isinstance(node, ast.BoolOp): + direct: set[tuple[str, ...]] = set() + contained: set[tuple[str, ...]] = set() + for value in node.values: + value_direct, value_contained = self._get_os_provenance(value) + direct.update(value_direct) + contained.update(value_contained) + return direct, contained + + if isinstance(node, ast.BinOp): + left_direct, left_contained = self._get_os_provenance(node.left) + right_direct, right_contained = self._get_os_provenance(node.right) + return left_direct | right_direct, left_contained | right_contained + + return set(), set() + + def _is_allowed_os_chain(self, chain: tuple[str, ...]) -> bool: + if chain == _OS_ROOT_CHAIN: + return True + if len(chain) == 2: + return chain[0] == "os" and chain[1] in self._allowed_os_attrs + if len(chain) != 3: + return False + if chain[:2] == _OS_PATH_CHAIN and "path" in self._allowed_os_attrs: + return chain[2] in ALLOWED_OS_PATH_ATTRS + if chain[:2] == _OS_ENVIRON_CHAIN and "environ" in self._allowed_os_attrs: + return chain[2] in ALLOWED_OS_ENVIRON_ATTRS + return False + + @staticmethod + def _format_os_chains(chains: set[tuple[str, ...]]) -> str: + return ", ".join(".".join(chain) for chain in sorted(chains)) + + @staticmethod + def _find_shadowed_os_value_builtins(tree: ast.AST) -> set[str]: + bound_names: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + bound_names.add(node.id) + elif isinstance(node, ast.arg): + bound_names.add(node.arg) + elif isinstance(node, ast.alias): + bound_names.add(node.asname or node.name.split(".")[0]) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + bound_names.add(node.name) + elif isinstance(node, ast.ExceptHandler) and node.name is not None: + bound_names.add(node.name) + elif isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name is not None: + bound_names.add(node.name) + elif isinstance(node, ast.MatchMapping) and node.rest is not None: + bound_names.add(node.rest) + return bound_names & _OS_VALUE_BUILTINS def visit_Call(self, node: ast.Call) -> None: """Validate function calls. @@ -371,10 +740,36 @@ def visit_Call(self, node: ast.Call) -> None: # Real builtin that wasn't explicitly allowed — reject so the allow-list is meaningful. self._errors.append(f"Call to builtin '{func_name}' is not in the allowed builtins list") - # Check for attribute access to dangerous methods + func_direct, func_contained = self._get_os_provenance(node.func) + for chain in func_direct: + if not self._is_allowed_os_chain(chain): + self._errors.append(f"Call to OS-derived function '{'.'.join(chain)}' is not allowed") + if func_contained: + chains = self._format_os_chains(func_contained) + self._errors.append(f"Calling a value containing OS-derived objects ({chains}) is not allowed") + + consumer_name = node.func.id if isinstance(node.func, ast.Name) else None + is_unshadowed_builtin = ( + consumer_name is not None + and consumer_name in _PYTHON_BUILTIN_NAMES + and consumer_name not in self._shadowed_os_value_builtins + ) + for argument in [*node.args, *(keyword_node.value for keyword_node in node.keywords)]: + direct, contained = self._get_os_provenance(argument) + consumes_value = is_unshadowed_builtin and consumer_name in _OS_VALUE_CONSUMER_BUILTINS + copies_environment = ( + is_unshadowed_builtin + and consumer_name in _OS_ENVIRON_COPY_BUILTINS + and not contained + and bool(direct) + and all(chain == _OS_ENVIRON_CHAIN for chain in direct) + ) + if (direct or contained) and not consumes_value and not copies_environment: + chains = self._format_os_chains(direct | contained) + self._errors.append(f"Passing an OS-derived value ({chains}) to a call is not allowed") + if isinstance(node.func, ast.Attribute): attr_name = node.func.attr - # Block common dangerous attribute methods if ( attr_name.startswith("__") and attr_name.endswith("__") @@ -386,42 +781,42 @@ def visit_Call(self, node: ast.Call) -> None: def visit_Attribute(self, node: ast.Attribute) -> None: """Validate attribute access.""" - # Enforce the `os` attribute allow-list. Anything outside `ALLOWED_OS_ATTRS` - # (file I/O, process control, mutating helpers, etc.) is rejected so the - # validator matches the documented `os.environ` / `os.path`-only contract. - if isinstance(node.value, ast.Name) and node.value.id in self._os_aliases and node.attr not in self._allowed_os_attrs: - self._errors.append(f"Access to os.{node.attr} is not allowed") + if node.attr in _BLOCKED_CAPABILITY_ATTRS: + self._errors.append(f"Access to capability attribute '{node.attr}' is not allowed") + + direct, _ = self._get_os_provenance(node) + _, base_contained = self._get_os_provenance(node.value) + for chain in direct: + if not self._is_allowed_os_chain(chain): + self._errors.append(f"Access to {'.'.join(chain)} is not allowed") + elif not isinstance(node.ctx, ast.Load): + self._errors.append(f"Mutation of {'.'.join(chain)} is not allowed") + if base_contained: + chains = self._format_os_chains(base_contained) + self._errors.append(f"Attribute access on a value containing OS-derived objects ({chains}) is not allowed") - # Block access to certain dangerous attributes if ( node.attr.startswith("__") and node.attr.endswith("__") - and node.attr - not in { - "__name__", - "__doc__", - "__dict__", - "__class__", - "__module__", - "__file__", - "__init__", - "__str__", - "__repr__", - "__eq__", - "__hash__", - "__len__", - "__iter__", - "__next__", - "__enter__", - "__exit__", - "__aenter__", - "__aexit__", - } + and node.attr not in _SAFE_DUNDER_ATTRS ): self._errors.append(f"Access to attribute '{node.attr}' is not allowed") self.generic_visit(node) + def visit_Subscript(self, node: ast.Subscript) -> None: + """Permit only read access to the scrubbed environment mapping.""" + direct, contained = self._get_os_provenance(node.value) + if contained: + chains = self._format_os_chains(contained) + self._errors.append(f"Subscript access to a value containing OS-derived objects ({chains}) is not allowed") + for chain in direct: + if chain != _OS_ENVIRON_CHAIN or "environ" not in self._allowed_os_attrs: + self._errors.append(f"Subscript access to {'.'.join(chain)} is not allowed") + elif not isinstance(node.ctx, ast.Load): + self._errors.append("Mutation of os.environ is not allowed") + self.generic_visit(node) + def validate_code( code: str, @@ -440,8 +835,10 @@ def validate_code( blocked_imports: Custom set of blocked module names (replaces defaults). allowed_builtins: Custom set of allowed builtin names (replaces defaults). blocked_builtins: Custom set of blocked builtin names (replaces defaults). - allowed_os_attrs: Custom set of allowed ``os`` attribute names - (replaces the default ``{"environ", "path"}`` allow-list). + allowed_os_attrs: Custom set of allowed top-level ``os`` attribute names + (replaces the default ``{"environ", "path"}`` allow-list). Nested + ``os.path`` and ``os.environ`` access remains constrained by the + built-in complete-chain policy. Raises: CodeValidationError: If the code violates the allow-list policy. diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs index c95ce790862..722f0092d73 100644 --- a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/LocalExecuteCodeFunctionIntegrationTests.cs @@ -70,29 +70,101 @@ await Assert.ThrowsAsync(async () => [InlineData("import os\n_o = os\n_o.system('id')")] [InlineData("import os as x\na = x\nb = a\nb.popen('id')")] [InlineData("import os.path\nos.system('id')")] + [InlineData("import os\nos.path.os.system('id')")] + [InlineData("import os\nos.path.os.popen('id')")] + [InlineData("import os.path as p\np.os.system('id')")] + [InlineData("from os import path as p\np.os.popen('id')")] + [InlineData("import pathlib\npathlib.os.system('id')")] + [InlineData("import random\nrandom._os.system('id')")] + [InlineData("from pathlib import os as o\no.system('id')")] + [InlineData("from random import _os as o\no.system('id')")] + [InlineData("import os\nq = os.path\nq.os.system('id')")] + [InlineData("import os\nq = os.path.os\nq.system('id')")] + [InlineData("import os\nq: object = os.path\nq.os.system('id')")] + [InlineData("import os\nq, _ = (os.path, 1)\nq.os.system('id')")] + [InlineData("import os\n[q, _] = [os.path, 1]\nq.os.system('id')")] + [InlineData("import os\nq = [os.path][0]\nq.os.system('id')")] + [InlineData("import os\nq = (os.path,)[0]\nq.os.system('id')")] + [InlineData("import os\nq = {'path': os.path}['path']\nq.os.system('id')")] + [InlineData("import os\nq = ([os] + [])[0]\nq.system('id')")] + [InlineData("import os\nq = [value for value in [os]][0]\nq.system('id')")] + [InlineData("import os\n_, *values = (1, 2, os)\nvalues[1].system('id')")] + [InlineData("import os\nvalues = []\nvalues += [os]\nvalues[0].system('id')")] + [InlineData("import os\nos.path.__dict__['os'].system('id')")] + [InlineData("import os\nos.path.os.__dict__['system']('id')")] + [InlineData("import os\ngetattr(os.path, 'os').system('id')")] + [InlineData("import os\nos.environ['KEY'] = 'value'")] + [InlineData("import os\nos.environ.update({'KEY': 'value'})")] + [InlineData("import os\nprint(os.path.exists('file'))")] + [InlineData("import os\ndef get_path():\n return os.path\nget_path().os.system('id')")] + [InlineData("import os\ndef identity(value):\n return value\nidentity(os.path).os.system('id')")] + [InlineData("import typing\ntyping.sys.modules['os'].system('id')")] + [InlineData("import asyncio\nawait asyncio.create_subprocess_exec('id')")] + [InlineData("import asyncio\nawait asyncio.open_connection('localhost', 80)")] + [InlineData("from typing import sys as s\ns.modules['os'].system('id')")] + [InlineData("from typing import __dict__ as namespace\nnamespace['__builtins__']['__import__']('os').system('id')")] + [InlineData("__builtins__['__import__']('os').system('id')")] + [InlineData("load = __import__\nload('os').system('id')")] + [InlineData("namespace = globals\nnamespace()['__builtins__']['__import__']('os').system('id')")] + [InlineData("from asyncio import create_subprocess_exec as start\nawait start('id')")] + [InlineData("from asyncio import open_connection as connect\nawait connect('localhost', 80)")] + [InlineData("import os\nmatch (os.environ,):\n case (env,):\n env['KEY'] = 'value'")] + [InlineData("import os\ndef print(value):\n return value\nq = print(os)\nq.system('id')")] + [InlineData("import os\ndef list(value):\n return value\nenv = list(os.environ)\nenv['KEY'] = 'value'")] + [InlineData("import os\ndef use(print):\n q = print(os)\n q.system('id')\nuse(lambda value: value)")] + [InlineData("import os\nos.environ.keys()._mapping['KEY'] = 'value'")] + [InlineData("import os\nos.environ.items()._mapping['KEY'] = 'value'")] + [InlineData("import os\nos.environ.values()._mapping['KEY'] = 'value'")] [InlineData("import os\na, _ = (os, 1)\na.system('id')")] [InlineData("import os\n[a, _] = [os, 1]\na.system('id')")] [InlineData("import os\nx: object = os\nx.system('id')")] - public async Task ExecuteCode_ValidationBlocksDisallowedOsAccessAsync(string code) + public async Task ExecuteCode_ValidationBlocksDisallowedOsAccessBeforeRunnerStartsAsync(string code) { SkipIfNoPython(); - var function = new LocalExecuteCodeFunction(s_python!); - - var args = new AIFunctionArguments + // Arrange + var tempDir = Directory.CreateTempSubdirectory("localcodeact-runner-marker-").FullName; + try { - ["code"] = code, - }; + var markerPath = Path.Combine(tempDir, "runner-started"); + var runnerPath = Path.Combine(tempDir, "marker_runner.py"); + File.WriteAllText( + runnerPath, + $"from pathlib import Path\nPath({JsonSerializer.Serialize(markerPath)}).touch()\n"); - var ex = await Assert.ThrowsAsync(async () => - await function.InvokeAsync(args, CancellationToken.None)); - Assert.Contains("os.", ex.Message, StringComparison.Ordinal); + var function = new LocalExecuteCodeFunction( + s_python!, + new LocalCodeActProviderOptions { RunnerScriptPath = runnerPath }); + var args = new AIFunctionArguments + { + ["code"] = code, + }; + + // Act + var exception = await Record.ExceptionAsync( + async () => await function.InvokeAsync(args, CancellationToken.None)); + + // Assert + Assert.False(File.Exists(markerPath), "The runner started before validation completed."); + var validationException = Assert.IsType(exception); + Assert.Contains("not allowed", validationException.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } } [Theory] [InlineData("import os\nprint(os.environ.get('PATH') is not None)")] + [InlineData("import os\nprint(os.environ['PATH'] if 'PATH' in os.environ else '')")] + [InlineData("import os\nenv = os.environ\nprint(list(env))")] [InlineData("import os as x\nprint(x.path.join('a', 'b'))")] [InlineData("import os.path as p\nprint(p.join('a', 'b'))")] + [InlineData("from os import path as p\nprint(p.basename('a/b'))")] + [InlineData("import os\np = os.path\nprint(p.normpath('a/../b'))")] + [InlineData("import os\np, env = (os.path, os.environ)\nprint(p.join('a', 'b'))\nprint(env.get('PATH'))")] + [InlineData("import os\njoin = os.path.join\nprint(join('a', 'b'))")] public async Task ExecuteCode_AllowsPermittedOsAccessAsync(string code) { SkipIfNoPython(); From 8a546111de85fdcf7e1cb0fdc1f7de1b001078e8 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 10 Sep 2026 13:26:09 +0000 Subject: [PATCH 005/108] .NET: upgrades xunit and other dependencies (#8202) * chore: upgrades xunit dependencies chore: upgrades bcl dependencies chore: upgrades additional system dependencies chore: upgrades dotnetenv chore: upgrades mcp dependencies chore: upgrades microsoft agents object model dependencies Signed-off-by: Vincent Biret * ci: updates to the new switch * fix: adds missing method implementations * Add InvokeMcpToolAction unsupported-action test Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * Update xUnit JUnit artifact upload pattern Retain the existing .junit glob and include xUnit 4's .junit.xml output so non-PR integration test reports are uploaded for trend aggregation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * tests: Add VoiceAuthenticate.Builder to test cases Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * tests: disables parallelization for integration tests to avoid swamping the service --------- Signed-off-by: Vincent Biret Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/dotnet-build-and-test.yml | 6 ++- dotnet/Directory.Packages.props | 37 +++++++++---------- .../Interpreter/WorkflowActionVisitor.cs | 4 ++ dotnet/tests/Directory.Build.props | 1 - ...ting.IntegrationTests.TestContainer.csproj | 1 - .../DevUIAccessControlTests.cs | 2 + .../DeclarativeWorkflowTest.cs | 2 + 7 files changed, 30 insertions(+), 23 deletions(-) diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml index 1d7a8423b91..02ba53477d6 100644 --- a/.github/workflows/dotnet-build-and-test.yml +++ b/.github/workflows/dotnet-build-and-test.yml @@ -434,7 +434,7 @@ jobs: -c ${{ matrix.configuration }} ` --no-build -v Normal ` --report-xunit-trx ` - --report-junit ` + --report-xunit-junit ` --results-directory ../IntegrationTestResults/ ` --ignore-exit-code 8 ` --filter-not-trait "Category=IntegrationDisabled" ` @@ -489,7 +489,9 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dotnet-test-results-${{ matrix.targetFramework }}-${{ matrix.os }} - path: IntegrationTestResults/**/*.junit + path: | + IntegrationTestResults/**/*.junit + IntegrationTestResults/**/*.junit.xml if-no-files-found: ignore dotnet-upload-coverage: diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 1a55bedc8f4..e1b56812593 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -32,7 +32,7 @@ - + @@ -45,15 +45,15 @@ - + - + - - - + + + @@ -78,7 +78,7 @@ - + @@ -119,8 +119,8 @@ - - + + @@ -133,9 +133,9 @@ - - - + + + @@ -144,14 +144,13 @@ - - - - + + + + - - - + + diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs index 1cd1b2bc941..08a7bdb1bbe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/WorkflowActionVisitor.cs @@ -559,6 +559,8 @@ protected override void Visit(HttpRequestAction item) protected override void Visit(InvokeConnectorAction item) => this.NotSupported(item); + protected override void Visit(InvokeMcpToolAction item) => this.NotSupported(item); + protected override void Visit(InvokeCustomModelAction item) => this.NotSupported(item); protected override void Visit(InvokeFlowAction item) => this.NotSupported(item); @@ -573,6 +575,8 @@ protected override void Visit(HttpRequestAction item) protected override void Visit(OAuthInput item) => this.NotSupported(item); + protected override void Visit(VoiceAuthenticate item) => this.NotSupported(item); + protected override void Visit(BeginDialog item) => this.NotSupported(item); protected override void Visit(UnknownDialogAction item) => this.NotSupported(item); diff --git a/dotnet/tests/Directory.Build.props b/dotnet/tests/Directory.Build.props index c4bfc0b0b5d..476e27c55a4 100644 --- a/dotnet/tests/Directory.Build.props +++ b/dotnet/tests/Directory.Build.props @@ -20,7 +20,6 @@ - diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj index 21492c77b22..a2bc80443ba 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj @@ -17,7 +17,6 @@ - diff --git a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs index dccb0ce938a..8f6266cc59b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DevUI.UnitTests/DevUIAccessControlTests.cs @@ -12,6 +12,8 @@ using Microsoft.Extensions.DependencyInjection; using Moq; +[assembly: Xunit.v3.Parallelization(Mode = Xunit.Sdk.ParallelMode.None)] + namespace Microsoft.Agents.AI.DevUI.UnitTests; public class DevUIAccessControlTests diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index d3e828fef65..f01b91e5e1a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -203,6 +203,8 @@ public async Task ExecuteActionAsync(string workflowFile, int expectedCount, str [InlineData(typeof(GetConversationMembers.Builder))] [InlineData(typeof(InvokeAIBuilderModelAction.Builder))] [InlineData(typeof(InvokeConnectorAction.Builder))] + [InlineData(typeof(InvokeMcpToolAction.Builder))] + [InlineData(typeof(VoiceAuthenticate.Builder))] [InlineData(typeof(InvokeCustomModelAction.Builder))] [InlineData(typeof(InvokeFlowAction.Builder))] [InlineData(typeof(InvokeSkillAction.Builder))] From 4b2f3a33d53e648a59bdb2eb17eeb46ab219d03c Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 10 Sep 2026 13:26:46 +0000 Subject: [PATCH 006/108] .NET: ci/promote removed apis (#8229) * ci: removes promote script and replaces by dedicated action Signed-off-by: Vincent Biret * ci: fixes the path Signed-off-by: Vincent Biret * ci: upgrades task to version that pins dependencies Signed-off-by: Vincent Biret * ci: upgrades to latest action version Signed-off-by: Vincent Biret --------- Signed-off-by: Vincent Biret --- .github/workflows/promote-shipped-apis.yml | 8 +-- dotnet/eng/scripts/promote-shipped-apis.ps1 | 54 --------------------- 2 files changed, 5 insertions(+), 57 deletions(-) delete mode 100644 dotnet/eng/scripts/promote-shipped-apis.ps1 diff --git a/.github/workflows/promote-shipped-apis.yml b/.github/workflows/promote-shipped-apis.yml index fb4d44ffe41..5fd5290a236 100644 --- a/.github/workflows/promote-shipped-apis.yml +++ b/.github/workflows/promote-shipped-apis.yml @@ -79,9 +79,11 @@ jobs: git checkout $prBranch git merge origin/$branch -m "Merge $branch into promote-shipped-apis branch" - - name: Run public API promotion script - shell: pwsh - run: ./dotnet/eng/scripts/promote-shipped-apis.ps1 + - name: Promote public API exports + id: promote + uses: BinkyLabs/public-api-promote@724e672702a3e55a06e4b1355183397d44f9f390 # v1.0.3 + with: + unshipped-glob: 'dotnet/src/**/*.Unshipped.txt' - name: Check for changes id: check_changes diff --git a/dotnet/eng/scripts/promote-shipped-apis.ps1 b/dotnet/eng/scripts/promote-shipped-apis.ps1 deleted file mode 100644 index f7140452f2f..00000000000 --- a/dotnet/eng/scripts/promote-shipped-apis.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -param( - [string] $Path = (Join-Path $PSScriptRoot '..\..\src') -) - -$ErrorActionPreference = 'Stop' - -$root = Resolve-Path $Path -$encoding = [System.Text.UTF8Encoding]::new($true) -$nullableHeader = '#nullable enable' -$promotedFiles = 0 -$promotedApis = 0 - -Get-ChildItem -Path $root -Recurse -Filter 'PublicAPI.Unshipped.txt' | ForEach-Object { - $unshippedFile = $_.FullName - $shippedFile = Join-Path $_.DirectoryName 'PublicAPI.Shipped.txt' - - $unshippedLines = if ((Get-Item $unshippedFile).Length -gt 0) { - Get-Content -Path $unshippedFile -Encoding UTF8 | Where-Object { - -not [string]::IsNullOrWhiteSpace($_) -and $_ -ne $nullableHeader - } - } - else { - @() - } - - if ($unshippedLines.Count -eq 0) { - return - } - - $allLines = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::Ordinal) - if (Test-Path $shippedFile) { - Get-Content -Path $shippedFile -Encoding UTF8 | Where-Object { - -not [string]::IsNullOrWhiteSpace($_) -and $_ -ne $nullableHeader - } | ForEach-Object { - $null = $allLines.Add($_) - } - } - - $unshippedLines | ForEach-Object { - $null = $allLines.Add($_) - } - - $shippedLines = @($nullableHeader) + @($allLines) - $shippedContent = [string]::Join("`n", $shippedLines) + "`n" - - [System.IO.File]::WriteAllText($shippedFile, $shippedContent, $encoding) - [System.IO.File]::WriteAllText($unshippedFile, "$nullableHeader`n", $encoding) - - $promotedFiles++ - $promotedApis += $unshippedLines.Count - Write-Host "Promoted $($unshippedLines.Count) public API entries in $($_.Directory.Name)." -} - -Write-Host "Promoted $promotedApis public API baseline entries across $promotedFiles baseline file(s)." From 5be4c7896784cc753150dd0919909e67f15ad0dc Mon Sep 17 00:00:00 2001 From: CoralGarden52 <97677340+CoralGarden52@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:42 +0000 Subject: [PATCH 007/108] Python: deduplicate MessagePack file history writes (#8224) --- .../core/agent_framework/_sessions.py | 6 ++- .../packages/core/tests/core/test_sessions.py | 50 ++++++++++++++++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/_sessions.py b/python/packages/core/agent_framework/_sessions.py index da8cfbcb318..9fcce18a071 100644 --- a/python/packages/core/agent_framework/_sessions.py +++ b/python/packages/core/agent_framework/_sessions.py @@ -2335,8 +2335,12 @@ def _append_messages() -> None: for message in new_messages: file_handle.write(f"{self._serialize_json_message(message)}\n") return + existing_messages = self._read_msgpack_messages(file_path) if file_path.exists() else [] + new_messages = filter_new_messages(existing_messages, messages) + if not new_messages: + return with file_path.open("ab") as file_handle: - for message in messages: + for message in new_messages: serialized = _DEFAULT_MSGPACK_ENCODER.encode(message.to_dict()) file_handle.write(len(serialized).to_bytes(self._MSGPACK_RECORD_HEADER_BYTES, "big")) file_handle.write(serialized) diff --git a/python/packages/core/tests/core/test_sessions.py b/python/packages/core/tests/core/test_sessions.py index 193859d3aa8..71457093d84 100644 --- a/python/packages/core/tests/core/test_sessions.py +++ b/python/packages/core/tests/core/test_sessions.py @@ -1636,6 +1636,28 @@ async def test_stores_and_loads_length_prefixed_msgpack(self, tmp_path: Path) -> assert first_record_length > 0 assert raw[4 : 4 + first_record_length] == msgspec.msgpack.encode(messages[0].to_dict()) + @pytest.mark.parametrize("serialization_format", ["json", "msgpack"]) + async def test_save_messages_deduplicates_replayed_transcript( + self, tmp_path: Path, serialization_format: Literal["json", "msgpack"] + ) -> None: + provider = FileHistoryProvider(tmp_path, serialization_format=serialization_format) + first_turn = [ + Message(role="user", contents=["hello"]), + Message(role="assistant", contents=["hi there"]), + ] + full_transcript = [ + Message(role="user", contents=["hello"]), + Message(role="assistant", contents=["hi there"]), + Message(role="user", contents=["follow-up"]), + Message(role="assistant", contents=["reply"]), + ] + + await provider.save_messages("replayed-transcript", first_turn) + await provider.save_messages("replayed-transcript", full_transcript) + + loaded = await provider.get_messages("replayed-transcript") + assert [message.text for message in loaded] == ["hello", "hi there", "follow-up", "reply"] + @pytest.mark.parametrize("serialization_format", ["json", "msgpack"]) async def test_round_trips_marked_refusal_text( self, tmp_path: Path, serialization_format: Literal["json", "msgpack"] @@ -1913,9 +1935,12 @@ def tracked_open(path: Path, *args: Any, **kwargs: Any) -> Any: loaded = await provider.get_messages(session_id) assert [message.text for message in loaded] == ["first", "second"] - async def test_save_messages_deduplicates_identical_messages(self, tmp_path: Path) -> None: + @pytest.mark.parametrize("serialization_format", ["json", "msgpack"]) + async def test_save_messages_deduplicates_identical_messages( + self, tmp_path: Path, serialization_format: Literal["json", "msgpack"] + ) -> None: """Test that FileHistoryProvider does not re-append already persisted messages.""" - provider = FileHistoryProvider(tmp_path) + provider = FileHistoryProvider(tmp_path, serialization_format=serialization_format) msg1 = Message(role="user", contents=["hello"]) msg2 = Message(role="assistant", contents=["hi there"]) @@ -1928,9 +1953,12 @@ async def test_save_messages_deduplicates_identical_messages(self, tmp_path: Pat loaded = await provider.get_messages("s1") assert len(loaded) == 2 - async def test_save_messages_only_appends_new_messages(self, tmp_path: Path) -> None: + @pytest.mark.parametrize("serialization_format", ["json", "msgpack"]) + async def test_save_messages_only_appends_new_messages( + self, tmp_path: Path, serialization_format: Literal["json", "msgpack"] + ) -> None: """Test that FileHistoryProvider filters out old messages and only appends new ones""" - provider = FileHistoryProvider(tmp_path) + provider = FileHistoryProvider(tmp_path, serialization_format=serialization_format) msg1 = Message(role="user", contents=["hello"]) msg2 = Message(role="assistant", contents=["hi there"]) @@ -1945,9 +1973,12 @@ async def test_save_messages_only_appends_new_messages(self, tmp_path: Path) -> assert len(loaded) == 3 assert loaded[2].text == "how are you?" - async def test_save_messages_different_roles_same_text_not_deduplicated(self, tmp_path: Path) -> None: + @pytest.mark.parametrize("serialization_format", ["json", "msgpack"]) + async def test_save_messages_different_roles_same_text_not_deduplicated( + self, tmp_path: Path, serialization_format: Literal["json", "msgpack"] + ) -> None: """Test that messages with the same text but different roles are kept separate.""" - provider = FileHistoryProvider(tmp_path) + provider = FileHistoryProvider(tmp_path, serialization_format=serialization_format) msg1 = Message(role="user", contents=["ping"]) msg2 = Message(role="assistant", contents=["ping"]) @@ -1974,9 +2005,12 @@ async def test_deduplication_file_integrity(self, tmp_path: Path) -> None: raw_lines = (await asyncio.to_thread(session_file.read_text, encoding="utf-8")).splitlines() assert len(raw_lines) == 3 - async def test_save_messages_preserves_duplicate_content(self, tmp_path: Path) -> None: + @pytest.mark.parametrize("serialization_format", ["json", "msgpack"]) + async def test_save_messages_preserves_duplicate_content( + self, tmp_path: Path, serialization_format: Literal["json", "msgpack"] + ) -> None: """Test that two identical user turns in the same batch are both persisted.""" - provider = FileHistoryProvider(tmp_path) + provider = FileHistoryProvider(tmp_path, serialization_format=serialization_format) yes_1 = Message(role="user", contents=["yes"]) yes_2 = Message(role="user", contents=["yes"]) From c457acaccf1942a0a12474ff4c0bd3c596f7a66c Mon Sep 17 00:00:00 2001 From: Leo Camus Date: Thu, 10 Sep 2026 14:36:53 +0000 Subject: [PATCH 008/108] Python: reset $LASTEXITCODE per command in persistent PowerShell sessions (#8206) * Python: reset $LASTEXITCODE per command in persistent PowerShell sessions $LASTEXITCODE is a session-wide automatic variable that only native (external) processes update, so in a long-lived session it stays set after such a command. Every later cmdlet-only command then took the $LASTEXITCODE branch in the sentinel script and reported that stale value instead of 0. Clear it in the global scope before invoking the user command so a non-null value can only have come from this command. The global scope qualifier matters: assigning the unqualified name inside the script block shadows the global the engine writes to, which would hide real native exit codes. * Python: snapshot $LASTEXITCODE instead of clearing it Review feedback: clearing $global:LASTEXITCODE before the user's command destroys state persistent mode exists to carry. After `cmd /c exit 3`, a following `Write-Output $LASTEXITCODE` printed nothing instead of 3. Snapshot the value before the command and compare afterwards instead. The variable is never assigned, so the user's own commands still read it, and a value that differs can only have been written by this command. $? is read on the statement right after Invoke-Expression, since anything else clobbers it. The regression test now also asserts the readback prints 3 while its own exit code is 0, which fails against the previous approach. --- .../agent_framework_tools/shell/_session.py | 15 +++++++++-- .../tools/tests/test_local_shell_tool.py | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/python/packages/tools/agent_framework_tools/shell/_session.py b/python/packages/tools/agent_framework_tools/shell/_session.py index e25c9f75ee1..7af4771d4b6 100644 --- a/python/packages/tools/agent_framework_tools/shell/_session.py +++ b/python/packages/tools/agent_framework_tools/shell/_session.py @@ -350,11 +350,22 @@ def _build_script(self, command: str, sentinel: str) -> str: return ( "& {" " $__af_rc = 0;" + # $LASTEXITCODE is a session-wide automatic variable that only + # native (external) processes update, so a value left by an + # earlier command is still there when a cmdlet-only command + # runs. Snapshot it instead of clearing it: the variable stays + # readable by the user's own commands, which persistent mode is + # meant to preserve, and a value that differs afterwards can + # only have been written by this command. + " $__af_last = $LASTEXITCODE;" " try {" f" $__af_cmd = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded}'));" " Invoke-Expression $__af_cmd;" - " if ($LASTEXITCODE -ne $null) { $__af_rc = $LASTEXITCODE }" - " elseif (-not $?) { $__af_rc = 1 }" + # $? has to be read on the very next statement: anything else + # in between overwrites it. + " $__af_ok = $?;" + " if ($LASTEXITCODE -ne $__af_last) { $__af_rc = $LASTEXITCODE }" + " elseif (-not $__af_ok) { $__af_rc = 1 }" " } catch {" " [Console]::Error.WriteLine($_.ToString());" " $__af_rc = 1" diff --git a/python/packages/tools/tests/test_local_shell_tool.py b/python/packages/tools/tests/test_local_shell_tool.py index d47935c1f75..41e23b60c41 100644 --- a/python/packages/tools/tests/test_local_shell_tool.py +++ b/python/packages/tools/tests/test_local_shell_tool.py @@ -245,6 +245,32 @@ async def test_persistent_powershell_propagates_cmdlet_error() -> None: assert result.stderr # message surfaced +@pytest.mark.skipif(sys.platform != "win32", reason="PowerShell-specific exit-code handling") +async def test_persistent_powershell_does_not_inherit_previous_exit_code() -> None: + """A cmdlet-only command must not report the rc of an earlier native command. + + ``$LASTEXITCODE`` is a session-wide automatic variable that only native + (external) executables update, so it stays set after such a command and + would otherwise be reported for every later cmdlet-only command. + """ + async with LocalShellTool(mode="persistent", approval_mode="never_require", acknowledge_unsafe=True) as tool: + failing = await tool.run("cmd /c exit 3") + assert failing.exit_code == 3 + + succeeding = await tool.run("Write-Output ok") + assert succeeding.exit_code == 0, f"inherited stale rc {succeeding.exit_code} from the previous command" + assert "ok" in succeeding.stdout + + # The fix must not cost the session state persistent mode exists for: + # the user's own command can still read the previous native exit code, + # while the command reporting it exits 0 itself. + readback = await tool.run("Write-Output $LASTEXITCODE") + assert readback.exit_code == 0 + assert readback.stdout.strip() == "3", ( + f"$LASTEXITCODE no longer visible to the user's command: {readback.stdout!r}" + ) + + @pytest.mark.skipif(sys.platform != "win32", reason="PowerShell-specific encoding") async def test_persistent_powershell_utf8_roundtrip() -> None: """Non-ASCII output should round-trip without mojibake.""" From d7823b2f3343f04b94b4d3767ef72532a568a72d Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 10 Sep 2026 14:58:13 +0000 Subject: [PATCH 009/108] .NET: fix: do not forward headers on redirect (#8164) * fix: do not forward headers on redirect Signed-off-by: Vincent Biret * tests: use mocked handlers instead of simulated endpoints for reliability Signed-off-by: Vincent Biret * docs: Clarify redirect handling in DefaultHttpRequestHandler Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore: Improve exception handling for invalid request URI Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: fixes xml doc structure Signed-off-by: Vincent Biret * tests: linting Signed-off-by: Vincent Biret * perf: only read headers for redirect responses Signed-off-by: Vincent Biret * fix: aggregate timeout across redirects Signed-off-by: Vincent Biret * fix: prevent https to http redirects Signed-off-by: Vincent Biret * fix: do not reject provider clients on initial request Signed-off-by: Vincent Biret * tests: refactor to avoid missing disposals Signed-off-by: Vincent Biret * fix: do not rely on specific headers names Signed-off-by: Vincent Biret * fix: address redirect review feedback Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * tests: ensure responses are disposed Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * chore: suppression of linting rule Signed-off-by: Vincent Biret * fix: reject redirect with body to different origin Signed-off-by: Vincent Biret * fix: timeout in dotnet fx as well Signed-off-by: Vincent Biret * fix: return the redirect response instead of throwing Signed-off-by: Vincent Biret * Apply batched suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding 'Missing Dispose call on local IDisposable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * chore: linting Signed-off-by: Vincent Biret --------- Signed-off-by: Vincent Biret Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../DefaultHttpRequestHandler.cs | 197 +++++- .../DefaultHttpRequestHandlerTests.cs | 621 ++++++++++++++++++ 2 files changed, 788 insertions(+), 30 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 606a716c207..72ad1d57409 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Threading; @@ -24,9 +26,17 @@ namespace Microsoft.Agents.AI.Workflows.Declarative; /// The handler applies the per-request using a linked /// so it does not mutate on shared instances. /// +/// +/// Redirects are handled by this handler only when using its internally owned client, which disables automatic +/// redirects so per-request headers are not forwarded to redirect destinations. If a supplied client returns a +/// redirect response, this handler does not follow it because the client's redirect behavior is opaque. Supplied +/// clients should disable automatic redirects and handle redirect responses before returning them to this handler. +/// /// public sealed class DefaultHttpRequestHandler : IHttpRequestHandler, IAsyncDisposable { + private const int MaxAutomaticRedirections = 50; + private readonly Func>? _httpClientProvider; private readonly Lazy _ownedHttpClient; @@ -80,7 +90,7 @@ public DefaultHttpRequestHandler(HttpClient httpClient) public DefaultHttpRequestHandler(Func>? httpClientProvider) { this._httpClientProvider = httpClientProvider; - this._ownedHttpClient = new Lazy(() => new HttpClient(), LazyThreadSafetyMode.ExecutionAndPublication); + this._ownedHttpClient = new Lazy(CreateOwnedHttpClient, LazyThreadSafetyMode.ExecutionAndPublication); } private static Func> CreateSingleClientProvider(HttpClient httpClient) @@ -111,15 +121,8 @@ public async Task SendAsync(HttpRequestInfo request, Cancella throw new ArgumentException("Request method must be provided.", nameof(request)); } - HttpClient? providedClient = null; - if (this._httpClientProvider is not null) - { - providedClient = await this._httpClientProvider(request, cancellationToken).ConfigureAwait(false); - } - - HttpClient client = providedClient ?? this._ownedHttpClient.Value; - - using HttpRequestMessage httpRequest = BuildHttpRequestMessage(request); + HttpRequestInfo currentRequest = request; + Uri currentUri = CreateAbsoluteUri(ResolveRequestUri(request)); using CancellationTokenSource? timeoutCts = request.Timeout is { } timeout && timeout > TimeSpan.Zero ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken) @@ -129,32 +132,76 @@ public async Task SendAsync(HttpRequestInfo request, Cancella CancellationToken effectiveToken = timeoutCts?.Token ?? cancellationToken; - using HttpResponseMessage httpResponse = await client - .SendAsync(httpRequest, HttpCompletionOption.ResponseContentRead, effectiveToken) - .ConfigureAwait(false); + for (int redirectCount = 0; redirectCount <= MaxAutomaticRedirections; redirectCount++) + { + HttpClient? providedClient = null; + if (this._httpClientProvider is not null) + { + providedClient = await this._httpClientProvider(currentRequest, effectiveToken).ConfigureAwait(false); + } + + HttpClient client = providedClient ?? this._ownedHttpClient.Value; + + using HttpRequestMessage httpRequest = BuildHttpRequestMessage(currentRequest); + + using HttpResponseMessage httpResponse = await client + .SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, effectiveToken) + .ConfigureAwait(false); + + if (providedClient is null && + TryCreateRedirectRequest(httpResponse, currentRequest, currentUri, out HttpRequestInfo? redirectRequest, out Uri? redirectUri)) + { + currentRequest = redirectRequest; + currentUri = redirectUri; + continue; + } + + string? body = httpResponse.Content is null + ? null + : await ReadResponseBodyAsStringAsync(httpResponse.Content, effectiveToken).ConfigureAwait(false); - string? body = httpResponse.Content is null - ? null + Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); + AppendHeaders(headers, httpResponse.Headers); + if (httpResponse.Content is not null) + { + AppendHeaders(headers, httpResponse.Content.Headers); + } + + return new HttpRequestResult + { + StatusCode = (int)httpResponse.StatusCode, + IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, + Body = body, + Headers = headers, + }; + } + + throw new HttpRequestException($"The maximum number of HTTP redirects ({MaxAutomaticRedirections}) was exceeded."); + } + + private static async Task ReadResponseBodyAsStringAsync(HttpContent content, CancellationToken cancellationToken) + { #if NET - : await httpResponse.Content.ReadAsStringAsync(effectiveToken).ConfigureAwait(false); + return await content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); #else - : await httpResponse.Content.ReadAsStringAsync().ConfigureAwait(false); -#endif - - Dictionary> headers = new(StringComparer.OrdinalIgnoreCase); - AppendHeaders(headers, httpResponse.Headers); - if (httpResponse.Content is not null) + Task readTask = content.ReadAsStringAsync(); + Task cancellationTask = Task.Delay(Timeout.Infinite, cancellationToken); + Task completedTask = await Task.WhenAny(readTask, cancellationTask).ConfigureAwait(false); + if (completedTask == readTask) { - AppendHeaders(headers, httpResponse.Content.Headers); + return await readTask.ConfigureAwait(false); } - return new HttpRequestResult - { - StatusCode = (int)httpResponse.StatusCode, - IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, - Body = body, - Headers = headers, - }; + content.Dispose(); + _ = readTask.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + cancellationToken.ThrowIfCancellationRequested(); + throw new OperationCanceledException(cancellationToken); +#endif } /// @@ -213,6 +260,96 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques return httpRequest; } + private static HttpClient CreateOwnedHttpClient() + { + HttpClientHandler handler = new() + { + AllowAutoRedirect = false, + UseCookies = false, + CheckCertificateRevocationList = true + }; + + return new HttpClient(handler); + } + + private static Uri CreateAbsoluteUri(string requestUri) + { + if (!Uri.TryCreate(requestUri, UriKind.Absolute, out Uri? uri)) + { + throw new ArgumentException("Request URL must be an absolute URL.", nameof(requestUri)); + } + + return uri; + } + + private static bool TryCreateRedirectRequest( + HttpResponseMessage response, + HttpRequestInfo currentRequest, + Uri currentUri, + [NotNullWhen(true)] out HttpRequestInfo? redirectRequest, + [NotNullWhen(true)] out Uri? redirectUri) + { + redirectRequest = null; + redirectUri = null; + + if (!IsRedirectStatusCode(response.StatusCode) || response.Headers.Location is null) + { + return false; + } + + redirectUri = response.Headers.Location.IsAbsoluteUri + ? response.Headers.Location + : new Uri(currentUri, response.Headers.Location); + + if (IsHttpsToHttpRedirect(currentUri, redirectUri)) + { + throw new HttpRequestException("Redirects from HTTPS to HTTP are not allowed."); + } + + bool rewriteToGet = ShouldRewriteRedirectMethodToGet(response.StatusCode, currentRequest.Method); + if (!rewriteToGet && currentRequest.Body is not null && !HaveSameOrigin(currentUri, redirectUri)) + { + throw new HttpRequestException("Redirects that preserve the request body to a different origin are not allowed."); + } + + redirectRequest = new HttpRequestInfo + { + Method = rewriteToGet ? "GET" : currentRequest.Method, + Url = redirectUri.ToString(), + Body = rewriteToGet ? null : currentRequest.Body, + BodyContentType = rewriteToGet ? null : currentRequest.BodyContentType, + Timeout = currentRequest.Timeout, + ConnectionName = currentRequest.ConnectionName, + }; + + return true; + } + + private static bool IsRedirectStatusCode(HttpStatusCode statusCode) + { + int code = (int)statusCode; + return code is 301 or 302 or 303 or 307 or 308; + } + + private static bool ShouldRewriteRedirectMethodToGet(HttpStatusCode statusCode, string method) + { + string normalized = method.Trim().ToUpperInvariant(); + int code = (int)statusCode; + return code == 303 || ((code == 301 || code == 302) && string.Equals(normalized, "POST", StringComparison.Ordinal)); + } + + private static bool IsHttpsToHttpRedirect(Uri currentUri, Uri redirectUri) => + string.Equals(currentUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(redirectUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase); + + private static bool HaveSameOrigin(Uri currentUri, Uri redirectUri) => + Uri.Compare( + currentUri, + redirectUri, + UriComponents.SchemeAndServer, + UriFormat.Unescaped, + StringComparison.OrdinalIgnoreCase) == 0; + private static HttpMethod ResolveMethod(string method) { string normalized = method.Trim().ToUpperInvariant(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 6970bc336c2..33dbfd17cef 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -2,8 +2,10 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net; using System.Net.Http; +using System.Reflection; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -364,6 +366,254 @@ public async Task SendAsyncTimeoutCancelsRequestAsync() await Assert.ThrowsAnyAsync(actAsync); } + [Fact] + public async Task SendAsyncTimeoutCancelsResponseBodyReadAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StallingContent(), + }; + TestHttpMessageHandler messageHandler = new((_, _) => + { + requestCount++; +#pragma warning disable CA2025 // Do not pass 'IDisposable' instances into unawaited tasks + return Task.FromResult(response); +#pragma warning restore CA2025 // Do not pass 'IDisposable' instances into unawaited tasks + }); + + using HttpClient httpClient = new(messageHandler); +#pragma warning disable CA2025 // Do not pass 'IDisposable' instances into unawaited tasks + await using DefaultHttpRequestHandler handler = new((_, _) => Task.FromResult(httpClient)); +#pragma warning restore CA2025 // Do not pass 'IDisposable' instances into unawaited tasks + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Timeout = TimeSpan.FromMilliseconds(50), + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + await Assert.ThrowsAnyAsync(actAsync); + Assert.Equal(1, requestCount); + } + + [Fact] + public async Task SendAsyncTimeoutAppliesAcrossRedirectsAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + }; + TestHttpMessageHandler messageHandler = new(async (req, ct) => + { + requestCount++; + TimeSpan delay = requestCount == 1 ? TimeSpan.FromMilliseconds(1) : TimeSpan.FromSeconds(5); + await Task.Delay(delay, ct).ConfigureAwait(false); + + if (requestCount == 1) + { + return redirectResponse; + } + + return okResponse; + }); + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Timeout = TimeSpan.FromMilliseconds(300), + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + await Assert.ThrowsAnyAsync(actAsync); + Assert.Equal(2, requestCount); + } + + [Fact] + public async Task SendAsyncPostFoundRedirectRewritesToGetAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.Found) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; + int requestCount = 0; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((_, _) => + { + requestCount++; + return Task.FromResult(requestCount == 1 ? redirectResponse : okResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "request-body", + BodyContentType = "text/plain", + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal(["POST", "GET"], messageHandler.RequestMethods); + Assert.Equal(["request-body", null], messageHandler.RequestBodies); + } + + [Theory] + [InlineData(307)] + [InlineData(308)] + public async Task SendAsyncPostPreserveMethodRedirectPreservesBodyAsync(int redirectStatusCode) + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage redirectResponse = new((HttpStatusCode)redirectStatusCode) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; + int requestCount = 0; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestCount++; + return Task.FromResult(requestCount == 1 ? redirectResponse : okResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "request-body", + BodyContentType = "text/plain", + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal(["POST", "POST"], messageHandler.RequestMethods); + Assert.Equal(["request-body", "request-body"], messageHandler.RequestBodies); + } + + [Theory] + [InlineData(307)] + [InlineData(308)] + public async Task SendAsyncPostPreserveMethodRedirectToDifferentOriginWithBodyThrowsAsync(int redirectStatusCode) + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + using HttpResponseMessage redirectResponse = new((HttpStatusCode)redirectStatusCode) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((_, _) => + { + requestCount++; + return Task.FromResult(redirectResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "request-body", + BodyContentType = "text/plain", + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + HttpRequestException exception = await Assert.ThrowsAsync(actAsync); + Assert.Contains("preserve the request body to a different origin", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, requestCount); + } + + [Fact] + public async Task SendAsyncTooManyRedirectsThrowsAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + List createdResponses = []; + TestHttpMessageHandler messageHandler = new((_, _) => + { + requestCount++; + HttpResponseMessage response = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri($"https://api.example.test/redirect/{requestCount}") }, + }; + + createdResponses.Add(response); +#pragma warning disable CA2025 + return Task.FromResult(response); +#pragma warning restore CA2025 + }); + + try + { + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + HttpRequestException exception = await Assert.ThrowsAsync(actAsync); + Assert.Contains("maximum number of HTTP redirects", exception.Message, StringComparison.Ordinal); + Assert.Equal(51, requestCount); + } + finally + { + foreach (HttpResponseMessage response in createdResponses) + { + response.Dispose(); + } + } + } + [Fact] public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync() { @@ -385,6 +635,302 @@ public async Task SendAsyncFallsBackToOwnedClientWhenProviderReturnsNullAsync() Assert.Equal(1, providerCallCount); } + [Fact] + public async Task SendAsyncDoesNotApplyRequestHeadersToRedirectedEndpointAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + List requestsWithHeader = []; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); + if (requestsWithHeader.Count == 1) + { + return Task.FromResult(redirectResponse); + } + + return Task.FromResult(okResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Headers = new Dictionary + { + ["X-Trace-Id"] = "trace-1", + }, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal([true, false], requestsWithHeader); + } + + [Fact] + public async Task SendAsyncDoesNotApplyRequestHeadersToDifferentRedirectedEndpointAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + List requestsWithHeader = []; + List requestUrls = []; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestUrls.Add(req.RequestUri!.ToString()); + requestsWithHeader.Add(req.Headers.Contains("X-Trace-Id")); + return Task.FromResult(requestUrls.Count == 1 ? redirectResponse : okResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + Headers = new Dictionary + { + ["X-Trace-Id"] = "trace-1", + }, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal([true, false], requestsWithHeader); + Assert.Equal([TestUrl, "https://secondary.example.test/next"], requestUrls); + } + + [Fact] + public async Task SendAsyncDoesNotReadRedirectedResponseBodyAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + TrackingContent redirectedContent = new("not returned"); + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Content = redirectedContent, + Headers = { Location = new Uri("https://api.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + if (req.RequestUri!.AbsolutePath == "/resource") + { + return Task.FromResult(redirectResponse); + } + + return Task.FromResult(okResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.False(redirectedContent.WasRead); + } + + [Fact] + public async Task SendAsyncRejectsHttpsToHttpRedirectAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + int requestCount = 0; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("http://api.example.test/next") }, + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestCount++; + return Task.FromResult(redirectResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "request-body", + BodyContentType = "text/plain", + }; + + // Act + async Task actAsync() => await handler.SendAsync(request, cancellationToken); + + // Assert + HttpRequestException exception = await Assert.ThrowsAsync(actAsync); + Assert.Contains("HTTPS to HTTP", exception.Message, StringComparison.Ordinal); + Assert.Equal(1, requestCount); + } + + [Fact] + public async Task SendAsyncProviderClientAllowsScopedDefaultHeadersOnInitialRequestAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("ok", Encoding.UTF8, "text/plain"), + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + Task.FromResult(okResponse)); +#pragma warning restore CA2025 + using HttpClient providerClient = new(messageHandler); + providerClient.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Token", "provider-header-value"); + + int providerCallCount = 0; +#pragma warning disable CA2025 + await using DefaultHttpRequestHandler handler = new((_, _) => + { + providerCallCount++; + return Task.FromResult(providerClient); + }); +#pragma warning restore CA2025 + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("ok", result.Body); + Assert.Equal(1, providerCallCount); + } + + [Fact] + public async Task SendAsyncSuppliedClientReturnsRedirectResponseAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }; +#pragma warning disable CA2025 + TestHttpMessageHandler primaryMessageHandler = new((req, _) => + Task.FromResult(redirectResponse)); +#pragma warning restore CA2025 + using HttpClient primaryClient = new(primaryMessageHandler); + primaryClient.DefaultRequestHeaders.TryAddWithoutValidation("Ocp-Apim-Subscription-Key", "provider-header-value"); + + int providerCallCount = 0; +#pragma warning disable CA2025 + await using DefaultHttpRequestHandler handler = new((_, _) => + { + providerCallCount++; + return Task.FromResult(primaryClient); + }); +#pragma warning restore CA2025 + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal(307, result.StatusCode); + Assert.False(result.IsSuccessStatusCode); + Assert.NotNull(result.Headers); + Assert.Equal("https://secondary.example.test/next", Assert.Single(result.Headers!["Location"])); + Assert.Equal(1, providerCallCount); + } + + [Fact] + public async Task SendAsyncInvokesProviderForRedirectDestinationAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + List providerUrls = []; + List requestUrls = []; + using HttpResponseMessage redirectResponse = new(HttpStatusCode.TemporaryRedirect) + { + Headers = { Location = new Uri("https://secondary.example.test/next") }, + }; + using HttpResponseMessage okResponse = new(HttpStatusCode.OK) + { + Content = new StringContent("redirected", Encoding.UTF8, "text/plain"), + }; +#pragma warning disable CA2025 + TestHttpMessageHandler messageHandler = new((req, _) => + { + requestUrls.Add(req.RequestUri!.ToString()); + return Task.FromResult(requestUrls.Count == 1 ? redirectResponse : okResponse); + }); +#pragma warning restore CA2025 + + await using DefaultHttpRequestHandler handler = CreateHandlerWithOwnedMessageHandler(messageHandler, (info, _) => + { + providerUrls.Add(info.Url); + return Task.FromResult(null); + }); + + HttpRequestInfo request = new() + { + Method = "GET", + Url = TestUrl, + }; + + // Act + HttpRequestResult result = await handler.SendAsync(request, cancellationToken); + + // Assert + Assert.Equal("redirected", result.Body); + Assert.Equal(2, providerUrls.Count); + Assert.Equal(TestUrl, providerUrls[0]); + Assert.Equal("https://secondary.example.test/next", providerUrls[1]); + Assert.Equal(providerUrls, requestUrls); + } + #endregion #region DisposeAsync @@ -476,6 +1022,17 @@ public async Task QueryParametersPreserveExistingQueryStringAsync() #endregion + private static DefaultHttpRequestHandler CreateHandlerWithOwnedMessageHandler( + HttpMessageHandler ownedHttpMessageHandler, + Func>? httpClientProvider = null) + { + DefaultHttpRequestHandler handler = new(httpClientProvider); + FieldInfo? ownedHttpClientField = typeof(DefaultHttpRequestHandler).GetField("_ownedHttpClient", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(ownedHttpClientField); + ownedHttpClientField.SetValue(handler, new Lazy(() => new HttpClient(ownedHttpMessageHandler), LazyThreadSafetyMode.ExecutionAndPublication)); + return handler; + } + private sealed class TestHttpMessageHandler : HttpMessageHandler { private readonly Func> _responseFactory; @@ -491,9 +1048,14 @@ public TestHttpMessageHandler(Func RequestMethods { get; } = []; + + public List RequestBodies { get; } = []; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { this.LastRequest = request; + this.RequestMethods.Add(request.Method.Method); if (request.Content is not null) { #if NET @@ -501,9 +1063,68 @@ protected override async Task SendAsync(HttpRequestMessage #else this.LastRequestBody = await request.Content.ReadAsStringAsync().ConfigureAwait(false); #endif + this.RequestBodies.Add(this.LastRequestBody); this.LastRequestContentType = request.Content.Headers.ContentType?.MediaType; } + else + { + this.RequestBodies.Add(null); + } return await this._responseFactory(request, cancellationToken).ConfigureAwait(false); } } + + private sealed class TrackingContent : HttpContent + { + private readonly string _content; + + public TrackingContent(string content) + { + this._content = content; + } + + public bool WasRead { get; private set; } + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + this.WasRead = true; + byte[] bytes = Encoding.UTF8.GetBytes(this._content); + return stream.WriteAsync(bytes, 0, bytes.Length); + } + + protected override bool TryComputeLength(out long length) + { + length = Encoding.UTF8.GetByteCount(this._content); + return true; + } + } + + private sealed class StallingContent : HttpContent + { + private readonly TaskCompletionSource _stall = new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + this._stall.Task; + +#if NET + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) => + Task.Delay(Timeout.Infinite, cancellationToken); +#endif + + protected override bool TryComputeLength(out long length) + { + length = 0; + return false; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + this._stall.TrySetCanceled(); + } + + base.Dispose(disposing); + } + } } From b9dbe4f801a829cf852b05ca7407a2a92aed23c6 Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Thu, 10 Sep 2026 17:41:49 +0000 Subject: [PATCH 010/108] .NET: updates open telemetry deps (#8253) * chore: updates open telemetry dependencies chore: updates trimmer dependency chore: updates copilot sdk dependency Signed-off-by: Vincent Biret * ci: adds an open telemetry group Signed-off-by: Vincent Biret * Update explicit OpenTelemetry sample pins Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --------- Signed-off-by: Vincent Biret Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> --- .github/dependabot.yml | 4 ++++ dotnet/Directory.Packages.props | 24 +++++++++---------- ...emory_Step06_MemoryUsingAgentMemory.csproj | 2 +- .../Hosted-Invocations-EchoAgent.csproj | 4 ++-- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 89a79167aa4..92e64d5d631 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -32,6 +32,10 @@ updates: anthropic: patterns: - "Anthropic*" + opentelemetry: + patterns: + - "OpenTelemetry*" + - "Azure.Monitor.OpenTelemetry.Exporter" ignore: # For all System.* and Microsoft.Extensions/Bcl.* packages, ignore all major version updates - dependency-name: "System.*" diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index e1b56812593..8dead31fe33 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -33,7 +33,7 @@ - + @@ -65,15 +65,15 @@ - - - - - - - - - + + + + + + + + + @@ -108,7 +108,7 @@ - + @@ -155,7 +155,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj index 5376b3c7cdc..4cee54785a4 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj @@ -49,7 +49,7 @@ version it would otherwise resolve to, 1.12.0, has a known moderate severity vulnerability (GHSA-g94r-2vxg-569j) that fails the repo's NuGet audit (NU1902 as error). Matches the version pinned in dotnet/Directory.Packages.props. --> - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj index edd74f5a74b..be47ac13eed 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj @@ -33,8 +33,8 @@ - - + + From c37de519ba5c2069653d9b81b4478a14a22e01ab Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:46:47 +0000 Subject: [PATCH 011/108] .NET: Dotnet: reset $LASTEXITCODE per command in persistent PowerShell sessions (#8259) * Dotnet: reset $LASTEXITCODE per command in persistent PowerShell sessions * .NET: reset LASTEXITCODE before each PowerShell command Clear the persistent automatic variable before invoking each command so repeated native failures with the same exit code are reported correctly. Update the Windows regression test to cover repeated failures and reset readback semantics. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ShellSession.cs | 7 ++++- .../LocalShellExecutorTests.cs | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs index 9e85c645436..4a62bf400b4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ShellSession.cs @@ -573,6 +573,9 @@ private string BuildScript(string command, string sentinel) return "& {" + " $__af_rc = 0;" + + // $LASTEXITCODE persists across commands and cmdlets do not update it. + // Clear it so any value after invocation belongs to this command. + " $global:LASTEXITCODE = $null;" + " try {" + $" $__af_cmd = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded}'));" + // Force the user command's success output through the same @@ -588,9 +591,11 @@ private string BuildScript(string command, string sentinel) " [Console]::WriteLine(($_ | Out-String).TrimEnd());" + " }" + " };" + + // Capture the pipeline status before flushing overwrites it. + " $__af_ok = $?;" + " [Console]::Out.Flush();" + " if ($LASTEXITCODE -ne $null) { $__af_rc = $LASTEXITCODE }" + - " elseif (-not $?) { $__af_rc = 1 }" + + " elseif (-not $__af_ok) { $__af_rc = 1 }" + " } catch {" + " [Console]::Error.WriteLine($_.ToString());" + " $__af_rc = 1" + diff --git a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs index 247033fa1d6..ae6400c295d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Tools.Shell.UnitTests/LocalShellExecutorTests.cs @@ -313,6 +313,37 @@ public async Task Persistent_CarriesEnvironment_AcrossCallsAsync() Assert.Contains("persisted-value", read.Stdout, StringComparison.Ordinal); } + [Fact] + public async Task Persistent_PowerShell_DoesNotInheritPreviousExitCodeAsync() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + // Arrange + await using var shell = new LocalShellExecutor(new() + { + Mode = ShellMode.Persistent, + Shell = "powershell.exe", + Timeout = TimeSpan.FromSeconds(20), + }); + + // Act + var firstFailure = await shell.RunAsync("cmd /c exit 3"); + var succeeding = await shell.RunAsync("Write-Output ok"); + var repeatedFailure = await shell.RunAsync("cmd /c exit 3"); + var readback = await shell.RunAsync("Write-Output $LASTEXITCODE"); + + // Assert + Assert.Equal(3, firstFailure.ExitCode); + Assert.Equal(0, succeeding.ExitCode); + Assert.Contains("ok", succeeding.Stdout, StringComparison.Ordinal); + Assert.Equal(3, repeatedFailure.ExitCode); + Assert.Equal(0, readback.ExitCode); + Assert.Empty(readback.Stdout.Trim()); + } + [Fact] public async Task Persistent_Timeout_ReturnsExitCode124Async() { From a6eb2991ddcccffe4738b324d6f6bd0bfb43073a Mon Sep 17 00:00:00 2001 From: NekoPunch <95899648+orangeCatDeveloper@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:32:30 +0000 Subject: [PATCH 012/108] Python: document the shape contract of FunctionInvocationContext.result (#8283) * docs(python): clarify context.result shape contract FunctionInvocationContext.result documents one contract but carries two: middleware observe FunctionTool.invoke's parsed output and may write back a value of any type, so a type check written from the docs silently never fires. The shared-state sample modeled the wrong side and sent a Python repr of the Content list to the model instead of the tool's text. * docs(python): note the approval-request passthrough A bare Content of type function_approval_request is returned unchanged by the invocation layer on both the normal and termination paths, so the override rule as written misdirects middleware that drive approval flows. --- .../core/agent_framework/_middleware.py | 24 ++++++++++++++++--- .../middleware/shared_state_middleware.py | 10 +++++--- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index b26abf36adc..419cff8f185 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -373,8 +373,18 @@ class FunctionInvocationContext: arguments: The validated arguments for the function. session: The agent session for this invocation, if any. metadata: Metadata dictionary for sharing data between function middleware. - result: Function execution result. Can be observed after calling ``call_next()`` - to see the actual execution result or can be set to override the execution result. + result: Function execution result. This attribute carries no guaranteed type. + The pipeline assigns :meth:`FunctionTool.invoke`'s output — a + ``list[Content]``, or the wrapped function's raw return value when the + tool is configured with ``result_parser=SKIP_PARSING`` — at the innermost + link of the chain, so only the innermost middleware observes it directly. + Every middleware above observes whatever the ones below it left behind, + and any middleware may overwrite it with a value of any type. On the way + out, only ``list[Content]`` and ``str`` survive intact: every other value, + a bare ``Content`` included, is stringified into a single text item, so + rebuild the list rather than assigning one item back. The exception is a + bare ``Content`` of type ``function_approval_request``, which the + invocation layer passes through untouched to drive the approval flow. kwargs: Additional runtime keyword arguments forwarded to the function invocation. tools: The live, mutable list of tools available to the model for the current agent run, or ``None`` when the function is invoked outside of a @@ -431,7 +441,8 @@ def __init__( arguments: The validated arguments for the function. session: The agent session for this invocation, if any. metadata: Metadata dictionary for sharing data between function middleware. - result: Function execution result. + result: Function execution result. Observed and overridden values do not + share a type; see the class docstring before type-checking it. kwargs: Additional runtime keyword arguments forwarded to the function invocation. tools: The live, mutable list of tools for the current agent run. When provided, this is the same list object the model sees on the next iteration, so @@ -766,6 +777,13 @@ async def process( MiddlewareTypes should not return anything. All data manipulation should happen within the context object. Set context.result to override execution, or observe context.result after calling call_next() for actual results. + The observed value has no guaranteed type: the innermost middleware sees + :meth:`FunctionTool.invoke`'s output — ``list[Content]``, or the raw return + value under ``SKIP_PARSING`` — while an outer one sees whatever the inner + middleware left. Overriding with anything but ``list[Content]`` or ``str`` + collapses the result into a single stringified text item, except a bare + ``Content`` of type ``function_approval_request``, which passes through + untouched to drive the approval flow. """ ... diff --git a/python/samples/02-agents/middleware/shared_state_middleware.py b/python/samples/02-agents/middleware/shared_state_middleware.py index bcfa91b80bd..9c96fc069b5 100644 --- a/python/samples/02-agents/middleware/shared_state_middleware.py +++ b/python/samples/02-agents/middleware/shared_state_middleware.py @@ -7,6 +7,7 @@ from agent_framework import ( Agent, + Content, FunctionInvocationContext, tool, ) @@ -86,10 +87,13 @@ async def result_enhancer_middleware( # Call the next middleware/function await call_next() - # After function execution, enhance the result using shared state + # After function execution, enhance the result using shared state. This middleware + # is registered last, so it is innermost: context.result is invoke's list[Content]. if context.result: - enhanced_result = f"[Call #{self.call_count}] {context.result}" - context.result = enhanced_result + context.result = [ + Content.from_text(f"[Call #{self.call_count}] {item.text}") if item.type == "text" else item + for item in context.result + ] print("[ResultEnhancer] Enhanced result with call number") From 5a8cef9409168ddf2b585c37e93e9739eedb6373 Mon Sep 17 00:00:00 2001 From: WhaleTech Date: Fri, 11 Sep 2026 09:15:54 +0000 Subject: [PATCH 013/108] Fix replayed history persistence in CosmosHistoryProvider (#8269) Co-authored-by: WhaleTech <304937387+ryo-whaletech@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg --- .../_history_provider.py | 9 +++- .../tests/test_cosmos_history_provider.py | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index 9d9cb2968d5..03d7617f1e1 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -11,7 +11,7 @@ from typing import Any, ClassVar, TypedDict from agent_framework import Message -from agent_framework._sessions import HistoryProvider +from agent_framework._sessions import HistoryProvider, filter_new_messages from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import get_user_agent, mark_feature_used from azure.core.credentials import TokenCredential @@ -187,10 +187,15 @@ async def save_messages( await self._ensure_container_proxy() session_key = self._session_partition_key(session_id) + existing_messages = await self.get_messages(session_key, state=state, **kwargs) + new_messages = filter_new_messages(existing_messages, messages) + + if not new_messages: + return base_sort_key = time.time_ns() operations: list[tuple[str, tuple[dict[str, Any]]]] = [] - for index, message in enumerate(messages): + for index, message in enumerate(new_messages): document = { "id": str(uuid.uuid4()), "session_id": session_key, diff --git a/python/packages/azure-cosmos/tests/test_cosmos_history_provider.py b/python/packages/azure-cosmos/tests/test_cosmos_history_provider.py index e2c24016899..58461cd094a 100644 --- a/python/packages/azure-cosmos/tests/test_cosmos_history_provider.py +++ b/python/packages/azure-cosmos/tests/test_cosmos_history_provider.py @@ -256,6 +256,47 @@ async def test_empty_messages_noop(self, mock_container: MagicMock) -> None: mock_container.execute_item_batch.assert_not_awaited() + async def test_filters_replayed_transcript_prefix(self, mock_container: MagicMock) -> None: + existing_messages = [ + Message(role="user", contents=["A"]), + Message(role="assistant", contents=["B"]), + ] + mock_container.query_items.return_value = _to_async_iter([ + {"message": message.to_dict()} for message in existing_messages + ]) + provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container) + incoming_messages = [ + Message(role="user", contents=["A"]), + Message(role="assistant", contents=["B"]), + Message(role="user", contents=["C"]), + ] + + await provider.save_messages("s1", incoming_messages) + + batch_operations = mock_container.execute_item_batch.await_args.kwargs["batch_operations"] + assert len(batch_operations) == 1 + assert batch_operations[0][1][0]["message"]["contents"][0]["text"] == "C" + mock_container.query_items.assert_called_once() + + async def test_skips_exact_replayed_transcript(self, mock_container: MagicMock) -> None: + existing_messages = [ + Message(role="user", contents=["A"]), + Message(role="assistant", contents=["B"]), + ] + mock_container.query_items.return_value = _to_async_iter([ + {"message": message.to_dict()} for message in existing_messages + ]) + provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container) + incoming_messages = [ + Message(role="user", contents=["A"]), + Message(role="assistant", contents=["B"]), + ] + + await provider.save_messages("s1", incoming_messages) + + mock_container.query_items.assert_called_once() + mock_container.execute_item_batch.assert_not_awaited() + async def test_batches_when_message_count_exceeds_limit(self, mock_container: MagicMock) -> None: provider = CosmosHistoryProvider(source_id="mem", container_client=mock_container) messages = [Message(role="user", contents=[f"msg-{index}"]) for index in range(101)] From 4d173b24007da3f1adeccd158024960de55f3a24 Mon Sep 17 00:00:00 2001 From: lsmlhi_25 Date: Fri, 11 Sep 2026 09:42:23 +0000 Subject: [PATCH 014/108] Python: Update Monty bridge for pydantic-monty pool API (#8152) (#8162) * Python: Update Monty bridge for pydantic-monty pool API (#8152) * Python: Refresh uv.lock for pydantic-monty>=0.0.23 (#8152) * Python: drop obsolete pydantic-monty version migration comment (#8152) Floor is already >=0.0.23; no need to document the old API transition. * Python: add uv.lock platform markers for monty deps (#8152) * Revert "Python: add uv.lock platform markers for monty deps (#8152)" This reverts commit 14a48a21c786ee253fcfd9655bbf11f9aaffecde. * Python: refresh uv.lock for pydantic-monty 0.0.23 (#8152) Keep main's prerelease-mode and platform-marker lock format so CI uv-lock stays clean while resolving pydantic-monty>=0.0.23. --------- Co-authored-by: minelhi <3417378192@qq.com> Co-authored-by: Eduard van Valkenburg --- python/packages/monty/AGENTS.md | 5 +- python/packages/monty/README.md | 4 +- .../agent_framework_monty/_monty_bridge.py | 64 +++--- python/packages/monty/pyproject.toml | 2 +- .../monty/tests/monty/test_monty_codeact.py | 64 +++++- python/uv.lock | 184 ++++++++---------- 6 files changed, 181 insertions(+), 142 deletions(-) diff --git a/python/packages/monty/AGENTS.md b/python/packages/monty/AGENTS.md index 0f8ffe479dc..67c3f45d8cb 100644 --- a/python/packages/monty/AGENTS.md +++ b/python/packages/monty/AGENTS.md @@ -59,7 +59,8 @@ Tool-management methods on both classes: `add_tools`, `get_tools`, adapted from the reference Monty CodeAct repo. Pauses on `FunctionSnapshot` to dispatch host calls, then resumes; supports direct typed tool calls, the `call_tool` fallback, `asyncio.gather` fan-out, and forwards - ``mount`` / ``limits`` to `Monty(...).start(...)`. + ``limits`` / type-check options via `pool.checkout(...)`, and ``mount`` / + ``print_callback`` via `session.feed_start(...)`. - **`_instructions.py`** — dynamic instruction / tool-description builders (include filesystem capability summaries when mounts are configured). @@ -67,7 +68,7 @@ Tool-management methods on both classes: `add_tools`, `get_tools`, | Capability | Monty primitive | Status | |------------|-----------------|--------| -| Custom virtual filesystem | `OSAccess` subclass passed to `Monty(...).start(os=...)` | Not exposed. Strictly more general than file mounts; useful when you want a fully synthetic FS. | +| Custom virtual filesystem | `OSAccess` subclass passed to `session.feed_start(..., os=...)` | Not exposed. Strictly more general than file mounts; useful when you want a fully synthetic FS. | | Outbound URL allow-list | No Monty primitive — expose `fetch_url` as a host tool with the allow-list check in your tool function. | Not exposed in this package; users add it as a regular tool. | ## Out of scope (for now) diff --git a/python/packages/monty/README.md b/python/packages/monty/README.md index 980182e61f3..47b781098f4 100644 --- a/python/packages/monty/README.md +++ b/python/packages/monty/README.md @@ -145,8 +145,8 @@ codeact = MontyCodeActProvider( nothing is captured). `read-only` mounts reject writes. - **`resource_limits`** is forwarded straight to Monty's [`ResourceLimits`](https://github.com/pydantic/monty) TypedDict - (`max_allocations`, `max_duration_secs`, `max_memory`, `gc_interval`, - `max_recursion_depth`). + (`max_duration_secs`, `max_memory`, `gc_interval`, `max_recursion_depth`, + `max_suspensions`). ## DSL inside `execute_code` diff --git a/python/packages/monty/agent_framework_monty/_monty_bridge.py b/python/packages/monty/agent_framework_monty/_monty_bridge.py index 798aad4ec46..cc9fa13729c 100644 --- a/python/packages/monty/agent_framework_monty/_monty_bridge.py +++ b/python/packages/monty/agent_framework_monty/_monty_bridge.py @@ -229,38 +229,46 @@ async def run(self, code: str) -> dict[str, Any]: NameLookupSnapshot = monty_module.NameLookupSnapshot printer = _PrintCollector() - monty = Monty( - _build_code(code), - script_name="codeact.py", - type_check=self.type_stubs is not None, - type_check_stubs=self.type_stubs, - ) - start_kwargs: dict[str, Any] = {"print_callback": printer} - if self._mounts: - start_kwargs["mount"] = list(self._mounts) + checkout_kwargs: dict[str, Any] = { + "script_name": "codeact.py", + "type_check": self.type_stubs is not None, + "type_check_stubs": self.type_stubs, + } if self._resource_limits: - start_kwargs["limits"] = self._resource_limits - progress = monty.start(**start_kwargs) - - while True: - if isinstance(progress, MontyComplete): - return { - "output": _ensure_json_value(progress.output), - "stdout": printer.output, - "truncated": printer.truncated, - } - if isinstance(progress, FunctionSnapshot): - progress = self._handle_function(progress) - continue - if isinstance(progress, FutureSnapshot): - progress = await self._handle_future(progress) - continue - if isinstance(progress, NameLookupSnapshot): - raise RuntimeError(f"Name lookup not supported: {progress.variable_name!r}") - raise RuntimeError(f"Unsupported Monty progress type: {type(progress).__name__}") + checkout_kwargs["limits"] = self._resource_limits + + feed_kwargs: dict[str, Any] = {"print_callback": printer} + if self._mounts: + feed_kwargs["mount"] = list(self._mounts) + + with Monty() as pool: + with pool.checkout(**checkout_kwargs) as session: + progress = session.feed_start(_build_code(code), **feed_kwargs) + + while True: + if isinstance(progress, MontyComplete): + return { + "output": _ensure_json_value(progress.output), + "stdout": printer.output, + "truncated": printer.truncated, + } + if isinstance(progress, FunctionSnapshot): + progress = self._handle_function(progress) + continue + if isinstance(progress, FutureSnapshot): + progress = await self._handle_future(progress) + continue + if isinstance(progress, NameLookupSnapshot): + raise RuntimeError(f"Name lookup not supported: {progress.variable_name!r}") + raise RuntimeError(f"Unsupported Monty progress type: {type(progress).__name__}") def _handle_function(self, snapshot: Any) -> Any: if snapshot.is_os_function: + # pydantic-monty pool API: feed_start always surfaces OS calls as + # snapshots. Mounts (and os=) are consulted only by resume_auto(), + # not by a plain resume(...) payload. + if self._mounts: + return snapshot.resume_auto() return snapshot.resume({ "exc_type": "PermissionError", "message": "OS and filesystem calls are not available.", diff --git a/python/packages/monty/pyproject.toml b/python/packages/monty/pyproject.toml index f8a57325e90..3660c96fa80 100644 --- a/python/packages/monty/pyproject.toml +++ b/python/packages/monty/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.13.0,<2", - "pydantic-monty>=0.0.1,<0.1", + "pydantic-monty>=0.0.23,<0.1", ] [tool.uv] diff --git a/python/packages/monty/tests/monty/test_monty_codeact.py b/python/packages/monty/tests/monty/test_monty_codeact.py index b319428bf5d..b1ccb3828e0 100644 --- a/python/packages/monty/tests/monty/test_monty_codeact.py +++ b/python/packages/monty/tests/monty/test_monty_codeact.py @@ -50,6 +50,10 @@ def resume(self, payload: Any) -> Any: assert self._script is not None, "Snapshot must be attached to a script." return self._script.advance(("function_resume", self, payload)) + def resume_auto(self) -> Any: + assert self._script is not None, "Snapshot must be attached to a script." + return self._script.advance(("function_resume_auto", self, None)) + @dataclass class _FakeFutureSnapshot: @@ -116,30 +120,80 @@ def _get_script() -> _FakeScript: return script -class _FakeMonty: +class _FakeSession: + """Fake ``MontySession`` matching the pydantic-monty pool/checkout API.""" + def __init__( self, - code: str, *, script_name: str, type_check: bool, type_check_stubs: str | None, + limits: dict[str, Any] | None = None, ) -> None: - self.code = code self.script_name = script_name self.type_check = type_check self.type_check_stubs = type_check_stubs + self.limits = limits + self.code: str | None = None self._script = _get_script() - def start(self, *, print_callback: Any) -> Any: + def __enter__(self) -> _FakeSession: + return self + + def __exit__(self, *args: Any) -> None: + return None + + def feed_start( + self, + code: str, + *, + print_callback: Any = None, + mount: Any = None, + **kwargs: Any, + ) -> Any: + self.code = code + self.mount = mount while True: item = self._script.next_item() if isinstance(item, _PrintAction): - print_callback("stdout", item.text) + if print_callback is not None: + print_callback("stdout", item.text) continue return item +class _FakeMonty: + """Fake ``Monty`` pool: ``with Monty() as pool: with pool.checkout() as session``.""" + + def __init__(self, **kwargs: Any) -> None: + self.pool_kwargs = kwargs + self.last_session: _FakeSession | None = None + + def __enter__(self) -> _FakeMonty: + return self + + def __exit__(self, *args: Any) -> None: + return None + + def checkout( + self, + *, + script_name: str = "main.py", + type_check: bool = False, + type_check_stubs: str | None = None, + limits: dict[str, Any] | None = None, + **kwargs: Any, + ) -> _FakeSession: + self.last_session = _FakeSession( + script_name=script_name, + type_check=type_check, + type_check_stubs=type_check_stubs, + limits=limits, + ) + return self.last_session + + @pytest.fixture(autouse=True) def fake_monty_module(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Install a fake ``pydantic_monty`` module for the duration of each test.""" diff --git a/python/uv.lock b/python/uv.lock index 38b21fed6b6..4c64db6109a 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -846,7 +846,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "pydantic-monty", specifier = ">=0.0.1,<0.1" }, + { name = "pydantic-monty", specifier = ">=0.0.23,<0.1" }, ] [[package]] @@ -4728,118 +4728,94 @@ wheels = [ [[package]] name = "pydantic-monty" -version = "0.0.19" +version = "0.0.23" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "pydantic-monty-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pydantic-monty-runtime", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/01/cd7927f51500a13c1661e2db6488f6ff668194c4378d0fb218928892aaba/pydantic_monty-0.0.23.tar.gz", hash = "sha256:ee674b81ed12f81cfbe0db210fe5e803c754d0682634331931f4d70d5742058d", size = 6713, upload-time = "2026-09-05T19:26:03.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b3/a259a600df30a54ee9e576c90bb7cb6c2b2d4750774c91748922cc202c5e/pydantic_monty-0.0.23-py3-none-any.whl", hash = "sha256:cddcf7d4d7dd163b56e4411f450ea0244a7205988c77cd85441ec89fedaab91a", size = 6302, upload-time = "2026-09-05T19:24:01.829Z" }, +] + +[[package]] +name = "pydantic-monty-client" +version = "0.0.23" +source = { registry = "https://pypi.org/simple" } +dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/51/f8/c04df414086488834d102ab85cf8172c114108f842fb142ac92a490213fd/pydantic_monty-0.0.19.tar.gz", hash = "sha256:f3f9e256058b4085349dd4ad347795d5203a8310e9eaad9a2bff93890ac5b86d", size = 1484032, upload-time = "2026-07-24T10:00:13.194Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/60/890a292f96f3a9363419ce01a89cc98be1f95115e6ee04010c8a8a6b6f26/pydantic_monty-0.0.19-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:435b0ae337f701ee83adc94eee6f2983378cb336bd8ebffa8282c74ea2b39d6b", size = 2495091, upload-time = "2026-07-24T09:56:31.788Z" }, - { url = "https://files.pythonhosted.org/packages/a1/7b/8fa600ade713a762eabe0fd5915a60d4395eb75fd6d8d1e0e8e4487d67ed/pydantic_monty-0.0.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:472d4a775d71a8bfe66bd67c926feb0ea77c0a5ba515c72be21702ab96995106", size = 2253609, upload-time = "2026-07-24T09:56:33.234Z" }, - { url = "https://files.pythonhosted.org/packages/ca/14/6f3b9142345f7d7b23043f2bc2340e6bcdb5206b7e2c3e1e4e2ffb0b7fac/pydantic_monty-0.0.19-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:0e94031cc2d81a5e7923f38ffaa8e8ec3ae7f001a7fcd4bdbe1cbf99a048bc6d", size = 2306671, upload-time = "2026-07-24T09:56:34.575Z" }, - { url = "https://files.pythonhosted.org/packages/f0/d4/893044608c29bc55eaf1001d419729c1ce36e0ba468bc5419c59f3c72706/pydantic_monty-0.0.19-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:d51f460ecefb77bc6efbf8a18ba9d63ce08fed7388fe5481d34c01210cf97c5b", size = 2006044, upload-time = "2026-07-24T09:56:36.068Z" }, - { url = "https://files.pythonhosted.org/packages/92/82/8b3921b23429f15d9dafda8d37a6fafc0b8240c9eedcbbeeac56da01fad1/pydantic_monty-0.0.19-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:87d43fa5831a177513535f022c590561b38f91fadd535aa18f8e45b294d08afc", size = 2152953, upload-time = "2026-07-24T09:56:37.771Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/b792e1d5348fb1ee4209c81800a0aada5c5d81651fb2c050d1b0bd19dbe1/pydantic_monty-0.0.19-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:c96fda04408e01c7becb4a7918f1459a0d9d8d4a41e96f5ed846162d1e9f5c80", size = 2303787, upload-time = "2026-07-24T09:56:39.114Z" }, - { url = "https://files.pythonhosted.org/packages/73/58/c11eb27a42302423cbb9adfab3de4ef83d4d4a8bbdf07c9d7d054ba9d5d5/pydantic_monty-0.0.19-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:f6268bed8489cc8ff13c656c4ec7cac0121e58502e1519f1875cf21fa3f29f62", size = 2164908, upload-time = "2026-07-24T09:56:40.846Z" }, - { url = "https://files.pythonhosted.org/packages/b6/57/6129bbfa55daf42175436535984273b199cc0c290de5349bf1781d3f001c/pydantic_monty-0.0.19-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:655b09207e3218a356b5298ac676536e0ed80c96001b4619748d0722c5d4f55a", size = 2382867, upload-time = "2026-07-24T09:56:42.436Z" }, - { url = "https://files.pythonhosted.org/packages/89/b4/af66ca38439b2712faf186b9e0341f179e1dd06ede791f5ae8e0738f5bf2/pydantic_monty-0.0.19-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:77884bf6047e6df2edc02089aa3b5f3fe0030028dd88ed61ff99d75d1b367199", size = 2501367, upload-time = "2026-07-24T09:56:43.794Z" }, - { url = "https://files.pythonhosted.org/packages/95/9f/8f6107b2ac31b89539317d9054947fccd0a6ab7ff7f4e4110aec53542efc/pydantic_monty-0.0.19-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0c6e4d78af78b23521d9dc15a6c191217942291d357b4b80ad5234d51633cd2", size = 2740027, upload-time = "2026-07-24T09:56:45.308Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b4/2ded2cc56c3d667fdc116c73e99176a588b212dc5a81aa84d8b9e2d72b23/pydantic_monty-0.0.19-cp311-cp311-win32.whl", hash = "sha256:49a979b0902eb0965521726e90826ff8d48fac5f51bfeda5e1b131f203c1790f", size = 1913668, upload-time = "2026-07-24T09:56:46.626Z" }, - { url = "https://files.pythonhosted.org/packages/00/d8/ef1147a176b7ced141f878034ac7ede71b7af519d721c4b80dba830af8b4/pydantic_monty-0.0.19-cp311-cp311-win_amd64.whl", hash = "sha256:1b3a4db9b7972068dfa80c91f814318f2fd4102a7716420051c79178b56e4ad7", size = 2131998, upload-time = "2026-07-24T09:56:48.261Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/152bbb3315dfa46d4e4aae71779230e50c67a34d859a3470fd75c01b795c/pydantic_monty-0.0.19-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b073e64edfd62cca918d792d6fe559512472f981f949e53a7aec673201f5f554", size = 2492733, upload-time = "2026-07-24T09:56:49.612Z" }, - { url = "https://files.pythonhosted.org/packages/46/16/9d37f1bf94c47c593a06ecb918bb64a2c8a38af24d6fa2b0d0e031938edf/pydantic_monty-0.0.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5825ae6c40270166f0f31b6e62d59b0fe47201d12b1be5842efc22d3b7dadcee", size = 2234610, upload-time = "2026-07-24T09:56:51.257Z" }, - { url = "https://files.pythonhosted.org/packages/82/4f/31732f4b9c2b6574eb564e420593b9be3f80d92440211970fab23e882bc5/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5b6821c0035ff2c02cf0f37823fb905890f7238ce923bbc3ca937740f9554836", size = 2303116, upload-time = "2026-07-24T09:56:52.581Z" }, - { url = "https://files.pythonhosted.org/packages/21/2c/c21e6179361100dd8b9ad410df775d176a29e0b85f2ffc3144fd29840ee9/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:5fb4514d99a10e304237a82baeb6072e934ce8462dcd5fb5c3fea0ee0ef16aeb", size = 2007947, upload-time = "2026-07-24T09:56:54.006Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f0/ad64f4894334499f689bdce7e5b5dda6b680d98989424092dcbf21666564/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:2b0f154ac2e6450befa337a99a8519e2f1195cc59f88bd73d780067ace0c4c97", size = 2151468, upload-time = "2026-07-24T09:56:55.626Z" }, - { url = "https://files.pythonhosted.org/packages/2a/97/4ff5f9170e4865ec28fb152bc6ebaa8bd1873e695ee98af2103607ba42e8/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:6fcb4e3a312397432f7c5a0aa04a765670d9f37f4e23f37cb580b3faa2f218b1", size = 2300164, upload-time = "2026-07-24T09:56:57.318Z" }, - { url = "https://files.pythonhosted.org/packages/1a/b1/c9bfb6708bc5d9f6b040db46156c6e3f16de68ab22f07e2de4f25782414b/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:955d308171ba0260f75d2dede18c46d9732647f94b8f7fba3076bb539f155dc8", size = 2164984, upload-time = "2026-07-24T09:56:58.66Z" }, - { url = "https://files.pythonhosted.org/packages/35/2d/8c1492216632f53e229cb2886c7fd09613d5990f4856f93f7ae0364da9bc/pydantic_monty-0.0.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:3d7e1a6cc1977b24e01b5322888a5ba3f05b112a04a1646ba51f2c34c562a3d9", size = 2357823, upload-time = "2026-07-24T09:57:00.048Z" }, - { url = "https://files.pythonhosted.org/packages/9b/cc/ae4adaaf343de00748fc3598402c1523e48db7094a9c1e0fa26177699440/pydantic_monty-0.0.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4904785a34f71f59aa1eb6dc081bafd09e4caa2f028cd379aa3aa846b8a1c27d", size = 2497387, upload-time = "2026-07-24T09:57:01.686Z" }, - { url = "https://files.pythonhosted.org/packages/ec/09/eaef87ed6cbffed9c720dd3cb850e748b0aab11f5ec1ecffb56250973f7d/pydantic_monty-0.0.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ce15a5326e24cf7045ec9c1456ddb92abd09df91708771c3ae5e72d8e6374c81", size = 2738391, upload-time = "2026-07-24T09:57:03.3Z" }, - { url = "https://files.pythonhosted.org/packages/63/af/58be6fd6ea87e27bd57435013ca1f63d645a0c990c2f23708bcdd048af24/pydantic_monty-0.0.19-cp312-cp312-win32.whl", hash = "sha256:600eb259415e8b2dfef4be38d030c945b3fbb4fb85e727cb97131e7329ab017f", size = 1908274, upload-time = "2026-07-24T09:57:05.023Z" }, - { url = "https://files.pythonhosted.org/packages/20/b7/1cb54e43113cb69c40fb765cfee3be1c222d81153b432131f50508569aee/pydantic_monty-0.0.19-cp312-cp312-win_amd64.whl", hash = "sha256:2c98b1c99994f92ab487a762b107067ab70036f64231e05aa3f6d2b16018688e", size = 2111335, upload-time = "2026-07-24T09:57:06.614Z" }, - { url = "https://files.pythonhosted.org/packages/24/17/0926da051f34ccaa45bf528777dc99e5ea611669cdd7b715be1e086c1fe0/pydantic_monty-0.0.19-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c01fb1162cf87dbf145b875450eabfde6b35b26f27ed63468398cb4c37732064", size = 2496669, upload-time = "2026-07-24T09:57:07.982Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/c38f79e24971b4d2205ee156df6e5c6ccdcc720152f54a956cc26e7488b0/pydantic_monty-0.0.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e97dc97de32410003fb5517b72ab47799b4546a3ab48a75e60260cf73734da76", size = 2234599, upload-time = "2026-07-24T09:57:09.458Z" }, - { url = "https://files.pythonhosted.org/packages/5a/89/fb2ed1677ad2c3e2801aa119fdc280d1c64f3480e1015811e0942c9a7d20/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:06318e6add780f66259067829ab16062ce7a556dba0884372a881881b4a7c3bf", size = 2305696, upload-time = "2026-07-24T09:57:10.97Z" }, - { url = "https://files.pythonhosted.org/packages/24/ec/e36ff1c2c97f46420d57680199e7ae2e1eb306d2cfda22be2448e53e7484/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:5a765a36141bbeb074d89b424d5488bed4e358c603c72606cc9d81ee44d83de2", size = 2007600, upload-time = "2026-07-24T09:57:12.333Z" }, - { url = "https://files.pythonhosted.org/packages/da/88/b47670d3e28f99dc2f4c2686dc42233843dce1a607f3d3cc8a387f3b9b74/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:48f5ebc048779c854f993834586ba372df3b65500d1ed7f147023abc29aeb2c4", size = 2151382, upload-time = "2026-07-24T09:57:13.8Z" }, - { url = "https://files.pythonhosted.org/packages/9f/18/559d71fc66a769c22f6b2a8470515aa6e69a141ab617900fe28a806080b7/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2eb7502f93d8bb568d255fccca95e3b9daca05bb674b9c5323fcba14d088932c", size = 2302643, upload-time = "2026-07-24T09:57:15.42Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b4/171be42e2ec211bd01fe4be7b6de9ab0c346081edc33830f9f9b781341ab/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:608994600a839dd863940ac84810c48d378390a4fea8c77e5145feb84037e610", size = 2169103, upload-time = "2026-07-24T09:57:16.805Z" }, - { url = "https://files.pythonhosted.org/packages/84/ce/8c47253c8f3f528f0fa2d87dde85623dc3f211189a372e2d69cb6ad896b1/pydantic_monty-0.0.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:13a652f9dffa6d25ea458fec83108f60f29682caf42cecef91955b5b8cb04365", size = 2358155, upload-time = "2026-07-24T09:57:18.51Z" }, - { url = "https://files.pythonhosted.org/packages/97/4e/239107b83bae3a20e4f5424f6c6f3f88bae1fd1e734603c298167985f8be/pydantic_monty-0.0.19-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6167c966db7d8d2fe03940f8da596de6dcd184dcf5cf6209f0a1a9af8792550d", size = 2500858, upload-time = "2026-07-24T09:57:19.878Z" }, - { url = "https://files.pythonhosted.org/packages/48/55/02a7059f20e7198e511ac0b88a3957a2c01b8991326359b7c1bb590a09b8/pydantic_monty-0.0.19-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3102b44307d04f41897ae51d4daeab4fc9b5bf84a78c036781b488876ef998c3", size = 2742212, upload-time = "2026-07-24T09:57:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/a8/28/b632fe0e8eeba3f2b4dbec6ebd4c9b569c20e9597007f2d0fbbf04101307/pydantic_monty-0.0.19-cp313-cp313-win32.whl", hash = "sha256:e43da52776796a894f40533a7e5a322e98d9aaf7d8f6fbb7dc21a0de60a93f41", size = 1908553, upload-time = "2026-07-24T09:57:22.765Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d3/b90872f017871339ceb03e70fa4915ef8682128a476a66adffedfff874d8/pydantic_monty-0.0.19-cp313-cp313-win_amd64.whl", hash = "sha256:fd8c195875f8f44d55bc7d1d53c4e43248b184b3ac803d8131c92d4cc05a1aef", size = 2111345, upload-time = "2026-07-24T09:57:24.351Z" }, - { url = "https://files.pythonhosted.org/packages/eb/11/c2aed55502bfc9620837312f0e2fca7a3d4bc959824a66d81b72144d7256/pydantic_monty-0.0.19-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:2692dc4452937cf2200afd257297e9ac3ccff122b80aa7a69935e8275d684193", size = 2497017, upload-time = "2026-07-24T09:57:25.836Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d0/d4b44a81c71308109cfa642a24b803057615ca1609530c5e66c376780efe/pydantic_monty-0.0.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a4717f829b35c4bc5d9f6f52a52d19b90729bd494bcb69133bd4c0afcab9c76", size = 2247468, upload-time = "2026-07-24T09:57:27.501Z" }, - { url = "https://files.pythonhosted.org/packages/a9/89/52848dce3acbb1d58df34496db3c4718814cc39f6db01a5025bfdc12b530/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:99f7282213ad6ebf7daf149a88d1517e6328afbf6780180512b9de62f30d292b", size = 2306181, upload-time = "2026-07-24T09:57:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/90/05/f7791c79c7be2240c43a9287196ed49034f773e3f4158492f028e486ebc2/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:978788b1c56fa0c49927e0a35151f0a635ef2f8d947d16915541ff864d2112f5", size = 2008738, upload-time = "2026-07-24T09:57:30.423Z" }, - { url = "https://files.pythonhosted.org/packages/93/a1/d714258eeb2583acaab2035834acc54ac6baa65bac456f897d901bc0c03a/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:8b821cac39deeb1abb2994d5a65f117ce26e55c586d0f570d425ff469ff48e3c", size = 2152275, upload-time = "2026-07-24T09:57:31.725Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/cdb1fa761e992a489b07a17adb80c21bf99eabd1286ca62f9e73acda1f4b/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:b43c4ffa5651f0eca97458dc673d7952064ae5eb5b836d23967a7d41483bb8f4", size = 2303533, upload-time = "2026-07-24T09:57:33.131Z" }, - { url = "https://files.pythonhosted.org/packages/db/9b/e6685cf82521e68e0dcb94e0c97a1a20410b1266fbce7008c0caa3484039/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:4054610601358943a3dd740a8d59a6812cc682e94d6903cb72baadae1ef5d2ac", size = 2169585, upload-time = "2026-07-24T09:57:34.511Z" }, - { url = "https://files.pythonhosted.org/packages/df/f4/6f031a628d3de72d95bedbb18292ccf998d9a422aff58eedf37347a0b367/pydantic_monty-0.0.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:0b2a34c320968a3cef3d933737e99c932f13c55667315128f366afdaeea2be04", size = 2375171, upload-time = "2026-07-24T09:57:35.907Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d3/4367bccdf2c06a977c0d5ddf816190d570a07199f011034df16d51c84723/pydantic_monty-0.0.19-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ffaf950f4284bb193a54f18fa4e3e8c225b5b52265813abffe492074e90c65fb", size = 2501475, upload-time = "2026-07-24T09:57:37.872Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0e/7a0e1d5c016848afc9a8605aa4f16e6960d68806e775865b986aacaf8f88/pydantic_monty-0.0.19-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:bb1e5ee6762f9494bfb0f4cc316ad3e9a8c7917e3c984a23eb2e2322d401e5cb", size = 2742547, upload-time = "2026-07-24T09:57:39.284Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/92f77cf088f1df72a5dbab109c8c0e82f7ddeb18e65835a8dffcf967bb4a/pydantic_monty-0.0.19-cp314-cp314-win32.whl", hash = "sha256:b68ef6503b39f2f014162e3d8e7f48b9722a43ceb7b6d7199dc6d545f4c37b34", size = 1907832, upload-time = "2026-07-24T09:57:40.982Z" }, - { url = "https://files.pythonhosted.org/packages/cc/3e/85e1a914f81659ea25c34916fdef27fcda2b00323dafb76cf2a5321f7c11/pydantic_monty-0.0.19-cp314-cp314-win_amd64.whl", hash = "sha256:14ef37b43c5bf90966ca51bcad0fae892a3c2546cd151fadc039e0a25ca74073", size = 2123187, upload-time = "2026-07-24T09:57:42.352Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/29/e9/dcbf9a90ccd195be0db5257c2234d002d551d47f9d8987543eddd3095812/pydantic_monty_client-0.0.23.tar.gz", hash = "sha256:8b31f75afebb60c0416c869c8d6667a75e84b0f05b7a0b44e51b77ef98f294d7", size = 1983536, upload-time = "2026-09-05T19:26:04.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/68/184c5716c0172f66d8eb45b4b300602dfaf80474ce88bb1faedecd9db03f/pydantic_monty_client-0.0.23-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e6ad118c625133e20f34f661c24ce7018597c1661294c235f816915363e2b3d", size = 3902422, upload-time = "2026-09-05T19:24:20.509Z" }, + { url = "https://files.pythonhosted.org/packages/66/20/0b15349e233d099981f0d932639ee627bc82281dbcf1cdbc082d745066a3/pydantic_monty_client-0.0.23-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7325bd61bab9552d874aaf2bdd38a97aac6611089dbd364ca55636e27223c043", size = 3714912, upload-time = "2026-09-05T19:24:21.946Z" }, + { url = "https://files.pythonhosted.org/packages/38/e1/a4200307267c83660bb6d014fbd9f566eba9fad2c01b0c5d646396cc8f9e/pydantic_monty_client-0.0.23-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7f6a5963e27feb35357c27dc6d536ad643343f2f62aaca9caedae80b0d165d47", size = 3731509, upload-time = "2026-09-05T19:24:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/74/de/a948700c0b304e42ea0b81afae7ce8974d891bc84f359ea0034aa786a33b/pydantic_monty_client-0.0.23-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:409e9f4e598de4a2c099c21de24105779120de4211b397e3d5e5ee0bb4c2ec4b", size = 3431846, upload-time = "2026-09-05T19:24:24.694Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1b/49b492c77c226a97bbb03a42ded3f31b69db4f6b16c822df3dfc2d9a6f12/pydantic_monty_client-0.0.23-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:9c59ab64c71544a30826ebc3e28fe113ac95875fad349f169295dd2b2c6dffc7", size = 3641670, upload-time = "2026-09-05T19:24:27.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4e9b8b2955adc53fb726d243db4ede5ea32f6e77626fa0360b3c959496ae/pydantic_monty_client-0.0.23-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:a26fcb1f7e7d2da772769517c0a8c2dbd470a687678a5e2adce88b12a84bdd30", size = 3848592, upload-time = "2026-09-05T19:24:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/01/47/17d5a31397c6a8d927d681c67e25b1196b3d51ae43d0324c115d2a6e0451/pydantic_monty_client-0.0.23-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:158ca5de9bdc837b9ab924b5e2401efa6f6def0c52f90e5bb27391789c225206", size = 3730379, upload-time = "2026-09-05T19:24:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/3d/19/333f77e0a846f60c031d4c8a11bbeddf2fb026f8c737d2ad8f6aeb7d43b8/pydantic_monty_client-0.0.23-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6a515aa0bad54279e8a3d00621c24ffcb32580d3e78c97debfeed82590528e7f", size = 4008461, upload-time = "2026-09-05T19:24:31.488Z" }, + { url = "https://files.pythonhosted.org/packages/71/6b/0298e1320632575b7bb22751521f8da4ee2751a330b7b390057ab5e1ef54/pydantic_monty_client-0.0.23-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a0794cd931636d80f9cbac9e27cb5917a83571d952f58aa35890f72296293baf", size = 3901523, upload-time = "2026-09-05T19:24:32.845Z" }, + { url = "https://files.pythonhosted.org/packages/b6/96/466efbfa928073c35ffac7e65599f6ad2237315dbcb3d6385b8d46bcb73c/pydantic_monty_client-0.0.23-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:15f3cb2847a134314391f7d89b76540638d57be056e2558b0bf6ee7beb27b0cc", size = 4201290, upload-time = "2026-09-05T19:24:34.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/ed/cf3ec61d7dd2a95292f67f5bee663b7fa91ab60b6fb6582dccd65f00e8f2/pydantic_monty_client-0.0.23-cp311-cp311-win32.whl", hash = "sha256:8fe9fdf0c124bbc141b8c6d3f6bdc3595b212ea65e72ab71cdded4e8d8ec85ea", size = 3295821, upload-time = "2026-09-05T19:24:35.699Z" }, + { url = "https://files.pythonhosted.org/packages/38/61/f8e58f43cbb9a826048be519eba45c55dc11e7ab9459e83c347d4cdfd167/pydantic_monty_client-0.0.23-cp311-cp311-win_amd64.whl", hash = "sha256:6d850c51c56bb04611319cdf4e64203c49e3b07b8bf9d2ab86d49f8c358e2381", size = 3935856, upload-time = "2026-09-05T19:24:37.013Z" }, + { url = "https://files.pythonhosted.org/packages/7e/f3/4e4975daee5ac4a86e95b08124e304cf7af774df50891b28479bb2c28ede/pydantic_monty_client-0.0.23-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7f309a03e75c418b34e469e547171059ebd1ebcd11971ed129b6905e2a7c057f", size = 3930126, upload-time = "2026-09-05T19:24:38.269Z" }, + { url = "https://files.pythonhosted.org/packages/6e/23/927d73209b509db9604606d88aaff90f56474bd9bdcfe9f39a873bfc945c/pydantic_monty_client-0.0.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e3efb7e2af60e17c16f3baa49ea3a2e2b90c88610666f294591b9819d3ccc69", size = 3702556, upload-time = "2026-09-05T19:24:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c9/bfb5847d79e8bdeefbeea531b90f09e2c5f156bc361730975171a7e93146/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f065e8286378f26d29ceaeb2fb10712f202f45656967706675f93b3a17b99f78", size = 3728660, upload-time = "2026-09-05T19:24:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/13/9c/50af7e5a67876cb7c421401ff5f3885ae4fbd60324fea3c25e5fd31926af/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:d3e353536016174f9e0aca289f7d3a81a07ac7574e2a610902152e0ebf5feb5a", size = 3425561, upload-time = "2026-09-05T19:24:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e3/1d1a53f72576c190ea2f1a6c0fb5eeb722bea6901df8ddfba294ff49b08b/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:ef186bfe5344a84515cd51dc9ee5914084b1d616fad2409328d8edfbfb4d5ac3", size = 3633369, upload-time = "2026-09-05T19:24:44.21Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fb/c5987eeff60e9d736a720ef1c6f3dadee39619f596517d8983a99ce4873a/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:7062196b773cc8b956b0dbece42203eb493270e0707e37447801255ad26f065e", size = 3843321, upload-time = "2026-09-05T19:24:45.55Z" }, + { url = "https://files.pythonhosted.org/packages/19/43/cc1e93c74103a225dc39c30966ff3bec9973be0bbb338b080a55e4653bda/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:15ddde16a1398c601a1bb791e5c3a796ce83222b79ecb21bd7070e52aa007257", size = 3735663, upload-time = "2026-09-05T19:24:46.866Z" }, + { url = "https://files.pythonhosted.org/packages/42/09/b7103e26fe6103e217c281f6581b918a2455f5a1600fc197b82010953b19/pydantic_monty_client-0.0.23-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b41dbca54254850a5ab68bed4c75992d6eeacd283265f7445c4ae705e29fed5a", size = 4006344, upload-time = "2026-09-05T19:24:48.17Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/6b21d3d52dcb1cdbeaa593baed170d2454a61bf5aad2692e6e3b3136c5f8/pydantic_monty_client-0.0.23-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1081ab9038004739ede448249d0b1c3d409fb8641ee9bad9ab5347d2bb415600", size = 3896737, upload-time = "2026-09-05T19:24:50.226Z" }, + { url = "https://files.pythonhosted.org/packages/02/df/6a4ed64e3c21389b7e277a7fc98df1021767f9e27edd487b9c5cb1cc4448/pydantic_monty_client-0.0.23-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:182798245ed7ba503c49c9cf5f313876df9b81f57fcbfdf12efa1aa25be0fcd1", size = 4199695, upload-time = "2026-09-05T19:24:51.844Z" }, + { url = "https://files.pythonhosted.org/packages/98/a7/5f166faeff1af270c1d139c90792fe9a2edf00b3c5e16d5014feb29c8d18/pydantic_monty_client-0.0.23-cp312-cp312-win32.whl", hash = "sha256:4d6354723ba6165d3856eac1439d2abed9d231730069c858219456e866a3bc16", size = 3288615, upload-time = "2026-09-05T19:24:53.371Z" }, + { url = "https://files.pythonhosted.org/packages/99/d9/ff24b9dd6d00b65724962f21f10c5844492d1c8d101d86e601155f6c0425/pydantic_monty_client-0.0.23-cp312-cp312-win_amd64.whl", hash = "sha256:099173c188a063ebecd79ea5a2268d06f0031ec93dbaee03b62f4386e024f5dc", size = 3927504, upload-time = "2026-09-05T19:24:54.748Z" }, + { url = "https://files.pythonhosted.org/packages/cf/75/ca3594de58d97c1f34a5c8c6965a3acc69a73e7e616c8b99b8f6fc221c55/pydantic_monty_client-0.0.23-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:80fc4d09ade86f9d527dd79d8ab6f26e510d687c962ca0d499b391905f6efd9e", size = 3934648, upload-time = "2026-09-05T19:24:56.682Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a2/ee39d943b7eb874a91df0fa0175c8153509322772bc5abace77f5e1763c1/pydantic_monty_client-0.0.23-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48f6cbb37c7a56dce5e1c4000e286447d5fb51dcf1e1cf5b69185922c43965e6", size = 3703648, upload-time = "2026-09-05T19:24:58.334Z" }, + { url = "https://files.pythonhosted.org/packages/07/2b/67bc41a8a6c178bd840e1e6115a7af3b0289ba7b1f7b4d8f39a23d0abd43/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:9b71eb5edd68ef9de22064dd5cf28feca283b5aef8f0ff00bb1308deba5e56bd", size = 3730495, upload-time = "2026-09-05T19:25:00.1Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2a/0d94df69fef37fd3d4fbc0330e7f8442eb9f1bcb7f2ece69f90ef0b349b6/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:2a0aa01d8c1321b610b1c0633b7ffcf9dcbb7ecaa79e5bd6d355483b23440231", size = 3425587, upload-time = "2026-09-05T19:25:01.593Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4f/61f1f175987a3ad595ee31b883ca1521f7defe3c733b7eb11f228b608a36/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:bfea6ada82fb43308833852980b147edca3a8de48bc2ce4e7cb08c6f1e13144a", size = 3633833, upload-time = "2026-09-05T19:25:03.397Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/173b79e38316b878534fb268bcb461730301ca2e7d656f71599f3be4cdb8/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:4ca25b02433751eb0735badd7004543f5eb799a7afc7354d0b0caeadc2e754d2", size = 3845772, upload-time = "2026-09-05T19:25:04.92Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/66707709d9d11222e04efd934486f22b1c44bdf4a62954516cfc746c5845/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:a4a7b40bb0d48b513a72977ccd9192d585c85bb470447a80a0a128a258d4c2c2", size = 3738982, upload-time = "2026-09-05T19:25:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/91/ac/d3ffac7ea991b490271df273213765408df4dec6943fed99ff36db1f0642/pydantic_monty_client-0.0.23-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6acfe4ffe75aac0b89f13ea1a527f388fc0d09643bca25d7c78f59b4fed5be25", size = 4011071, upload-time = "2026-09-05T19:25:07.746Z" }, + { url = "https://files.pythonhosted.org/packages/fa/26/c6599214e286e55eb7dcc8b43617b74878fe4d55f69b8e0d12abb0576019/pydantic_monty_client-0.0.23-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:538a8edd9afcc3461fe9b744beda644d1daca60fc76423e695795de57d939d4f", size = 3899476, upload-time = "2026-09-05T19:25:09.499Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4d/e3179f513cb4e112d6acd1fafff1217eaf5567ea351f00dc61dd92a7d8ae/pydantic_monty_client-0.0.23-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ef72181626512a8b7c3c6365d60e416c57bef219ee68f98475f7ec7c59db0db0", size = 4203793, upload-time = "2026-09-05T19:25:11.253Z" }, + { url = "https://files.pythonhosted.org/packages/54/68/2889ff38031ab76eababba29eec92c7d4e9f23a84a58aad306f4609219d8/pydantic_monty_client-0.0.23-cp313-cp313-win32.whl", hash = "sha256:3509dce955db0b7ccbf8a2b458d6562e289295281b7910841e42a1de656a59dc", size = 3288733, upload-time = "2026-09-05T19:25:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f6/e8344f4b3c4d0b8b37fee2b3d27676ae8ad35c5ef731c6ba48ae4012448c/pydantic_monty_client-0.0.23-cp313-cp313-win_amd64.whl", hash = "sha256:c661a74a80158460d633ac5510ccc5c80f77eb3c6c51a54a71d1d9a6108a6fdb", size = 3931122, upload-time = "2026-09-05T19:25:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/96/73/f4f36e465afd5346737a46b59c50e50ec133b9f05a0ba7f3adab0c5e8ab4/pydantic_monty_client-0.0.23-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:28bbf56a7f48dda2acf1cc11807daa50af8cc94e34e1c57fac5d16f99b3e7392", size = 3935351, upload-time = "2026-09-05T19:25:15.497Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ab/0f6f9e9018c2107c9f0bf036167cadc2d1e7edf8275727c9333311f0f8d5/pydantic_monty_client-0.0.23-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff363c8a2afb25d6e716f2a67a6b350aaa864a278b0ba60a390231d4b351940b", size = 3704385, upload-time = "2026-09-05T19:25:16.944Z" }, + { url = "https://files.pythonhosted.org/packages/54/71/2a0eded398b705c65a6d56cb8139c9fce4d878e01594570c98574734818a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:94b056d3ee44ff3fa39be0790debba41f65fcb7db9d26461f039a01e49349c0a", size = 3730307, upload-time = "2026-09-05T19:25:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c8/ba9fba363a8c96ef4635cf26f3d6d3715e7663823d166676bdf8f5bc06a3/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:f94396bdd59f74918871307fb7f63f4ed852ab770a91a70ed29d159b63823e97", size = 3426378, upload-time = "2026-09-05T19:25:19.958Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/73d07f0b6f2608e155e121c573b4c5f42fa9dba672f4a5b827045ead3bd9/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:fbc150afe82ab299ceb80a9c308224f3d10ac6a1d98cc9214c24baede8fc42d5", size = 3635275, upload-time = "2026-09-05T19:25:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/6a812e7bce36c48d87d84d0cda46002c86bcda8aa5fdf18667d48174815a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:a080f9e5427b3223189c456337ec7866cb51c2033592c89f289ef457153800a8", size = 3846543, upload-time = "2026-09-05T19:25:23.174Z" }, + { url = "https://files.pythonhosted.org/packages/2b/06/19788b96be88fe6f58783b7a24225fb3129de7d4807c0837ae50c67172d6/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:ccae280613d9e15f8344cf82cda25d24dc7b598e191c4549c5f3118f327659b4", size = 3741406, upload-time = "2026-09-05T19:25:24.837Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/306c0715579eb2ecf63166470535951d11907b162ac8d54cbdcea8eb479a/pydantic_monty_client-0.0.23-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:f41b870c7ce2e0b852ae50355749b47d92452ced6f166d32ee9df5da714356a2", size = 4011087, upload-time = "2026-09-05T19:25:26.483Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9d/07e939e83c4b223a0a5941664c2447dcd24c0d3c11c78721889d02d16443/pydantic_monty_client-0.0.23-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2d48db758abafcf45cdbfd710f998a6b0af991267587831498739ac11fd7e3b5", size = 3899694, upload-time = "2026-09-05T19:25:27.818Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b7/fdbf223f919b2b8ffdd2df79912b3eec1db274656486534b1ffd84b8dbc1/pydantic_monty_client-0.0.23-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7fedc8d482e9338e8e97d2d433cf82b10f671d98416b427a52ab034978731e3d", size = 4204183, upload-time = "2026-09-05T19:25:29.279Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/b231969ef89209c02b172fddb6182f328b56f89b6c494b238cd923c50766/pydantic_monty_client-0.0.23-cp314-cp314-win32.whl", hash = "sha256:3bc5e57df0e44057b97614149ea749438f3e3cb48c93c9a06f0ffc753824a28d", size = 3289691, upload-time = "2026-09-05T19:25:30.833Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5f/5e681044f43f6e3fd42dffc84548d86c0a15b06486f29194eca54caa1b8e/pydantic_monty_client-0.0.23-cp314-cp314-win_amd64.whl", hash = "sha256:e2b664edc793fda985f7fb6a03fddfe536fd6de4b7dba0765282938a883f4c51", size = 3929684, upload-time = "2026-09-05T19:25:32.378Z" }, ] [[package]] name = "pydantic-monty-runtime" -version = "0.0.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/29/e44460fd934584ddecc6b8a8acddbc0605e86ea9d027910acdbf526c8f14/pydantic_monty_runtime-0.0.19.tar.gz", hash = "sha256:717e11349d7234575750cec881ac390961de02a40bd09f875dc5e097705b896b", size = 1322628, upload-time = "2026-07-24T10:00:14.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/58/3e661a611e74be1ebd607cb8aa4d6098812cf02267032dd29ab36c97af50/pydantic_monty_runtime-0.0.19-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3f512175460f7d6c3c14c06368962c8ffbe5a4d0f5446dc8c7865e3296c8b14e", size = 9449108, upload-time = "2026-07-24T09:58:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/a4/71/0e7d258aa755ed993e4f3a0a9a74af5bfcb2cbb5ca6266061a8f65bef0b7/pydantic_monty_runtime-0.0.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6da3f1c9cb810fdfc5a3878ac1271945906f09b238e8034dca8bd0554120f21b", size = 9735875, upload-time = "2026-07-24T09:58:16.355Z" }, - { url = "https://files.pythonhosted.org/packages/cf/de/7252d0212cf630f3d93029584fef41cfbea9771f09f74543b74f3a09b114/pydantic_monty_runtime-0.0.19-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:84ef70970561c4e75877d478d6262db9761bfaf56242be304198e2be2b1e5ff4", size = 9171499, upload-time = "2026-07-24T09:58:18.788Z" }, - { url = "https://files.pythonhosted.org/packages/e3/8b/0902cb24c51d50d8e8fe2ffda4c78a49cdc1b43959432f6609da7478a69d/pydantic_monty_runtime-0.0.19-cp311-cp311-manylinux_2_28_armv7l.whl", hash = "sha256:aa3b392025457ea8e261a4671952419d51540dc72171927e046734fd610ab908", size = 9565495, upload-time = "2026-07-24T09:58:21.103Z" }, - { url = "https://files.pythonhosted.org/packages/12/29/1795d33147d68f5d0acacc45c118583d8607ec2df2b7c82888728ca6faa3/pydantic_monty_runtime-0.0.19-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f1d83baaed567e6ea6b6647642dcda139df9aa33aa47d0fb4772fa01a0956082", size = 10199598, upload-time = "2026-07-24T09:58:23.491Z" }, - { url = "https://files.pythonhosted.org/packages/23/36/77182137b297e53cb60c779b10102a3225bd05bd24c832a6e8cae34c60fb/pydantic_monty_runtime-0.0.19-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:e74ddfcec96e09a54e2b5b9ab5e197b3eb6d86ec1f21147dd689c7b8d21a1af3", size = 10355699, upload-time = "2026-07-24T09:58:26.141Z" }, - { url = "https://files.pythonhosted.org/packages/db/78/f9f1d4d9dbea085de7ebb713e4ce93a30e638560fdd12eccd874fb4185d6/pydantic_monty_runtime-0.0.19-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:3b4fff2d14d397205aa73ae294fd9bf8e985f37efb49d0047a989fe38441db8b", size = 10197860, upload-time = "2026-07-24T09:58:28.517Z" }, - { url = "https://files.pythonhosted.org/packages/62/22/cad347f3109f48bd7e0371c0cd3d81b7090c309d734bea0632d4c6087808/pydantic_monty_runtime-0.0.19-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:df7b3ae6fc2f4560685aef1d321feabc00ada6f61d9622201fd1c92ce1a969d6", size = 10661716, upload-time = "2026-07-24T09:58:30.979Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0b/401b8ad8d77cb55417e06a75549e98190109c2adff2c02a58f6a6520958d/pydantic_monty_runtime-0.0.19-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c0f505f44ac4fea5fe714a44b32bb7c157285c3627433f0361795070ae4ee47", size = 9143209, upload-time = "2026-07-24T09:58:33.615Z" }, - { url = "https://files.pythonhosted.org/packages/5d/c4/b5a5fe26c2209b0e07de1671ebb8e595cd32d0620d709f06ed879f47f2af/pydantic_monty_runtime-0.0.19-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:358364545a90d2f4229cedfa3346f3559ddd3e7b897dff2ef30c5c2ee3919eb1", size = 9731588, upload-time = "2026-07-24T09:58:36.101Z" }, - { url = "https://files.pythonhosted.org/packages/52/8a/7319feb23909047283c0891f59a621a3b7747e8a40afcc99ba7b65ec8d0d/pydantic_monty_runtime-0.0.19-cp311-cp311-win32.whl", hash = "sha256:3447e0cabdbdd3ed6d07327b0f23fb342915a27b3e5378d1e68e73a92a2007fe", size = 9227833, upload-time = "2026-07-24T09:58:38.482Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f8/40661b44c19b9c02b625ae1549e74712d23485b6e6cae59a05c2c9cc43b2/pydantic_monty_runtime-0.0.19-cp311-cp311-win_amd64.whl", hash = "sha256:aa2e84f08e65d022b107f49392486a502c2a894346ac31ec9d5841081a9bd466", size = 10941520, upload-time = "2026-07-24T09:58:41.429Z" }, - { url = "https://files.pythonhosted.org/packages/fd/11/a6f4e12982b2232b9036db334fbcfecbacf46b9acaf311f9c3110e431c53/pydantic_monty_runtime-0.0.19-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:be34905548e31237fc683f5a34986489c127727e8f65481e8c87c4ad0b3a4dc2", size = 9449108, upload-time = "2026-07-24T09:58:44.394Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a0/1e720b1bba457c6a1ab4fca20c571ae058238c7df3e1892b5efebad05a8b/pydantic_monty_runtime-0.0.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0f402f20672c1e25d4915de7e97023519461b9ffbedacd08c427d40daa77bdd6", size = 9735875, upload-time = "2026-07-24T09:58:46.952Z" }, - { url = "https://files.pythonhosted.org/packages/51/97/4439b312d9463881630dd9d998d2c8fe2b8ef457b451202c71816e25688c/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9ec0d25dfbe65b9a5dfe77ff86353e95a86774d786a1d496206163f828f072fd", size = 9171496, upload-time = "2026-07-24T09:58:49.504Z" }, - { url = "https://files.pythonhosted.org/packages/1e/67/fa7b99afe1917b89eec3b4b6375f468c76eeea8227dd48361b7f2db90d97/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_armv7l.whl", hash = "sha256:7ecc2f8eeabf6483908db4cc69b7d4c156a95675dcbdda2a7540a22ed904bfb2", size = 9565495, upload-time = "2026-07-24T09:58:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a6/83a9dceb8d9f5dffd9a082b60590bb61b0ac48dca19aa02847ebbab1ad46/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:fc1e508b9dc2cab64e27004d0f4ce44c7e1d255e85bafba390884fa07d696319", size = 10199598, upload-time = "2026-07-24T09:58:54.394Z" }, - { url = "https://files.pythonhosted.org/packages/c6/5e/bafd9dfa9a9a0d26b9882053aaea2280d791225c1e3b33b4b48f23fd5f92/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:9e3f6e366f62e54d7bd0fb995f007f42d23c26eee560d57c04d75d5adde3b45c", size = 10355698, upload-time = "2026-07-24T09:58:56.713Z" }, - { url = "https://files.pythonhosted.org/packages/0a/00/ef55cdc9f5ade20475622bb8dba617efce00ef9da2b94b36a76172edadce/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:2ddf4f2d1d063f330bb54b2dc00f6d5bcfbdd3defdc5988b856d350f62160e26", size = 10197859, upload-time = "2026-07-24T09:58:59.158Z" }, - { url = "https://files.pythonhosted.org/packages/a8/67/d1c359658458cf97296fda4359bfc71de8c9f968fb3db68eb7ce00136d43/pydantic_monty_runtime-0.0.19-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ce2c9550c30a5c94b63dfe578243d0823511feeecb020723f5140f9737505410", size = 10661715, upload-time = "2026-07-24T09:59:01.576Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/9841d93f956bfbd0bbbac75edf42bb3b13b5bad7d25b09d9a221b5e54d71/pydantic_monty_runtime-0.0.19-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:05563e178b6be783de088abe4a25cef9cfbc04811f7c1e1873860252e711edac", size = 9143212, upload-time = "2026-07-24T09:59:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ce/0cd3643cc5051456b7baad6f16d85fb1d05daefd0556e4c82ea8f22cc9a2/pydantic_monty_runtime-0.0.19-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de74aa4df47147d104bccdac4b39c1f3fd543091be3fd0156f77eeea3e483bc3", size = 9731590, upload-time = "2026-07-24T09:59:06.323Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5a/69f4eabec2538df364242395ab3ef77b30a124a0e4b461231589651a1e97/pydantic_monty_runtime-0.0.19-cp312-cp312-win32.whl", hash = "sha256:5208056d9e23d951768ba4b94df3caf7fd84bfe951f68ec4a1803eb03377bbeb", size = 9227834, upload-time = "2026-07-24T09:59:08.694Z" }, - { url = "https://files.pythonhosted.org/packages/c7/4b/221a21f477aef0c488cbe1467111b0988658bc4a42cfc6b404201bc432af/pydantic_monty_runtime-0.0.19-cp312-cp312-win_amd64.whl", hash = "sha256:e4bba0c6024a3a8bd8c8a8cba25233a19cf686218e97afb4c059aa0c625a4b8b", size = 10941520, upload-time = "2026-07-24T09:59:10.959Z" }, - { url = "https://files.pythonhosted.org/packages/f2/a8/1ba497c35eca33273f2144b8e78d94832cc33aec5f69d8bef56968a61933/pydantic_monty_runtime-0.0.19-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:84dd041652581335503af7c60ee7362a462d946a62e8c4a44a939984034d0d25", size = 9449108, upload-time = "2026-07-24T09:59:13.497Z" }, - { url = "https://files.pythonhosted.org/packages/2d/6e/08c05c9729a35d6c9831bfd57d535bd1257f601d15b62936c27f1c0cfb47/pydantic_monty_runtime-0.0.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:263ab33dce2008ca6c83b4013b0280a98a1991d4071c55413d00b539940fe8aa", size = 9735874, upload-time = "2026-07-24T09:59:15.918Z" }, - { url = "https://files.pythonhosted.org/packages/c0/64/63fbdb4c069ceb2af6261fe5923864b5be309799087f16a5dccc96141c12/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:64f56d48097d8a158125534b20e8b22003c5e727082a303907da8e61aae0c7e3", size = 9171498, upload-time = "2026-07-24T09:59:18.799Z" }, - { url = "https://files.pythonhosted.org/packages/1d/cf/82390fe7f0e1100662e6cac7a1c0de716ea4bf851a53aa2b0ef324fc4e3e/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_armv7l.whl", hash = "sha256:a4d80fb4598524cd5039a596f8e6900adb2a5a2da66d012a3734a2b441ad06c3", size = 9565494, upload-time = "2026-07-24T09:59:21.232Z" }, - { url = "https://files.pythonhosted.org/packages/23/37/caa8bf32ba0c0e8ce31ef2773b1a1f60d688e0237cd396e48bbef9f7161f/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:1c5cd0b140c765772e0606a7047fbf95cc49d62d0d8b79b4f520dae0e38b3ba7", size = 10199597, upload-time = "2026-07-24T09:59:23.702Z" }, - { url = "https://files.pythonhosted.org/packages/48/25/ae9bb52518b2ce8fe7509d6cad4f4916b4458b748fecb180bef2d78165e9/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:7d80975c6e792088285f49a91e26de483ef95e5bffc4939b02d4c5ea1059749b", size = 10355699, upload-time = "2026-07-24T09:59:26.389Z" }, - { url = "https://files.pythonhosted.org/packages/a7/38/0875b1239b8ea57e4ea6de421cd240fc5a88c02e381f88d858f00439b1e9/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:f826cb911f78fe4fddf09fa3f38518484041908d08de927fa7dd134c002a2c77", size = 10197858, upload-time = "2026-07-24T09:59:28.889Z" }, - { url = "https://files.pythonhosted.org/packages/11/d4/9365473fe31e53a6dc4d6fea406d5d8f3f03a9b7d84d1f29a9e6d664c862/pydantic_monty_runtime-0.0.19-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:fdf56c6cd8c6163737d1ce284f9acb00feff77b7e952f041dc281b94336c4fc9", size = 10661715, upload-time = "2026-07-24T09:59:31.498Z" }, - { url = "https://files.pythonhosted.org/packages/20/f9/8d85b8f77d4006a8d80517a0781c49e6ca026f68747f801b63298e64197e/pydantic_monty_runtime-0.0.19-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a675f165773af0a2e0209dd96014aa9e613115cdc6dad297a7bb96cf87f83315", size = 9143210, upload-time = "2026-07-24T09:59:33.843Z" }, - { url = "https://files.pythonhosted.org/packages/58/53/ded73742ee9fa2160c711141c28ea2ee083f073019e89724049c5e67cd84/pydantic_monty_runtime-0.0.19-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9e42ab0287c0497d9d5529e8a53dd2f63f9d1f92f6592170baab894ea9956da6", size = 9731590, upload-time = "2026-07-24T09:59:36.642Z" }, - { url = "https://files.pythonhosted.org/packages/4b/06/67be8320dc592b8c4caa8c4c1544c8ddf2760029d46843d078aa5a4cdb14/pydantic_monty_runtime-0.0.19-cp313-cp313-win32.whl", hash = "sha256:1f5ff1b9585e648304096705045fb6bd90d43b561568b0d373265e8d201b1234", size = 9227833, upload-time = "2026-07-24T09:59:39.022Z" }, - { url = "https://files.pythonhosted.org/packages/7b/f4/bab34897974d83640f8773f03d2001142bc13e80e517bfce8c8c4a57157e/pydantic_monty_runtime-0.0.19-cp313-cp313-win_amd64.whl", hash = "sha256:7181a2153ff257fe34109685148167b6e8219d4acfe8f346115b58152fd26aa8", size = 10941519, upload-time = "2026-07-24T09:59:41.538Z" }, - { url = "https://files.pythonhosted.org/packages/63/8d/f46ac4778b2ac64183607bc63ecc783f16ca9981de30eb8ec9aa5e7132cd/pydantic_monty_runtime-0.0.19-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4cc92139b476469c0d7e5caa147d38c2929de39bf1724cb22bbab80297823963", size = 9449107, upload-time = "2026-07-24T09:59:44.139Z" }, - { url = "https://files.pythonhosted.org/packages/a5/14/34a7bb4630d1bac0d055049568ecc888a87954c176428bde5764a7ff6ed7/pydantic_monty_runtime-0.0.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a97e00bd46305f34a85f2e2e3ac4c92dbd3340e7af694aafb192ff58c4fb40e", size = 9735874, upload-time = "2026-07-24T09:59:46.546Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/977de0b258c0858f52b23bd32afbfdf8a8c4614daff5d0b7d5c86332ce6e/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:46ad28b1b3c41113c9e89373da18bcc883a24da371d4eeb9b32d3d194286f1a5", size = 9171497, upload-time = "2026-07-24T09:59:48.768Z" }, - { url = "https://files.pythonhosted.org/packages/39/39/4374200ee4b938fc8b5026056f13bb677e15796bca1323a16210738431ad/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_armv7l.whl", hash = "sha256:dc61844187a32c2f9c2b69846c5d55679eb838b4f7e495b4d03509f89fbf41f9", size = 9565495, upload-time = "2026-07-24T09:59:51.149Z" }, - { url = "https://files.pythonhosted.org/packages/04/55/c4ee4b0a10610359e09cad9d327db19095914a3bf427fb3d8164ec2bcae0/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:b23b52a79ee6be0a943e4b8e5d996e6c489a37b23a337838164f22c9e8ed11a7", size = 10199598, upload-time = "2026-07-24T09:59:53.619Z" }, - { url = "https://files.pythonhosted.org/packages/87/62/cc9df084e9f930bbb2873b6dd832b377f76071aec309b1545589d818fd90/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_ppc64le.whl", hash = "sha256:484496817f5f238c42aaea8a1945b545a17d8ef2a21e9e81799d55e481d25485", size = 10355699, upload-time = "2026-07-24T09:59:56.043Z" }, - { url = "https://files.pythonhosted.org/packages/c8/cc/bb13e6f655fcaee340032ad6a3cd1524957d0fa471ddc7e27bdb1f4c240b/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_s390x.whl", hash = "sha256:c3e7cbad58ae9bd3402581faa7d54d719f9c140dc1888f5c9439d45bff528ea1", size = 10197859, upload-time = "2026-07-24T09:59:58.481Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c3/c532715987383668ee835337e1485f51585bc8bf189f033370a05eff17f1/pydantic_monty_runtime-0.0.19-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5534067dc7ffdae809293da95d0e95b6d8481f4c88aff59385e19f466ba3c0f0", size = 10661715, upload-time = "2026-07-24T10:00:01.163Z" }, - { url = "https://files.pythonhosted.org/packages/ed/6c/9a0ab28a061efc184398a0b4db34e459572bb2316cf766f0d6ce65b475e3/pydantic_monty_runtime-0.0.19-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c1ae32b06a4456ab223bafa54d29a349066d625d09063c28ba14ee9019f9b7c2", size = 9143211, upload-time = "2026-07-24T10:00:03.856Z" }, - { url = "https://files.pythonhosted.org/packages/23/5f/af5b3e6395834572975d98f4d1a00a57ee8029bf68ca5732347550f32b35/pydantic_monty_runtime-0.0.19-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:497cf8c3f30992f9aafc8707084eaffe2391b7b5dec067d04d5715f9a562c56b", size = 9731591, upload-time = "2026-07-24T10:00:06.351Z" }, - { url = "https://files.pythonhosted.org/packages/1a/98/96797fd269342cfdb49f03c22fd9a05ef91f711089081c4b3861b9c520e2/pydantic_monty_runtime-0.0.19-cp314-cp314-win32.whl", hash = "sha256:942feb948df8edb61ae7ba6ae77dc655e6985be06d72eef886562fe573ba3086", size = 9227832, upload-time = "2026-07-24T10:00:08.708Z" }, - { url = "https://files.pythonhosted.org/packages/39/fc/02d15281c8e00b48df9af8f75a4fe06f3f8f33ef6a910507a45a19f2b61b/pydantic_monty_runtime-0.0.19-cp314-cp314-win_amd64.whl", hash = "sha256:91d93339c70483ed9256b3b15e3375f6597ae65be280f9b89ba9ca0355f95f54", size = 10941519, upload-time = "2026-07-24T10:00:11.226Z" }, +version = "0.0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/1d/c1139f1f82460046d505d7a6871d4d22f9e36cdab967807487f6a70cb51f/pydantic_monty_runtime-0.0.23.tar.gz", hash = "sha256:d181f557cd3d19ee826459dea234a2b440e2b6de093e66384629843959146071", size = 1727575, upload-time = "2026-09-05T19:26:05.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/97/d1575be31396cf662ea8419d427c58279378a2e08b674d436ed4990751ea/pydantic_monty_runtime-0.0.23-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c8968491ae44648c11cf075c0f5d798d4aa7f7e4a0d0754add0b74d725c075c", size = 9828049, upload-time = "2026-09-05T19:25:34.446Z" }, + { url = "https://files.pythonhosted.org/packages/15/09/01ae8472d860223618cd61a4656d8ff44519e1bd9e320ee8698dbb206aca/pydantic_monty_runtime-0.0.23-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c60e44cbef9cfdc9bd5e53631650490e66890f57e8c6bea5611dae502d8374d4", size = 11548563, upload-time = "2026-09-05T19:25:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/fe/30/ebf796ec2236b15cb9f5541e3a940536f37de41bd2561989985aa92fb396/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4bc24603678107b7f5b2b24a5946ca1e103379b2289589212c86959993a052b4", size = 11935977, upload-time = "2026-09-05T19:25:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/c2/40/8ed68e144f80c9d14e0532d631ffd78ee6a0ae8d7d3272ac037d5d703266/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_armv7l.whl", hash = "sha256:7c68422e06c5aae8b52f9835ee949256db0f261301515e4443d6647f249cf789", size = 10180097, upload-time = "2026-09-05T19:25:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f3/ea3632cebb754b81fa63c01b84c89299bba936c670fa519f3ff363166bcc/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_i686.whl", hash = "sha256:b8a049a554bff6ba7e553c1ae4b6290e554b6a1695bc1d47b1b03db485f7fb91", size = 10515148, upload-time = "2026-09-05T19:25:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a9/6a43d9d9f01f441ecd6ec6c31d9b8d45b143a37b9368f582b27378e50ec7/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_ppc64le.whl", hash = "sha256:9cea174ed1a56888bb58975feff34b0475504ab3831b3b1dc3189397d5bcccc3", size = 10746650, upload-time = "2026-09-05T19:25:46.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/dc2c2e066ce4f619f34a98b44c9c84fb398095421808c9dd6ac12463aea2/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_s390x.whl", hash = "sha256:28bb59cde9de0cec0d3d5d72029851757249c682a6747251f61e8cdece5995f7", size = 11092334, upload-time = "2026-09-05T19:25:49.314Z" }, + { url = "https://files.pythonhosted.org/packages/3b/82/6af8a19dd357d63e783e9647ef81d47db2f48c13e96558d25484cd0dec91/pydantic_monty_runtime-0.0.23-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:721d2d1455482c929c934412e61ecf56673b88a2c0c049e041295cf543ff1412", size = 12371608, upload-time = "2026-09-05T19:25:51.663Z" }, + { url = "https://files.pythonhosted.org/packages/f7/53/2939bce8c8a688771777ee2301851a712744d809d752c1c8dcb5d6a52574/pydantic_monty_runtime-0.0.23-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f0183e589ba18de4fac8a675fe0f93049baa293d5409eb8d39c639dc24af943f", size = 9593312, upload-time = "2026-09-05T19:25:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f9/180d9acbea18d8050c190280ef591cb52d05e4d0e6d026cc6b7b2613510c/pydantic_monty_runtime-0.0.23-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:cbf56c19712c12072f6607d8e2323941441c9343734dcb07d2d2b4ce2dd98bf3", size = 10133520, upload-time = "2026-09-05T19:25:56.797Z" }, + { url = "https://files.pythonhosted.org/packages/e1/00/dcd69bd9fcc57c7a6ecfcd3b2199f4598853edb14d504f481dcda41b17cd/pydantic_monty_runtime-0.0.23-py3-none-win32.whl", hash = "sha256:854f58a472397f34588a22eb813f2acd65efc0bcb966ef1fe173c32366209d15", size = 9639882, upload-time = "2026-09-05T19:25:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c0/3f7c604ac93ec47a36a3733d6e7717b7096f0989f669d4133bd3e85f3375/pydantic_monty_runtime-0.0.23-py3-none-win_amd64.whl", hash = "sha256:742375f494e298a4f96933ac3694a0fe34c9cddd9b666669bc193f348bbed0b8", size = 10404629, upload-time = "2026-09-05T19:26:01.143Z" }, ] [[package]] From eddccc474c078e443d4f5cadeaafc0ae80ae6e09 Mon Sep 17 00:00:00 2001 From: CoralGarden52 <97677340+CoralGarden52@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:20:08 +0000 Subject: [PATCH 015/108] Python: count async FunctionTool invocation exceptions (#8278) * Python: count async tool invocation exceptions * Python: track direct async tool failures --- .../packages/core/agent_framework/_tools.py | 18 +++-- python/packages/core/tests/core/test_tools.py | 66 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index d09078ea434..7c471bd5d93 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -614,9 +614,16 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: if func is None: raise ToolException(f"Function '{self.name}' has no implementation.") # If we have a bound instance, call the function with self - if self._instance is not None: - return func(self._instance, *args, **kwargs) - return func(*args, **kwargs) + result = func(self._instance, *args, **kwargs) if self._instance is not None else func(*args, **kwargs) + return self._await_invocation_result(result) if inspect.isawaitable(result) else result + except Exception: + self.invocation_exception_count += 1 + raise + + async def _await_invocation_result(self, result: Any) -> Any: + """Await a function result and count exceptions raised by the awaitable.""" + try: + return await result except Exception: self.invocation_exception_count += 1 raise @@ -626,9 +633,8 @@ async def _invoke_function(self, call_kwargs: Mapping[str, Any]) -> Any: func = self.func.func if isinstance(self.func, FunctionTool) else self.func if inspect.iscoroutinefunction(func) or getattr(self, "_invoke_sync_on_event_loop", False): res = self.__call__(**call_kwargs) - return await res if inspect.isawaitable(res) else res - - res = await asyncio.to_thread(self.__call__, **call_kwargs) + else: + res = await asyncio.to_thread(self.__call__, **call_kwargs) return await res if inspect.isawaitable(res) else res @overload diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 33fad82ddc6..e23d1db751d 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -343,6 +343,72 @@ async def async_test_tool(x: int, y: int) -> int: assert (await async_test_tool(1, 2)) == 3 +async def test_async_tool_exception_limit_counts_awaited_failures() -> None: + """Async tool failures count toward the configured exception limit.""" + from agent_framework.exceptions import ToolException + + @tool(name="failing_async_tool", max_invocation_exceptions=1) + async def failing_async_tool() -> str: + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + await failing_async_tool.invoke(skip_parsing=True) + + assert failing_async_tool.invocation_count == 1 + assert failing_async_tool.invocation_exception_count == 1 + + with pytest.raises(ToolException, match="maximum exception limit"): + await failing_async_tool.invoke(skip_parsing=True) + + assert failing_async_tool.invocation_count == 1 + assert failing_async_tool.invocation_exception_count == 1 + + +async def test_direct_async_tool_exception_limit_counts_awaited_failures() -> None: + """Direct async tool calls count failures toward the configured exception limit.""" + from agent_framework.exceptions import ToolException + + @tool(name="failing_direct_async_tool", max_invocation_exceptions=1) + async def failing_direct_async_tool() -> str: + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + await failing_direct_async_tool() + + assert failing_direct_async_tool.invocation_count == 1 + assert failing_direct_async_tool.invocation_exception_count == 1 + + with pytest.raises(ToolException, match="maximum exception limit"): + await failing_direct_async_tool() + + assert failing_direct_async_tool.invocation_count == 1 + assert failing_direct_async_tool.invocation_exception_count == 1 + + +async def test_sync_awaitable_tool_exception_limit_counts_awaited_failures() -> None: + """Sync tools returning awaitables count failures during async invocation.""" + from agent_framework.exceptions import ToolException + + @tool(name="failing_sync_awaitable_tool", max_invocation_exceptions=1) + def failing_sync_awaitable_tool() -> Any: + async def fail_later() -> str: + raise RuntimeError("boom") + + return fail_later() + + with pytest.raises(RuntimeError, match="boom"): + await failing_sync_awaitable_tool.invoke(skip_parsing=True) + + assert failing_sync_awaitable_tool.invocation_count == 1 + assert failing_sync_awaitable_tool.invocation_exception_count == 1 + + with pytest.raises(ToolException, match="maximum exception limit"): + await failing_sync_awaitable_tool.invoke(skip_parsing=True) + + assert failing_sync_awaitable_tool.invocation_count == 1 + assert failing_sync_awaitable_tool.invocation_exception_count == 1 + + def test_tool_decorator_in_class(): """Test the tool decorator.""" From 3abfddffc5566fd31174df999205f396aa0c837b Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:26:53 +0000 Subject: [PATCH 016/108] Implement same approval process for LocalCodeAct as is used by Hyperlight (#8289) --- .../LocalCodeActApprovalMode.cs | 25 ++ .../LocalCodeActProvider.cs | 24 +- .../LocalCodeActProviderOptions.cs | 13 ++ .../LocalExecuteCodeFunction.cs | 22 ++ .../README.md | 27 +++ .../ApprovalPropagationTests.cs | 218 ++++++++++++++++++ 6 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActApprovalMode.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ApprovalPropagationTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActApprovalMode.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActApprovalMode.cs new file mode 100644 index 00000000000..5e10b88a64c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActApprovalMode.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.LocalCodeAct; + +/// +/// Controls the approval behavior for the execute_code tool exposed by +/// and . +/// +public enum LocalCodeActApprovalMode +{ + /// + /// execute_code always requires user approval before invocation. + /// + AlwaysRequire, + + /// + /// Approval is derived from the provider-owned CodeAct tool registry. + /// If any configured tool is an + /// , + /// execute_code also requires approval. Otherwise it does not. + /// + NeverRequire, +} diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs index 9d36c5e5706..33f9205f735 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProvider.cs @@ -36,6 +36,7 @@ public sealed class LocalCodeActProvider : AIContextProvider, IDisposable private static readonly IReadOnlyList s_stateKeys = [FixedStateKey]; private readonly CodeExecutor _executor; + private readonly LocalCodeActApprovalMode _approvalMode; private readonly ConcurrentDictionary _tools = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _fileMounts = new(StringComparer.Ordinal); @@ -66,6 +67,8 @@ public LocalCodeActProvider(string pythonExecutablePath, LocalCodeActProviderOpt options.BlockedBuiltins?.ToList()); } + this._approvalMode = options.ApprovalMode; + this._executor = new CodeExecutor( pythonExecutablePath, runnerScript, @@ -190,8 +193,15 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co this._fileMounts.Values.ToList()); FeatureUsageMarker.MarkUsed(); + var approvalRequired = ComputeApprovalRequired(this._approvalMode, snapshot.Tools); + var description = InstructionBuilder.BuildExecuteCodeDescription(snapshot.Tools, snapshot.FileMounts); - var executeCode = new ExecuteCodeFunction(this._executor, snapshot, description); + + AIFunction executeCode = new ExecuteCodeFunction(this._executor, snapshot, description); + if (approvalRequired) + { + executeCode = new ApprovalRequiredAIFunction(executeCode); + } var instructions = InstructionBuilder.BuildContextInstructions(); @@ -202,6 +212,18 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co }); } + /// + /// Computes whether execute_code must require approval for the supplied tool set. + /// + /// + /// Approval is bundled: because generated code can reach any registered tool through + /// call_tool(...) after execution has begun, a single approval-required tool escalates + /// the approval requirement to the whole execute_code invocation. + /// + internal static bool ComputeApprovalRequired(LocalCodeActApprovalMode mode, IReadOnlyList tools) => + mode == LocalCodeActApprovalMode.AlwaysRequire + || tools.Any(t => t.GetService() is not null); + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this); /// diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs index 208b9fe24f5..51a161a20ea 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalCodeActProviderOptions.cs @@ -18,6 +18,19 @@ public sealed class LocalCodeActProviderOptions /// public IEnumerable? Tools { get; set; } + /// + /// Gets or sets the approval mode for execute_code. + /// Defaults to . + /// + /// + /// Under , approval still propagates from the + /// tools in : if any of them is an , + /// execute_code requires approval as well. This is required because generated code can + /// invoke any registered tool via call_tool(...) once execution has started, at which + /// point per-tool approval can no longer be enforced. + /// + public LocalCodeActApprovalMode ApprovalMode { get; set; } = LocalCodeActApprovalMode.NeverRequire; + /// /// Gets or sets the initial set of file mounts exposed to generated code. /// diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs index 96736ab4b16..a8b0fd34e22 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/LocalExecuteCodeFunction.cs @@ -20,6 +20,11 @@ namespace Microsoft.Agents.AI.LocalCodeAct; /// Use this when you want to expose code execution directly as a model-facing function without /// the indirection. Tools and file mounts are captured at /// construction time and immutable for the lifetime of the function. +/// When the configuration requires approval (per +/// or because a configured tool is itself +/// an ), the instance surfaces an +/// via , +/// which is how the rest of the framework discovers approval requirements. /// public sealed class LocalExecuteCodeFunction : AIFunction { @@ -28,6 +33,8 @@ public sealed class LocalExecuteCodeFunction : AIFunction private readonly CodeExecutor _executor; private readonly CodeExecutor.RunSnapshot _snapshot; private readonly AIFunction _inner; + private readonly bool _approvalRequired; + private ApprovalRequiredAIFunction? _approvalProxy; /// Initializes a new instance of the class. /// Path to the Python interpreter used for execution and validation. @@ -73,6 +80,8 @@ public LocalExecuteCodeFunction(string pythonExecutablePath, LocalCodeActProvide Name = ExecuteCodeName, Description = InstructionBuilder.BuildExecuteCodeDescription(tools, fileMounts), }); + + this._approvalRequired = LocalCodeActProvider.ComputeApprovalRequired(options.ApprovalMode, tools); } /// @@ -84,6 +93,19 @@ public LocalExecuteCodeFunction(string pythonExecutablePath, LocalCodeActProvide /// public override JsonElement JsonSchema => this._inner.JsonSchema; + /// + public override object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceKey is null + && this._approvalRequired + && serviceType == typeof(ApprovalRequiredAIFunction)) + { + return this._approvalProxy ??= new ApprovalRequiredAIFunction(this); + } + + return base.GetService(serviceType, serviceKey); + } + /// protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => this._inner.InvokeAsync(arguments, cancellationToken); diff --git a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md index c8ac9e694d7..4bcd95720fc 100644 --- a/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md +++ b/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/README.md @@ -94,6 +94,33 @@ total = await call_tool("add", a=2, b=3) print(total) ``` +## Tool Approval + +Generated code reaches registered tools through `call_tool(...)`, which invokes +them directly. A per-tool approval interaction cannot be surfaced at that point, +so approval is **bundled** onto `execute_code` instead: + +* If any registered tool is an `ApprovalRequiredAIFunction`, `execute_code` + itself requires approval before the code runs. +* `LocalCodeActApprovalMode.AlwaysRequire` makes `execute_code` require approval + regardless of the registered tools. + +```csharp +var deploy = new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(RunDeployment, name: "deploy")); + +using var provider = new LocalCodeActProvider("/usr/bin/python3", new LocalCodeActProviderOptions +{ + Tools = new[] { deploy }, + // ApprovalMode = LocalCodeActApprovalMode.AlwaysRequire, // optional, opt-in +}); +``` + +For `LocalCodeActProvider`, approval is recomputed on every run, so tools added +via `AddTools` after construction are taken into account. `LocalExecuteCodeFunction` +captures its tools at construction time and exposes the approval requirement +through `GetService()`. + ## Code Validation By default, the package validates Python code against allow-lists before diff --git a/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ApprovalPropagationTests.cs b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ApprovalPropagationTests.cs new file mode 100644 index 00000000000..8228d853253 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.LocalCodeAct.UnitTests/ApprovalPropagationTests.cs @@ -0,0 +1,218 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.LocalCodeAct.UnitTests; + +/// +/// Regression tests for approval propagation from provider-owned CodeAct tools to the +/// model-facing execute_code function. +/// +/// +/// Generated code reaches registered tools through call_tool(...), which invokes them +/// directly and therefore cannot surface a per-tool approval interaction. Approval must be +/// bundled onto execute_code instead. +/// +public sealed class ApprovalPropagationTests +{ + private static readonly AIAgent s_mockAgent = new Mock().Object; + + private static AIContextProvider.InvokingContext NewInvokingContext() => + new(s_mockAgent, session: null, new AIContext()); + + private static ApprovalRequiredAIFunction GatedTool() => + new(AIFunctionFactory.Create(() => "ok", name: "approval_gated_shell")); + + [Fact] + public void ComputeApprovalRequired_AlwaysRequire_NoTools_ReturnsTrue() + { + // Act / Assert + Assert.True(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.AlwaysRequire, tools: [])); + } + + [Fact] + public void ComputeApprovalRequired_AlwaysRequire_WithoutGatedTool_ReturnsTrue() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "ok", name: "t"); + + // Act / Assert + Assert.True(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.AlwaysRequire, tools: [tool])); + } + + [Fact] + public void ComputeApprovalRequired_NeverRequire_NoTools_ReturnsFalse() + { + // Act / Assert + Assert.False(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.NeverRequire, tools: [])); + } + + [Fact] + public void ComputeApprovalRequired_NeverRequire_WithoutGatedTool_ReturnsFalse() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "ok", name: "t"); + + // Act / Assert + Assert.False(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.NeverRequire, tools: [tool])); + } + + [Fact] + public void ComputeApprovalRequired_NeverRequire_WithGatedTool_ReturnsTrue() + { + // Arrange + var tool = GatedTool(); + + // Act / Assert + Assert.True(LocalCodeActProvider.ComputeApprovalRequired(LocalCodeActApprovalMode.NeverRequire, tools: [tool])); + } + + [Fact] + public async Task ProvideAIContextAsync_WithGatedTool_WrapsExecuteCodeInApprovalRequiredAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [GatedTool()], + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + var tool = Assert.IsType(context!.Tools!.Single()); + Assert.Equal("execute_code", tool.Name); + Assert.NotNull(tool.GetService()); + } + + [Fact] + public async Task ProvideAIContextAsync_ToolAddedAfterConstruction_WrapsExecuteCodeInApprovalRequiredAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "/usr/bin/python3", + new LocalCodeActProviderOptions { ValidationDisabled = true }); + provider.AddTools(GatedTool()); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + _ = Assert.IsType(context!.Tools!.Single()); + } + + [Fact] + public async Task ProvideAIContextAsync_AlwaysRequire_WrapsExecuteCodeInApprovalRequiredAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + ApprovalMode = LocalCodeActApprovalMode.AlwaysRequire, + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + _ = Assert.IsType(context!.Tools!.Single()); + } + + [Fact] + public async Task ProvideAIContextAsync_WithoutGatedTool_DoesNotRequireApprovalAsync() + { + // Arrange + using var provider = new LocalCodeActProvider( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [AIFunctionFactory.Create(() => "ok", name: "ping")], + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + var tool = Assert.IsAssignableFrom(context!.Tools!.Single()); + Assert.IsNotType(tool); + Assert.Null(tool.GetService()); + } + + [Fact] + public void LocalExecuteCodeFunction_WithGatedTool_ExposesApprovalRequiredService() + { + // Arrange + var function = new LocalExecuteCodeFunction( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [GatedTool()], + }); + + // Act + var marker = function.GetService(); + + // Assert + Assert.NotNull(marker); + Assert.Same(marker, function.GetService()); + Assert.Equal("execute_code", marker!.Name); + } + + [Fact] + public void LocalExecuteCodeFunction_AlwaysRequire_ExposesApprovalRequiredService() + { + // Arrange + var function = new LocalExecuteCodeFunction( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + ApprovalMode = LocalCodeActApprovalMode.AlwaysRequire, + }); + + // Act / Assert + Assert.NotNull(function.GetService()); + } + + [Fact] + public void LocalExecuteCodeFunction_WithoutGatedTool_DoesNotExposeApprovalRequiredService() + { + // Arrange + var function = new LocalExecuteCodeFunction( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [AIFunctionFactory.Create(() => "ok", name: "ping")], + }); + + // Act / Assert + Assert.Null(function.GetService()); + } + + [Fact] + public void LocalExecuteCodeFunction_WithServiceKey_DoesNotExposeApprovalRequiredService() + { + // Arrange + var function = new LocalExecuteCodeFunction( + "/usr/bin/python3", + new LocalCodeActProviderOptions + { + ValidationDisabled = true, + Tools = [GatedTool()], + }); + + // Act / Assert + Assert.Null(function.GetService(typeof(ApprovalRequiredAIFunction), serviceKey: "key")); + } +} From e4309e5456c42ce391b982b6c61e60c8197b345c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Fri, 11 Sep 2026 10:54:48 +0000 Subject: [PATCH 017/108] .NET: Add Foundry hosted session client samples (#8227) * .NET: Add Foundry hosted session client samples * .NET: Handle closed input in hosted clients * .NET: Clarify user isolation sample trust boundary * .NET: Keep preview flag on direct Toolbox MCP calls --- dotnet/agent-framework-dotnet.slnx | 2 + .../Agent_Step25_FoundryToolboxMcp/Program.cs | 26 +-- .../InvokeFoundryToolboxMcp/Program.cs | 24 +-- .../responses/Hosted-AgentSkills/Program.cs | 28 +--- .../responses/Hosted-AgentSkills/README.md | 6 +- .../FoundryHostedSession/.env.example | 2 + .../FoundryHostedSession.csproj | 24 +++ .../FoundryHostedSession/Program.cs | 127 ++++++++++++++ .../FoundryHostedSession/README.md | 86 ++++++++++ .../responses/Using-Samples/README.md | 2 + .../UserIsolationAgent/.env.example | 2 + .../UserIsolationAgent/Program.cs | 158 ++++++++++++++++++ .../UserIsolationAgent/README.md | 122 ++++++++++++++ .../UserIsolationAgent.csproj | 24 +++ 14 files changed, 555 insertions(+), 78 deletions(-) create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/FoundryHostedSession.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/UserIsolationAgent.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 6f5fa9d46a6..638d6096225 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -388,9 +388,11 @@ + + diff --git a/dotnet/samples/02-agents/AgentProviders/foundry/Agent_Step25_FoundryToolboxMcp/Program.cs b/dotnet/samples/02-agents/AgentProviders/foundry/Agent_Step25_FoundryToolboxMcp/Program.cs index 8dd3f55a8a1..5a04a8c7048 100644 --- a/dotnet/samples/02-agents/AgentProviders/foundry/Agent_Step25_FoundryToolboxMcp/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/foundry/Agent_Step25_FoundryToolboxMcp/Program.cs @@ -6,7 +6,6 @@ // discovers the toolbox's tools at runtime and invokes them locally. using System.ClientModel; -using System.ClientModel.Primitives; using System.Net.Http.Headers; using Azure.AI.Projects; using Azure.AI.Projects.Agents; @@ -81,10 +80,7 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint, // script, not the application itself. This helper exists so the sample can // be run end-to-end without first setting a toolbox up by hand. - // The Foundry-Features header is currently required for toolbox CRUD operations. - var options = new AgentAdministrationClientOptions(); - options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall); - var adminClient = new AgentAdministrationClient(new Uri(endpoint), credential, options); + var adminClient = new AgentAdministrationClient(new Uri(endpoint), credential); var toolboxClient = adminClient.GetAgentToolboxes(); // Delete existing toolbox if present (ignore 404). @@ -114,26 +110,6 @@ static async Task CreateSampleToolboxAsync(string name, string endpoint, return $"{endpoint}/toolboxes/{created.Name}/mcp?api-version=v{created.Version}"; } -// --------------------------------------------------------------------------- -// Pipeline policy: adds the Foundry-Features header for toolbox CRUD calls -// --------------------------------------------------------------------------- -internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy -{ - private const string FeatureHeader = "Foundry-Features"; - - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - message.Request.Headers.Add(FeatureHeader, feature); - ProcessNext(message, pipeline, currentIndex); - } - - public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - message.Request.Headers.Add(FeatureHeader, feature); - return ProcessNextAsync(message, pipeline, currentIndex); - } -} - // --------------------------------------------------------------------------- // DelegatingHandler: attaches a fresh Azure AI bearer token to every request // --------------------------------------------------------------------------- diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs index 812d13cf7a2..7c3ecbc7e96 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/Program.cs @@ -5,7 +5,6 @@ // through the reserved tools/list operation, then calls microsoft_docs_search from the workflow. using System.ClientModel; -using System.ClientModel.Primitives; using System.Collections.Concurrent; using System.Net.Http.Headers; using Azure.AI.Projects; @@ -19,8 +18,6 @@ using Shared.Workflows; #pragma warning disable OPENAI001 // Experimental API -#pragma warning disable AAIP001 // AgentToolboxes is experimental - namespace Demo.Workflows.Declarative.InvokeFoundryToolboxMcp; /// @@ -148,9 +145,7 @@ Be concise. private static async Task CreateSampleToolboxAsync(string name, string serverLabel, Uri foundryEndpoint, TokenCredential credential) { - AgentAdministrationClientOptions options = new(); - options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall); - AgentAdministrationClient adminClient = new(foundryEndpoint, credential, options); + AgentAdministrationClient adminClient = new(foundryEndpoint, credential); AgentToolboxes toolboxClient = adminClient.GetAgentToolboxes(); try @@ -199,21 +194,4 @@ protected override async Task SendAsync( return await base.SendAsync(request, cancellationToken); } } - - private sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy - { - private const string FeatureHeader = "Foundry-Features"; - - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - message.Request.Headers.Add(FeatureHeader, feature); - ProcessNext(message, pipeline, currentIndex); - } - - public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - message.Request.Headers.Add(FeatureHeader, feature); - return ProcessNextAsync(message, pipeline, currentIndex); - } - } } diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs index 30ac2904a5f..9af0e7b81f0 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/Program.cs @@ -20,7 +20,6 @@ #pragma warning disable AAIP001 // ProjectAgentSkills is experimental using System.ClientModel; -using System.ClientModel.Primitives; using Azure.AI.Projects; using Azure.AI.Projects.Agents; using Azure.Identity; @@ -65,10 +64,7 @@ var credential = new DefaultAzureCredential(); AIProjectClient projectClient = new(new Uri(endpoint), credential); -var adminOptions = new AgentAdministrationClientOptions(); -adminOptions.AddPolicy(new FoundryFeaturesPolicy("Skills=V1Preview"), PipelinePosition.PerCall); -var adminClient = new AgentAdministrationClient(new Uri(endpoint), credential, adminOptions); -ProjectAgentSkills skillsClient = adminClient.GetAgentSkills(); +ProjectAgentSkills skillsClient = projectClient.AgentAdministrationClient.GetAgentSkills(); // ── Provision skills (sample convenience only — NOT a production pattern) ───── // In production, skills are provisioned externally (e.g., via CI/CD or a management script). @@ -153,7 +149,6 @@ static async Task DownloadSkillsAsync(ProjectAgentSkills skillsClient, string[] $"Downloaded skill '{name}' did not contain a SKILL.md at the root."); } } - } // Ensures each requested skill is provisioned in Foundry. For each skill name, checks whether @@ -183,24 +178,3 @@ static async Task EnsureSkillsProvisionedAsync(ProjectAgentSkills skillsClient, } } } - -// Skills is a preview data-plane surface and requires an explicit feature opt-in on every call. -internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy -{ - private const string FeatureHeader = "Foundry-Features"; - - public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) - { - message.Request.Headers.Add(FeatureHeader, feature); - ProcessNext(message, pipeline, currentIndex); - } - - public override ValueTask ProcessAsync( - PipelineMessage message, - IReadOnlyList pipeline, - int currentIndex) - { - message.Request.Headers.Add(FeatureHeader, feature); - return ProcessNextAsync(message, pipeline, currentIndex); - } -} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md index a658e4f5a8f..40536036de6 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-AgentSkills/README.md @@ -165,9 +165,9 @@ Do not substitute `Azure AI Developer`; Microsoft documents that role as insuffi hosted agents. Recreate the role assignment when the agent is deleted and created again, because the new agent receives a new identity. -The Skills API is a preview surface. `Program.cs` adds the required -`Foundry-Features: Skills=V1Preview` header and downloads skills into the writable temporary -directory. The source-deploy application directory (`/app`) is read-only. +The Skills API is a preview surface. `Azure.AI.Projects` applies its required feature opt-in +internally. `Program.cs` downloads skills into the writable temporary directory. The source-deploy +application directory (`/app`) is read-only. ### Step 4: clean up diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/.env.example new file mode 100644 index 00000000000..45ba6ac976f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_AGENT_NAME= diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/FoundryHostedSession.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/FoundryHostedSession.csproj new file mode 100644 index 00000000000..0c68e461ed4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/FoundryHostedSession.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + FoundryHostedSessionClient + foundry-hosted-session-client + $(NoWarn);MEAI001;NU1605;OPENAI001 + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/Program.cs new file mode 100644 index 00000000000..31291c53878 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/Program.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Creates a Foundry hosted session explicitly, pins an Agent Framework session to it, +// reuses the hosted sandbox across turns, and deletes the hosted session on exit. + +#pragma warning disable MEAI001 // Foundry hosted session helpers are experimental. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; + +Env.TraversePath().Load(); + +Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME") + ?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set."); +Uri agentEndpoint = new($"{projectEndpoint.ToString().TrimEnd('/')}/agents/{agentName}/endpoint/protocols/openai"); + +var credential = new AzureCliCredential(); +var projectClient = new AIProjectClient(projectEndpoint, credential); +FoundryAgent agent = projectClient.AsAIAgent(agentEndpoint); +AgentAdministrationClient adminClient = projectClient.AgentAdministrationClient; + +Console.WriteLine("Creating a Foundry hosted session..."); +ProjectsAgentRecord agentRecord = await adminClient.GetAgentAsync(agentName); +string agentVersion = agentRecord.GetLatestVersion().Version; +ProjectAgentSession hostedSession = await adminClient.CreateSessionAsync( + agentName, + new VersionRefIndicator(agentVersion)); +string hostedSessionId = hostedSession.AgentSessionId; + +try +{ + await WaitForSessionActiveAsync(adminClient, agentName, hostedSessionId); + + ChatClientAgentSession session = await agent.CreateFoundryHostedAgentSessionAsync( + hostedSessionId: hostedSessionId); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($""" + ============================================================ + Foundry Hosted Session Sample + Agent: {agentName} + Hosted session: {session.FoundryHostedAgentSessionId} + + Every turn uses the same Foundry sandbox. Conversation history + remains a separate concern and is carried by this AgentSession. + Type a message or 'quit' to exit. + ============================================================ + """); + Console.ResetColor(); + + while (true) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + if (input is null) + { + break; + } + + if (string.IsNullOrWhiteSpace(input)) + { + continue; + } + + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + Console.WriteLine(); + } +} +finally +{ + Console.WriteLine($"Deleting hosted session {hostedSessionId}..."); + await adminClient.DeleteSessionAsync(agentName, hostedSessionId); +} + +static async Task WaitForSessionActiveAsync( + AgentAdministrationClient adminClient, + string agentName, + string sessionId, + CancellationToken cancellationToken = default) +{ + TimeSpan timeout = TimeSpan.FromMinutes(3); + DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout; + ProjectAgentSession session = await adminClient.GetSessionAsync(agentName, sessionId, cancellationToken); + + while (session.Status != AgentSessionStatus.Active) + { + if (session.Status == AgentSessionStatus.Failed + || session.Status == AgentSessionStatus.Deleted + || session.Status == AgentSessionStatus.Expired) + { + throw new InvalidOperationException( + $"Hosted session '{sessionId}' entered terminal status '{session.Status}'."); + } + + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException( + $"Hosted session '{sessionId}' did not become active within {timeout.TotalSeconds:F0} seconds. Last status: {session.Status}."); + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + session = await adminClient.GetSessionAsync(agentName, sessionId, cancellationToken); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/README.md new file mode 100644 index 00000000000..86a9a417fe6 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/FoundryHostedSession/README.md @@ -0,0 +1,86 @@ +# Foundry Hosted Session + +This client creates a Foundry hosted session explicitly, attaches its identifier to an Agent Framework +`AgentSession`, and sends every turn to the same hosted sandbox. It deletes the hosted session when the +client exits. + +Use this pattern when the application must know the hosted session identifier before the first agent +invocation. Common reasons include uploading files before the first turn and assigning several logical +users to a caller managed session pool. + +## Sessions and conversations + +A hosted session and a conversation solve different problems. + +| Value | Purpose | +| --- | --- | +| Foundry `agent_session_id` | Selects the hosted sandbox, its compute, persisted `$HOME`, and session files. | +| Agent Framework `AgentSession` | Keeps the hosted session identifier and conversation continuation state together for the client. | +| Conversation or previous response identifier | Preserves model conversation history. Reusing only `agent_session_id` does not restore message history. | + +The sample creates the platform session with `AgentAdministrationClient.CreateSessionAsync`. It then +calls `CreateFoundryHostedAgentSessionAsync(hostedSessionId: ...)`. Agent Framework stores the identifier +on the returned `AgentSession` and sends `agent_session_id` on every subsequent Responses request. + +## Session lifetime + +The platform owns the hosted session lifecycle. When the idle timeout is reached, Foundry can remove +the active compute while preserving the session filesystem. Referencing the session again causes Foundry +to provision compute and restore the saved state. A hosted session identifies a logical sandbox. It does +not reserve one physical virtual machine for its complete lifetime. + +This sample deletes the session in a `finally` block. A production service can retain session identifiers +when it needs a longer lived pool, but it must eventually delete unused sessions or let the configured +expiration reclaim them. + +## Session pools + +Applications that serve many users usually should not create one hosted session for every registered user. +Instead, a middle tier can maintain a bounded pool: + +1. Create a small number of hosted sessions. +2. Assign each user to one session. +3. Keep that assignment stable while the user is active. +4. Send the selected `agent_session_id` on every call. +5. Grow the pool according to observed concurrent work rather than total user count. + +Sharing a hosted session does not automatically partition files or application data written by the +container. When users share a sandbox, container owned data must be stored under a per user partition. +The sibling [`UserIsolationAgent`](../UserIsolationAgent/) sample shows how to identify each user while +sharing one hosted session. + +## Prerequisites + +* [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +* A hosted agent deployed to Foundry +* Azure CLI authenticated with `az login` +* Permission to invoke the agent and create, read, and delete its hosted sessions + +This sample targets a deployed agent because the Foundry session administration API is not available +from a local `dotnet run` agent server. + +## Configuration + +Copy `.env.example` to `.env` and set: + +```env +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_AGENT_NAME= +``` + +## Run + +```powershell +cd dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-Samples\FoundryHostedSession +dotnet run +``` + +The startup output prints the explicit hosted session identifier. All REPL turns reuse that identifier. +Entering `quit` or encountering an exception causes the `finally` block to request session deletion. +An abrupt process termination can prevent cleanup, so production systems should also reclaim abandoned +sessions outside the request process. + +## Related documentation + +* [Manage hosted agent sessions](https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions) +* [Multiplex multiple users in one hosted agent session](https://learn.microsoft.com/azure/foundry/agents/how-to/multiplex-session-users) diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md index 78d518670bb..7de3f5e4639 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/README.md @@ -47,6 +47,8 @@ never hits the TLS check. | [`SimpleAgent/`](./SimpleAgent/) | Any hosted agent | Generic, agent-agnostic REPL. Point it at any `Hosted-*` server via `AZURE_AI_AGENT_NAME`. Used by `Hosted-Toolbox`, `Hosted-Toolbox-AuthPaths`, and `Hosted-McpTools`. | | [`Hosted-Toolbox-AuthPaths-Client/`](./Hosted-Toolbox-AuthPaths-Client/) | Hosted toolbox agents | Handles OAuth consent, function-tool approvals, and native MCP approvals. Use it with `Hosted-Toolbox-AuthPaths` or `Hosted-ToolboxMcpSkills`. | | [`SessionFilesClient/`](./SessionFilesClient/) | [`Hosted-Files`](../Hosted-Files/) | Same shape as `SimpleAgent`, framed around the bundled-files demo. | +| [`FoundryHostedSession/`](./FoundryHostedSession/) | Any deployed hosted agent | Creates a hosted session explicitly, pins an `AgentSession` to it, explains session pools, and deletes the session on exit. | +| [`UserIsolationAgent/`](./UserIsolationAgent/) | Any deployed hosted agent | Shares one hosted session across several application users while keeping an independent conversation and delegated identity for each user. | For a self-contained demonstration that covers both crash and shutdown recovery, see [`Using-E2E-Resilience`](../Using-E2E-Resilience/). diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/.env.example new file mode 100644 index 00000000000..45ba6ac976f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_AGENT_NAME= diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/Program.cs new file mode 100644 index 00000000000..b26b02f01dd --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/Program.cs @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Shares one Foundry hosted session across application users while preserving an +// independent Agent Framework session and delegated identity for each user. + +#pragma warning disable MEAI001 // Foundry hosted session and user identity helpers are experimental. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Extensions.AI; + +Env.TraversePath().Load(); + +Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME") + ?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set."); +Uri agentEndpoint = new($"{projectEndpoint.ToString().TrimEnd('/')}/agents/{agentName}/endpoint/protocols/openai"); + +var credential = new AzureCliCredential(); +var projectClient = new AIProjectClient(projectEndpoint, credential); +FoundryAgent agent = projectClient.AsAIAgent(agentEndpoint); +AgentAdministrationClient adminClient = projectClient.AgentAdministrationClient; + +Console.WriteLine("Creating one shared Foundry hosted session..."); +ProjectsAgentRecord agentRecord = await adminClient.GetAgentAsync(agentName); +string agentVersion = agentRecord.GetLatestVersion().Version; +ProjectAgentSession hostedSession = await adminClient.CreateSessionAsync( + agentName, + new VersionRefIndicator(agentVersion)); +string hostedSessionId = hostedSession.AgentSessionId; + +// Demonstration only: this console accepts arbitrary user IDs to make isolation visible. +// Production code must derive the user ID from authenticated request context, authorize the +// operation, and use a managed session pool instead of trusting caller-provided identity text. +var userSessions = new Dictionary(StringComparer.Ordinal); + +try +{ + await WaitForSessionActiveAsync(adminClient, agentName, hostedSessionId); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($""" + ============================================================ + User Isolation Agent Sample + Agent: {agentName} + Shared hosted session: {hostedSessionId} + + Enter a stable application user id for each message. + Each user gets an independent AgentSession and conversation, + while every user shares the same Foundry hosted sandbox. + Enter 'quit' as the user id to exit. + ============================================================ + """); + Console.ResetColor(); + + while (true) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("User id> "); + Console.ResetColor(); + + string? userId = Console.ReadLine(); + if (userId is null) + { + break; + } + + userId = userId.Trim(); + if (string.IsNullOrWhiteSpace(userId)) + { + continue; + } + + if (userId.Equals("quit", StringComparison.OrdinalIgnoreCase)) + { + break; + } + + Console.Write("Message> "); + string? input = Console.ReadLine(); + if (input is null) + { + break; + } + + if (string.IsNullOrWhiteSpace(input)) + { + continue; + } + + if (!userSessions.TryGetValue(userId, out ChatClientAgentSession? userSession)) + { + userSession = await agent.CreateFoundryHostedAgentSessionAsync( + hostedSessionId: hostedSessionId); + userSessions.Add(userId, userSession); + Console.WriteLine($"Created an independent conversation for '{userId}'."); + } + + var runOptions = new ChatClientAgentRunOptions( + new ChatOptions().WithFoundryHostedAgentUserIdentity(userId)); + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write($"Agent for {userId}> "); + Console.ResetColor(); + + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync( + input, + userSession, + runOptions)) + { + Console.Write(update); + } + + Console.WriteLine(); + Console.WriteLine(); + } +} +finally +{ + Console.WriteLine($"Deleting shared hosted session {hostedSessionId}..."); + await adminClient.DeleteSessionAsync(agentName, hostedSessionId); +} + +static async Task WaitForSessionActiveAsync( + AgentAdministrationClient adminClient, + string agentName, + string sessionId, + CancellationToken cancellationToken = default) +{ + TimeSpan timeout = TimeSpan.FromMinutes(3); + DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout; + ProjectAgentSession session = await adminClient.GetSessionAsync(agentName, sessionId, cancellationToken); + + while (session.Status != AgentSessionStatus.Active) + { + if (session.Status == AgentSessionStatus.Failed + || session.Status == AgentSessionStatus.Deleted + || session.Status == AgentSessionStatus.Expired) + { + throw new InvalidOperationException( + $"Hosted session '{sessionId}' entered terminal status '{session.Status}'."); + } + + if (DateTimeOffset.UtcNow >= deadline) + { + throw new TimeoutException( + $"Hosted session '{sessionId}' did not become active within {timeout.TotalSeconds:F0} seconds. Last status: {session.Status}."); + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + session = await adminClient.GetSessionAsync(agentName, sessionId, cancellationToken); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/README.md new file mode 100644 index 00000000000..8788c3ce7db --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/README.md @@ -0,0 +1,122 @@ +# User Isolation Agent + +This client demonstrates delegated user identity for a middle tier that serves several application +users through one Foundry hosted session. + +The sample creates one shared hosted session. Each user receives a separate Agent Framework +`AgentSession`, and every invocation sends that user's stable identifier through +`ChatOptions.WithFoundryHostedAgentUserIdentity`. Agent Framework places the value in the +`x-ms-user-identity` request header. + +## What Foundry isolates + +The middle tier authenticates Alice and Bob before calling Foundry. It then sends a stable application +identifier for the authenticated user: + +```text +Alice AgentSession + shared agent_session_id + x-ms-user-identity: alice +Bob AgentSession + shared agent_session_id + x-ms-user-identity: bob +``` + +Foundry resolves that delegated identity and isolates platform managed conversation history. Bob cannot +continue Alice's response chain, even though both users share the same hosted sandbox. + +The sample deliberately creates one `AgentSession` per user. Reusing one `AgentSession` for different +users would also reuse its conversation continuation identifier. Foundry rejects that cross user +continuation rather than exposing the first user's history. + +## What the application must isolate + +Foundry isolates the conversation history it manages. It does not automatically partition arbitrary +files, database rows, or cache entries written by the container. If the hosted agent stores its own +user data, partition that data by both the hosted session identifier and the resolved user identifier: + +```text +(agent_session_id, user_id) +``` + +Do not trust a user identifier copied directly from an untrusted client request. The middle tier must +authenticate the user, authorize the requested action, and choose the stable identifier that it sends +to Foundry. + +## Configure the calling principal + +The identity represented by `AzureCliCredential` in this sample is acting as the trusted middle tier. +In production this is normally a managed identity or service principal. That principal needs both: + +1. Permission to invoke the agent endpoint. `Foundry Agent Consumer` is the least privilege built in role. +2. Permission to send `x-ms-user-identity`. This permission is not included in a built in role and must + be granted through a custom role. + +Create `custom-impersonation-role.json`: + +```json +{ + "Name": "Foundry Agent User Identity Impersonation", + "IsCustom": true, + "Description": "Lets a trusted middle-tier service delegate an end-user identity to a hosted agent.", + "Actions": [], + "NotActions": [], + "DataActions": [ + "Microsoft.CognitiveServices/accounts/AIServices/agents/endpoints/UserIdentityImpersonation/action" + ], + "NotDataActions": [], + "AssignableScopes": [ + "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/" + ] +} +``` + +Create and assign the role: + +```powershell +az role definition create --role-definition custom-impersonation-role.json + +az role assignment create ` + --assignee ` + --role "Foundry Agent User Identity Impersonation" ` + --scope "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/" +``` + +The role can be assigned at the Foundry project or specific agent scope when your deployment model +supports that narrower scope. A caller that sends `x-ms-user-identity` without this data action receives +HTTP 403. + +The delegated identifier is not an access token and this flow is not OAuth on behalf of token exchange. +The middle tier authenticates the user itself, then Foundry accepts the identifier only because the +calling principal has the explicit impersonation data action. + +## Prerequisites + +* [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +* A hosted agent deployed with Responses protocol version 2.0.0 +* Azure CLI authenticated with `az login` +* Session administration permission for the sample's create and delete operations +* The invocation and impersonation permissions described above + +User isolation is a platform behavior. It is not enforced by a local `dotnet run` agent server, so this +sample targets a deployed Foundry agent. + +## Configuration + +Copy `.env.example` to `.env` and set: + +```env +FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_AGENT_NAME= +``` + +## Run + +```powershell +cd dotnet\samples\04-hosting\FoundryHostedAgents\responses\Using-Samples\UserIsolationAgent +dotnet run +``` + +Enter `alice`, send several messages, then enter `bob`. Returning to `alice` reuses Alice's own +`AgentSession`. Both users remain attached to the one hosted session printed at startup. + +## Related documentation + +* [Multiplex multiple users in one hosted agent session](https://learn.microsoft.com/azure/foundry/agents/how-to/multiplex-session-users) +* [Hosted agent permissions reference](https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agent-permissions#delegate-the-end-user-identity) diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/UserIsolationAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/UserIsolationAgent.csproj new file mode 100644 index 00000000000..aa7f03ca1c1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/UserIsolationAgent/UserIsolationAgent.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + UserIsolationAgentClient + user-isolation-agent-client + $(NoWarn);MEAI001;NU1605;OPENAI001 + + + + + + + + + + + + + From e1c849c4ad1a0add12378df95df71184b8745b3d Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 11 Sep 2026 12:27:25 +0000 Subject: [PATCH 018/108] .NET: deps/ai extensions (#8270) * chore: upgrades MSAI extensions packages chore: upgrade OpenAI package Signed-off-by: Vincent Biret * chore: upgrades azure blobs chore: upgrades AGUI chore: upgrades hyperlight Signed-off-by: Vincent Biret * chore: upgrades cosmos chore: upgrades copilot studio chore: upgrades microsoft agents Signed-off-by: Vincent Biret * chore: update Aspire.Azure.AI.Inference and Azure.AI.AgentServer.Core versions Signed-off-by: Vincent Biret * chore: update DotNetEnv package version to 3.2.0 across multiple projects Signed-off-by: Vincent Biret * chore: update Anthropic package version to 12.45.0 Signed-off-by: Vincent Biret * chore: update Azure.AI.Projects package version to 3.0.0-beta.2 across multiple projects Signed-off-by: Vincent Biret * chore: update AgentFrameworkVersion to 1.20.0-preview.260831.1 across multiple projects Signed-off-by: Vincent Biret * chore: update ModelContextProtocol package version to 2.2.0 across multiple projects Signed-off-by: Vincent Biret * chore: update ResponsibleAI.AgentHooks package version to 0.1.0-alpha.5 Signed-off-by: Vincent Biret * fix: updated sample mapping to the wrong property Signed-off-by: Vincent Biret * chore: update Microsoft.Extensions.AI.OpenAI to version 10.10.0 and OpenAI to version 2.13.0 in multiple projects Signed-off-by: Vincent Biret * chore: update OllamaSharp package version to 5.4.30 Signed-off-by: Vincent Biret * chore: remove central package transitive pinning and version overrides for Microsoft.Extensions packages Signed-off-by: Vincent Biret * chore: update AGUIJsonSerializerOptions usage in session persistence tests Signed-off-by: Vincent Biret * chore: update AGUIJsonSerializerOptions usage in FakeTestAgentHost and SessionPersistenceTests Signed-off-by: Vincent Biret * chore: update CommunityToolkit.Aspire.OllamaSharp package version to 13.5.0 Signed-off-by: Vincent Biret * chore: update Microsoft.Identity.Client.Extensions.Msal package version to 4.88.0 Signed-off-by: Vincent Biret * chore: Update Microsoft.Agents.AI.Mcp package version in non CPM project Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore: add ModelContextProtocol.Extensions.Tasks package reference Signed-off-by: Vincent Biret * tests: updates integration tests following dependency upgrade --------- Signed-off-by: Vincent Biret Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 62 +++++++++---------- .../Program.cs | 2 +- .../Agent_With_Dapr/Agent_With_Dapr.csproj | 9 --- ...emory_Step06_MemoryUsingAgentMemory.csproj | 2 +- .../Hosted-Invocations-EchoAgent.csproj | 2 +- .../HostedAgentSkills.csproj | 6 +- .../HostedAzureSearchRag.csproj | 6 +- .../HostedChatClientAgentDocker.csproj | 6 +- .../HostedChatClientAgent.csproj | 6 +- .../responses/Hosted-Files/HostedFiles.csproj | 6 +- .../HostedFoundryAgent.csproj | 6 +- .../HostedLocalCodeAct.csproj | 4 +- .../Hosted-LocalTools/HostedLocalTools.csproj | 6 +- .../Hosted-McpTools/HostedMcpTools.csproj | 8 +-- .../HostedMemoryAgent.csproj | 6 +- .../HostedObservability.csproj | 6 +- .../Hosted-Steering/HostedSteering.csproj | 2 +- .../Hosted-TextRag/HostedTextRag.csproj | 6 +- .../Hosted-Toolbox-AuthPaths.csproj | 6 +- .../Hosted-Toolbox/HostedToolbox.csproj | 6 +- .../HostedToolboxMcpSkills.csproj | 11 ++-- .../HostedWorkflowHandoff.csproj | 10 +-- .../HostedWorkflowResilientLongRunning.csproj | 2 +- .../HostedWorkflowResilient.csproj | 2 +- .../HostedWorkflowSimple.csproj | 6 +- ...reSearchToolAnnotationsHostedAgentTests.cs | 6 +- .../ProjectResponsesTestOptions.cs | 20 ++++++ .../ResilientWorkflowHostedAgentTests.cs | 8 +-- .../SessionFilesHostedAgentTests.cs | 3 +- .../SteerableLongRunningHostedAgentTests.cs | 10 ++- .../WebSearchAnnotationsHostedAgentTests.cs | 6 +- .../SessionPersistenceTests.cs | 13 +++- .../FakeTestAgentHost.cs | 12 +++- 33 files changed, 149 insertions(+), 123 deletions(-) create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/ProjectResponsesTestOptions.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 8dead31fe33..c4e154e75e7 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -11,19 +11,19 @@ - + - - + + - + - + @@ -31,14 +31,14 @@ - + - + @@ -55,11 +55,11 @@ - - - - - + + + + + @@ -80,14 +80,14 @@ - - - - - - + + + + + + - + @@ -97,24 +97,24 @@ - + - - + + - - + + - - + + @@ -122,16 +122,16 @@ - - + + - - + + - + diff --git a/dotnet/samples/02-agents/AgentProviders/anthropic/Agent_Anthropic_Step04_UsingSkills/Program.cs b/dotnet/samples/02-agents/AgentProviders/anthropic/Agent_Anthropic_Step04_UsingSkills/Program.cs index 9d356a8598b..a7d87d59bc6 100644 --- a/dotnet/samples/02-agents/AgentProviders/anthropic/Agent_Anthropic_Step04_UsingSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/anthropic/Agent_Anthropic_Step04_UsingSkills/Program.cs @@ -33,7 +33,7 @@ foreach (var skill in skills.Items) { - Console.WriteLine($" {skill.Source}: {skill.ID} (version: {skill.LatestVersion})"); + Console.WriteLine($" {skill.Source}: {skill.ID} (version: {skill.LatestVersionID})"); } } catch (Exception ex) diff --git a/dotnet/samples/02-agents/AgentProviders/dapr/Agent_With_Dapr/Agent_With_Dapr.csproj b/dotnet/samples/02-agents/AgentProviders/dapr/Agent_With_Dapr/Agent_With_Dapr.csproj index 1e1fa945e7d..27d1e4d607b 100644 --- a/dotnet/samples/02-agents/AgentProviders/dapr/Agent_With_Dapr/Agent_With_Dapr.csproj +++ b/dotnet/samples/02-agents/AgentProviders/dapr/Agent_With_Dapr/Agent_With_Dapr.csproj @@ -6,20 +6,11 @@ enable enable - false $(NoWarn);DAPR_CONVERSATION - - - diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj index 4cee54785a4..8373e63ef0e 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj @@ -42,7 +42,7 @@ - + - 1.20.0 + 1.21.0 1 - 260831 + 260911 $(VersionPrefix)-rc$(RCNumber) $(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1 $(VersionPrefix)-preview.$(DateSuffix).1 $(VersionPrefix) - 1.20.0 + 1.21.0 Debug;Release;Publish true From c1f33dfca3b48591f3d92c8c19d921ea4636c042 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 11 Sep 2026 13:31:56 +0000 Subject: [PATCH 021/108] Simplify CODEOWNERS rules (#8296) * Simplify CODEOWNERS rules Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6f2020a7-d92e-4a6b-8a5d-1b05cdbabda6 * Clarify CODEOWNERS rule scope Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6f2020a7-d92e-4a6b-8a5d-1b05cdbabda6 --------- Copilot-Session: 6f2020a7-d92e-4a6b-8a5d-1b05cdbabda6 --- .github/CODEOWNERS | 135 +++------------------------------------------ 1 file changed, 8 insertions(+), 127 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0803547f9ba..0820032bf39 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,133 +1,14 @@ # Code ownership assignments # https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # -# Policy: a PR needs one approval, and it must come from a code owner of the changed -# files ("Require review from Code Owners" + "Required approvals: 1" on `main`). -# A PR touching several CODEOWNERS patterns will request review from the applicable -# code owners, but an approval from any applicable code owner is sufficient to satisfy -# GitHub's required-code-owner review. -# -# Order matters: the LAST matching pattern wins, so a module rule fully replaces the -# catch-all rather than adding to it. @chetantoshniwal is included on every line as a -# repository-wide fallback owner. All owners on a line have equal approval authority. -# -# CONVENTION: owners are written in the order -# @chetantoshniwal [...] -# @chetantoshniwal is at the beginning for aesthetics. The owners share equal approval -# power and responsibility. -# -# RULE: every path must list at least two owners besides @chetantoshniwal. An author -# cannot approve their own PR, so a path with a single module owner leaves only Chetan -# to review whenever that owner is the author, which defeats the point of naming a -# module owner. -# -# Samples: owned by all core developers of that language, not by the module a sample -# demonstrates. Any core Python developer can approve any Python sample, and any core -# .NET developer can approve any .NET sample. No dedicated rule is needed -- samples -# fall through to the /python and /dotnet rules, which already list those developers. -# -# Tests: same as samples. Tests that live inside a package (python/packages//tests) -# are covered by that package's rule instead, since they sit under its path. - -# Default owners for everything not matched by a module rule below. -* @chetantoshniwal @westey-m - -# Repository-level paths: every core Agent Framework developer is a code owner, so any -# one of them can approve. Be explicit now and we can use the AgentFramework team in the future. -/docs/ @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh -/*.md @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh -/LICENSE @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh -/.gitattributes @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh -/.gitignore @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh -/.github @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh - -# Repository-level paths that require specific owners -/.devcontainer @chetantoshniwal @westey-m @rogerbarreto @SergeyMenshykh -/declarative-agents @chetantoshniwal @moonbox3 @eavanvalkenburg @peibekwe @baywet - -# Core Python developers: @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl -/python @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl +# Order matters: the last matching pattern wins. The default rule applies outside +# /python and /dotnet; each language-specific rule replaces it for that tree. -# Python packages -/python/packages/a2a/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 -/python/packages/ag-ui/ @chetantoshniwal @eavanvalkenburg @moonbox3 @giles17 -/python/packages/anthropic/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/azure-ai-search/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/azure-contentunderstanding/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 -/python/packages/azure-cosmos/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/azure-cosmos-memory/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/bedrock/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/chatkit/ @chetantoshniwal @eavanvalkenburg @moonbox3 @giles17 -/python/packages/claude/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/copilotstudio/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/core/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl @giles17 -/python/packages/core/agent_framework/_in_memory.py @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh -/python/packages/core/agent_framework/_vector_filters.py @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh -/python/packages/core/agent_framework/_vectors.py @chetantoshniwal @westey-m @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl @peibekwe @baywet @rogerbarreto @SergeyMenshykh -/python/packages/core/agent_framework/_workflows/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl -/python/packages/core/agent_framework/_harness/ @chetantoshniwal @westey-m @eavanvalkenburg @moonbox3 -/python/packages/declarative/ @chetantoshniwal @eavanvalkenburg @moonbox3 @peibekwe @baywet -/python/packages/devui/ @chetantoshniwal @eavanvalkenburg @moonbox3 -/python/packages/foundry/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 @giles17 -/python/packages/foundry_hosting/ @chetantoshniwal @TaoChenOSU @jpalvarezl @eavanvalkenburg @moonbox3 -/python/packages/foundry_local/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/gemini/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 -/python/packages/github_copilot/ @chetantoshniwal @giles17 @eavanvalkenburg @moonbox3 -/python/packages/hosting/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 -/python/packages/hosting-a2a/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 -/python/packages/hosting-mcp/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 -/python/packages/hosting-responses/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 -/python/packages/hosting-telegram/ @chetantoshniwal @eavanvalkenburg @TaoChenOSU @jpalvarezl @moonbox3 -/python/packages/hyperlight/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/lab/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 @TaoChenOSU @jpalvarezl -/python/packages/mem0/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/mistral/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/monty/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/ollama/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/openai/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl @giles17 -/python/packages/orchestrations/ @chetantoshniwal @eavanvalkenburg @moonbox3 @TaoChenOSU @jpalvarezl -/python/packages/purview/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/redis/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 -/python/packages/tools/ @chetantoshniwal @eavanvalkenburg @giles17 @moonbox3 +# Default owners for paths outside Python and .NET +* @eavanvalkenburg @SergeyMenshykh @rogerbarreto @westey-m @moonbox3 @jpalvarezl @TaoChenOSU @giles17 @baywet @peibekwe -# Core .NET developers: @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m -/dotnet @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m +# Python owners +/python/ @eavanvalkenburg @SergeyMenshykh @rogerbarreto @westey-m @moonbox3 @jpalvarezl @TaoChenOSU @giles17 -# .NET projects -/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/LegacySupport/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Shared/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.A2A/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Abstractions/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.AGUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Anthropic/ @chetantoshniwal @rogerbarreto @westey-m -/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/ @chetantoshniwal @rogerbarreto @westey-m -/dotnet/src/Microsoft.Agents.AI.CopilotStudio/ @chetantoshniwal @rogerbarreto @westey-m -/dotnet/src/Microsoft.Agents.AI.CosmosNoSql/ @chetantoshniwal @rogerbarreto @westey-m -/dotnet/src/Microsoft.Agents.AI.Declarative/ @chetantoshniwal @peibekwe @baywet @westey-m -/dotnet/src/Microsoft.Agents.AI.DevUI/ @chetantoshniwal @peibekwe @baywet @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Foundry/ @chetantoshniwal @rogerbarreto @westey-m -/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ @chetantoshniwal @rogerbarreto @westey-m -/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/ @chetantoshniwal @rogerbarreto @westey-m -/dotnet/src/Microsoft.Agents.AI.Harness/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.Hosting/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/ @chetantoshniwal @rogerbarreto @SergeyMenshykh @westey-m -/dotnet/src/Microsoft.Agents.AI.Hyperlight/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.LocalCodeAct/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.Mcp/ @chetantoshniwal @westey-m @peibekwe @baywet -/dotnet/src/Microsoft.Agents.AI.Mem0/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.OpenAI/ @chetantoshniwal @westey-m @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Purview/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.Tools.Shell/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.Valkey/ @chetantoshniwal @westey-m @SergeyMenshykh -/dotnet/src/Microsoft.Agents.AI.Workflows/ @chetantoshniwal @peibekwe @baywet @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ @chetantoshniwal @peibekwe @baywet @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/ @chetantoshniwal @peibekwe @baywet @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/ @chetantoshniwal @peibekwe @baywet @rogerbarreto -/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ @chetantoshniwal @peibekwe @baywet @rogerbarreto +# .NET owners +/dotnet/ @eavanvalkenburg @SergeyMenshykh @rogerbarreto @westey-m @moonbox3 @baywet @peibekwe From 595aa8a5f26289e98d4879640a302237daf58eca Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 11 Sep 2026 14:22:44 +0000 Subject: [PATCH 022/108] Python: Isolate Anthropic request parsing state (#8237) * Python: Isolate Anthropic request parsing state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Cover concurrent Anthropic responses Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_anthropic/_chat_client.py | 81 ++-- .../anthropic/tests/test_anthropic_client.py | 384 +++++++++++++++--- 2 files changed, 383 insertions(+), 82 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index cefc50b1223..aeb4822411e 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -5,6 +5,7 @@ import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence +from dataclasses import dataclass, field from typing import Any, ClassVar, Final, Generic, Literal, TypedDict, cast from agent_framework import ( @@ -255,6 +256,15 @@ def _map_finish_reason(stop_reason: str | None) -> FinishReason | None: return FinishReason(FINISH_REASON_MAP.get(stop_reason, stop_reason)) +@dataclass +class _AnthropicRequestState: + """Mutable parsing state scoped to one Anthropic request.""" + + active_call_id: str | None = None + active_call_content_type: str | None = None + tool_name_aliases: dict[str, str] = field(default_factory=dict[str, str]) + + class AnthropicSettings(TypedDict, total=False): """Anthropic Project settings. @@ -408,10 +418,6 @@ class MyOptions(AnthropicChatOptions, total=False): self.anthropic_client = anthropic_client self.additional_beta_flags = additional_beta_flags or [] self.model = model_setting - # streaming requires tracking the last function call ID, name, and content type - self._last_call_id_name: tuple[str, str] | None = None - self._last_call_content_type: str | None = None - self._tool_name_aliases: dict[str, str] = {} # region Static factory methods for hosted tools @@ -583,7 +589,8 @@ def _inner_get_response( **kwargs: Any, ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: # prepare - run_options = self._prepare_options(messages, options, **kwargs) + request_state = _AnthropicRequestState() + run_options = self._prepare_options(messages, options, request_state, **kwargs) if stream: # Streaming mode @@ -595,7 +602,7 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: mark_feature_used(FeatureIndex.ANTHROPIC) try: async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): - parsed_chunk = self._process_stream_event(chunk, emitted_usage) + parsed_chunk = self._process_stream_event(chunk, emitted_usage, request_state=request_state) if parsed_chunk: yield parsed_chunk except AgentFrameworkException: @@ -614,7 +621,7 @@ async def _get_response() -> ChatResponse: raise except Exception as ex: raise _wrap_anthropic_error(ex) from ex - return self._process_message(message, options) + return self._process_message(message, options, request_state=request_state) return _get_response() @@ -624,6 +631,8 @@ def _prepare_options( self, messages: Sequence[Message], options: Mapping[str, Any], + request_state: _AnthropicRequestState | None = None, + /, **kwargs: Any, ) -> dict[str, Any]: """Create run options for the Anthropic client based on messages and options. @@ -631,11 +640,13 @@ def _prepare_options( Args: messages: The list of chat messages. options: The options dict. + request_state: State used to parse the response for this request. kwargs: Additional keyword arguments. Returns: A dictionary of run options for the Anthropic client. """ + request_state = request_state or _AnthropicRequestState() # Start with a copy of options, excluding keys we handle separately run_options: dict[str, Any] = { k: v @@ -697,7 +708,7 @@ def _prepare_options( run_options["metadata"] = metadata # tools, mcp servers and tool choice - if tools_config := self._prepare_tools_for_anthropic(options): + if tools_config := self._prepare_tools_for_anthropic(options, request_state=request_state): run_options.update(tools_config) # response_format - emit Anthropic's GA ``output_config.format`` shape. @@ -1014,7 +1025,11 @@ def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: "content": a_content, } - def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, Any] | None: + def _prepare_tools_for_anthropic( + self, + options: Mapping[str, Any], + request_state: _AnthropicRequestState | None = None, + ) -> dict[str, Any] | None: """Prepare tools and tool choice configuration for the Anthropic API request. Converts FunctionTool to Anthropic format. MCP tools are routed to separate @@ -1022,12 +1037,14 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, Args: options: The options dict containing tools and tool choice settings. + request_state: State used to parse the response for this request. Returns: A dictionary with tools, mcp_servers, and tool_choice configuration, or None if empty. """ from agent_framework._types import validate_tool_mode + request_state = request_state or _AnthropicRequestState() result: dict[str, Any] = {} tools = options.get("tools") @@ -1076,9 +1093,9 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, result["tools"] = tool_list if mcp_server_list: result["mcp_servers"] = mcp_server_list - self._tool_name_aliases = tool_name_aliases + request_state.tool_name_aliases = tool_name_aliases else: - self._tool_name_aliases = {} + request_state.tool_name_aliases = {} # Process tool choice if options.get("tool_choice") is None: @@ -1101,7 +1118,7 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, api_tool_name = next( ( api_name - for api_name, local_name in self._tool_name_aliases.items() + for api_name, local_name in request_state.tool_name_aliases.items() if local_name == required_name ), required_name, @@ -1124,12 +1141,18 @@ def _prepare_tools_for_anthropic(self, options: Mapping[str, Any]) -> dict[str, # region Response Processing Methods - def _process_message(self, message: BetaMessage, options: Mapping[str, Any]) -> ChatResponse: + def _process_message( + self, + message: BetaMessage, + options: Mapping[str, Any], + request_state: _AnthropicRequestState | None = None, + ) -> ChatResponse: """Process the response from the Anthropic client. Args: message: The message returned by the Anthropic client. options: The options dict used for the request. + request_state: State used to parse this response. Returns: A ChatResponse object containing the processed response. @@ -1139,7 +1162,7 @@ def _process_message(self, message: BetaMessage, options: Mapping[str, Any]) -> messages=[ Message( role="assistant", - contents=self._parse_contents_from_anthropic(message.content), + contents=self._parse_contents_from_anthropic(message.content, request_state=request_state), raw_representation=message, ) ], @@ -1151,7 +1174,10 @@ def _process_message(self, message: BetaMessage, options: Mapping[str, Any]) -> ) def _process_stream_event( - self, event: BetaRawMessageStreamEvent, emitted_usage: dict[str, int] | None = None + self, + event: BetaRawMessageStreamEvent, + emitted_usage: dict[str, int] | None = None, + request_state: _AnthropicRequestState | None = None, ) -> ChatResponseUpdate | None: """Process a streaming event from the Anthropic client. @@ -1161,6 +1187,7 @@ def _process_stream_event( emitted, used to convert Anthropic's cumulative usage snapshots into increments (see ``_incremental_usage``). Pass ``None`` for a one-off event to keep the snapshot unchanged. + request_state: State used to parse this stream. Returns: A ChatResponseUpdate object containing the processed update. @@ -1177,7 +1204,7 @@ def _process_stream_event( role="assistant", response_id=event.message.id, contents=[ - *self._parse_contents_from_anthropic(event.message.content), + *self._parse_contents_from_anthropic(event.message.content, request_state=request_state), *usage_details, ], model=event.message.model, @@ -1201,13 +1228,13 @@ def _process_stream_event( case "message_stop": logger.debug("Received message_stop event; no content to process.") case "content_block_start": - contents = self._parse_contents_from_anthropic([event.content_block]) + contents = self._parse_contents_from_anthropic([event.content_block], request_state=request_state) return ChatResponseUpdate( contents=contents, raw_representation=event, ) case "content_block_delta": - contents = self._parse_contents_from_anthropic([event.delta]) + contents = self._parse_contents_from_anthropic([event.delta], request_state=request_state) return ChatResponseUpdate( contents=contents, raw_representation=event, @@ -1270,8 +1297,10 @@ def _incremental_usage(cumulative: UsageDetails, emitted: dict[str, int] | None) def _parse_contents_from_anthropic( self, content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock], + request_state: _AnthropicRequestState | None = None, ) -> list[Content]: """Parse contents from the Anthropic message.""" + request_state = request_state or _AnthropicRequestState() contents: list[Content] = [] for content_block in content: match content_block.type: @@ -1284,8 +1313,8 @@ def _parse_contents_from_anthropic( ) ) case "tool_use" | "mcp_tool_use" | "server_tool_use": - self._last_call_id_name = (content_block.id, content_block.name) - self._last_call_content_type = content_block.type + request_state.active_call_id = content_block.id + request_state.active_call_content_type = content_block.type if content_block.type == "mcp_tool_use": contents.append( Content.from_mcp_server_tool_call( @@ -1310,7 +1339,7 @@ def _parse_contents_from_anthropic( ) ) else: - resolved_tool_name = self._tool_name_aliases.get(content_block.name, content_block.name) + resolved_tool_name = request_state.tool_name_aliases.get(content_block.name, content_block.name) contents.append( Content.from_function_call( call_id=content_block.id, @@ -1321,11 +1350,10 @@ def _parse_contents_from_anthropic( ) ) case "mcp_tool_result": - call_id, _ = self._last_call_id_name or (None, None) parsed_output: list[Content] | None = None if content_block.content: if isinstance(content_block.content, list): - parsed_output = self._parse_contents_from_anthropic(content_block.content) + parsed_output = self._parse_contents_from_anthropic(content_block.content, request_state) elif isinstance(content_block.content, (str, bytes)): parsed_output = [ Content.from_text( @@ -1334,7 +1362,7 @@ def _parse_contents_from_anthropic( ) ] else: - parsed_output = self._parse_contents_from_anthropic([content_block.content]) + parsed_output = self._parse_contents_from_anthropic([content_block.content], request_state) contents.append( Content.from_mcp_server_tool_result( call_id=content_block.tool_use_id, @@ -1343,7 +1371,6 @@ def _parse_contents_from_anthropic( ) ) case "web_search_tool_result" | "web_fetch_tool_result": - call_id, _ = self._last_call_id_name or (None, None) contents.append( Content.from_function_result( call_id=content_block.tool_use_id, @@ -1548,10 +1575,10 @@ def _parse_contents_from_anthropic( ) case "input_json_delta": # Skip argument deltas for MCP and server tools — execution is handled server-side. - if self._last_call_content_type in ("mcp_tool_use", "server_tool_use"): + if request_state.active_call_content_type in ("mcp_tool_use", "server_tool_use"): pass else: - call_id = self._last_call_id_name[0] if self._last_call_id_name else "" + call_id = request_state.active_call_id or "" contents.append( Content.from_function_call( call_id=call_id, diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 133656e197f..c671076505f 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -1,4 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import os import re from pathlib import Path @@ -16,8 +17,10 @@ ChatResponseUpdate, Content, FunctionInvocationLayer, + FunctionTool, InlineSkill, Message, + ResponseStream, SkillFrontmatter, SkillsProvider, SupportsChatGetResponse, @@ -41,7 +44,7 @@ from pydantic import BaseModel, Field from agent_framework_anthropic import AnthropicChatOptions, AnthropicClient, RawAnthropicClient -from agent_framework_anthropic._chat_client import AnthropicSettings +from agent_framework_anthropic._chat_client import AnthropicSettings, _AnthropicRequestState from agent_framework_anthropic._feature_usage import FeatureIndex # Test constants @@ -75,8 +78,6 @@ def create_test_anthropic_client( # Set attributes directly client.anthropic_client = mock_anthropic_client client.model = model or anthropic_settings["chat_model"] - client._last_call_id_name = None - client._tool_name_aliases = {} client.additional_properties = {} cast(Any, client).middleware = None client.additional_beta_flags = [] @@ -598,32 +599,26 @@ def test_streaming_replay_preserves_empty_signed_thinking_block( client = create_test_anthropic_client(mock_anthropic_client) events: list[BetaRawMessageStreamEvent] = [ - BetaRawContentBlockStartEvent.model_validate( - { - "type": "content_block_start", - "index": 0, - "content_block": {"type": "thinking", "thinking": "", "signature": ""}, - } - ), - BetaRawContentBlockDeltaEvent.model_validate( - { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "signature_delta", "signature": "synthetic-signature"}, - } - ), - BetaRawContentBlockStartEvent.model_validate( - { - "type": "content_block_start", - "index": 1, - "content_block": { - "type": "tool_use", - "id": "toolu_test", - "name": "lookup", - "input": {}, - }, - } - ), + BetaRawContentBlockStartEvent.model_validate({ + "type": "content_block_start", + "index": 0, + "content_block": {"type": "thinking", "thinking": "", "signature": ""}, + }), + BetaRawContentBlockDeltaEvent.model_validate({ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": "synthetic-signature"}, + }), + BetaRawContentBlockStartEvent.model_validate({ + "type": "content_block_start", + "index": 1, + "content_block": { + "type": "tool_use", + "id": "toolu_test", + "name": "lookup", + "input": {}, + }, + }), ] updates = [client._process_stream_event(event) for event in events] @@ -1768,15 +1763,16 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name( ag-ui from emitting duplicate ToolCallStartEvents. """ client = create_test_anthropic_client(mock_anthropic_client) + request_state = _AnthropicRequestState() - # First, simulate a tool_use event that sets _last_call_id_name + # First, simulate a tool_use event that establishes the active call. tool_use_content = MagicMock() tool_use_content.type = "tool_use" tool_use_content.id = "call_123" tool_use_content.name = "get_weather" tool_use_content.input = {} - result = client._parse_contents_from_anthropic([tool_use_content]) + result = client._parse_contents_from_anthropic([tool_use_content], request_state) assert len(result) == 1 assert result[0].type == "function_call" assert result[0].call_id == "call_123" @@ -1787,7 +1783,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name( delta_content_1.type = "input_json_delta" delta_content_1.partial_json = '{"location":' - result = client._parse_contents_from_anthropic([delta_content_1]) + result = client._parse_contents_from_anthropic([delta_content_1], request_state) assert len(result) == 1 assert result[0].type == "function_call" assert result[0].call_id == "call_123" @@ -1799,7 +1795,7 @@ def test_parse_contents_from_anthropic_input_json_delta_no_duplicate_name( delta_content_2.type = "input_json_delta" delta_content_2.partial_json = '"San Francisco"}' - result = client._parse_contents_from_anthropic([delta_content_2]) + result = client._parse_contents_from_anthropic([delta_content_2], request_state) assert len(result) == 1 assert result[0].type == "function_call" assert result[0].call_id == "call_123" @@ -1817,27 +1813,28 @@ def test_parse_contents_server_tool_use_input_json_delta_ignored( entries that would cause Anthropic API 400 errors on subsequent turns. """ client = create_test_anthropic_client(mock_anthropic_client) + request_state = _AnthropicRequestState() - # Simulate a server_tool_use event that sets _last_call_content_type + # Simulate a server_tool_use event that establishes the hosted call type. server_tool_content = MagicMock() server_tool_content.type = "server_tool_use" server_tool_content.id = "srvtool_abc" server_tool_content.name = "web_search" server_tool_content.input = {} - result = client._parse_contents_from_anthropic([server_tool_content]) + result = client._parse_contents_from_anthropic([server_tool_content], request_state) # server_tool_use falls through to informational-only function_call (not mcp_tool_use / code_execution) assert len(result) == 1 assert result[0].type == "function_call" assert result[0].informational_only is True - assert client._last_call_content_type == "server_tool_use" # type: ignore[attr-defined] + assert request_state.active_call_content_type == "server_tool_use" # input_json_delta events after server_tool_use must be silently ignored delta_content = MagicMock() delta_content.type = "input_json_delta" delta_content.partial_json = '{"query": "latest news"}' - result = client._parse_contents_from_anthropic([delta_content]) + result = client._parse_contents_from_anthropic([delta_content], request_state) assert result == [], "input_json_delta after server_tool_use should produce no content, but got: %r" % result # A second delta must also be ignored @@ -1845,7 +1842,7 @@ def test_parse_contents_server_tool_use_input_json_delta_ignored( delta_content_2.type = "input_json_delta" delta_content_2.partial_json = '{"extra": true}' - result = client._parse_contents_from_anthropic([delta_content_2]) + result = client._parse_contents_from_anthropic([delta_content_2], request_state) assert result == [], ( "subsequent input_json_delta after server_tool_use should also be ignored, but got: %r" % result ) @@ -2049,6 +2046,300 @@ async def _raise_after_first_event() -> Any: pass +def _message_start_event(response_id: str) -> MagicMock: + event = MagicMock() + event.type = "message_start" + event.message.id = response_id + event.message.role = "assistant" + event.message.model = "claude-test" + event.message.content = [] + event.message.stop_reason = None + event.message.usage = None + return event + + +def _tool_start_event(*, block_type: str, call_id: str, name: str) -> MagicMock: + event = MagicMock() + event.type = "content_block_start" + event.content_block.type = block_type + event.content_block.id = call_id + event.content_block.name = name + event.content_block.input = {} + return event + + +def _argument_delta_event(partial_json: str) -> MagicMock: + event = MagicMock() + event.type = "content_block_delta" + event.delta.type = "input_json_delta" + event.delta.partial_json = partial_json + return event + + +def _message_delta_event(stop_reason: str) -> MagicMock: + event = MagicMock() + event.type = "message_delta" + event.delta.stop_reason = stop_reason + event.usage = None + return event + + +async def test_concurrent_streams_isolate_tool_state_when_peer_fails( + mock_anthropic_client: MagicMock, +) -> None: + """A failing hosted-tool stream must not affect a concurrent local-tool stream.""" + client = create_test_anthropic_client(mock_anthropic_client) + + def run_primary_shell(command: str) -> str: + return command + + def run_peer_shell(command: str) -> str: + return command + + primary_tool = client.get_shell_tool(func=run_primary_shell, approval_mode="never_require") + peer_tool = client.get_shell_tool(func=run_peer_shell, approval_mode="never_require") + hosted_tool = client.get_mcp_tool(name="hosted-search", url="https://example.com/mcp") + primary_started = asyncio.Event() + hosted_started = asyncio.Event() + hosted_failed = asyncio.Event() + + async def primary_events() -> Any: + yield _message_start_event("primary-response") + yield _tool_start_event(block_type="tool_use", call_id="shared-call", name="bash") + primary_started.set() + await asyncio.wait_for(hosted_started.wait(), timeout=1) + yield _argument_delta_event('{"command":') + await asyncio.wait_for(hosted_failed.wait(), timeout=1) + yield _argument_delta_event('"pwd"}') + yield _message_delta_event("tool_use") + + async def hosted_events() -> Any: + await asyncio.wait_for(primary_started.wait(), timeout=1) + yield _message_start_event("hosted-response") + yield _tool_start_event(block_type="server_tool_use", call_id="shared-call", name="web_search") + hosted_started.set() + yield _argument_delta_event('{"query":"framework"}') + hosted_failed.set() + raise RuntimeError("peer stream failed") + + async def create(**kwargs: Any) -> Any: + if kwargs.get("mcp_servers"): + return hosted_events() + return primary_events() + + mock_anthropic_client.beta.messages.create.side_effect = create + primary_stream = client._inner_get_response( # type: ignore[attr-defined] + messages=[Message(role="user", contents=["primary"])], + options={"tools": [primary_tool], "max_tokens": 64}, + stream=True, + ) + hosted_stream = client._inner_get_response( # type: ignore[attr-defined] + messages=[Message(role="user", contents=["hosted"])], + options={"tools": [peer_tool, hosted_tool], "max_tokens": 64}, + stream=True, + ) + assert isinstance(primary_stream, ResponseStream) + assert isinstance(hosted_stream, ResponseStream) + + async def consume_primary() -> tuple[list[ChatResponseUpdate], ChatResponse]: + updates = [update async for update in primary_stream] + return updates, await primary_stream.get_final_response() + + primary_task = asyncio.create_task(consume_primary()) + hosted_contents: list[Content] = [] + with pytest.raises(ChatClientException, match="Anthropic"): + async for update in hosted_stream: + hosted_contents.extend(update.contents) + + primary_updates, primary_response = await asyncio.wait_for(primary_task, timeout=1) + primary_fragments = [ + content for update in primary_updates for content in update.contents if content.type == "function_call" + ] + assert [(content.name, content.call_id, content.arguments) for content in primary_fragments] == [ + (primary_tool.name, "shared-call", {}), + ("", "shared-call", '{"command":'), + ("", "shared-call", '"pwd"}'), + ] + primary_calls = [ + content + for message in primary_response.messages + for content in message.contents + if content.type == "function_call" + ] + assert len(primary_calls) == 1 + assert primary_calls[0].name == primary_tool.name + assert primary_calls[0].call_id == "shared-call" + assert primary_calls[0].parse_arguments() == {"command": "pwd"} + + hosted_calls = [content for content in hosted_contents if content.type == "function_call"] + assert len(hosted_calls) == 1 + assert hosted_calls[0].name == "web_search" + assert hosted_calls[0].call_id == "shared-call" + assert hosted_calls[0].arguments == {} + assert hosted_calls[0].informational_only is True + + +async def test_concurrent_streams_keep_approval_requests_request_local( + mock_anthropic_client: MagicMock, +) -> None: + """Concurrent approval-required shell calls must retain their request's tool identity.""" + client = create_test_anthropic_client(mock_anthropic_client) + + def first_shell(command: str) -> str: + return command + + def second_shell(command: str) -> str: + return command + + first_tool = client.get_shell_tool(func=first_shell, approval_mode="always_require") + second_tool = client.get_shell_tool(func=second_shell, approval_mode="always_require") + requests_ready = asyncio.Event() + request_count = 0 + + async def approval_events(response_id: str, command: str) -> Any: + yield _message_start_event(response_id) + yield _tool_start_event(block_type="tool_use", call_id="shared-approval-call", name="bash") + yield _argument_delta_event('{"command":') + yield _argument_delta_event(f'"{command}"}}') + yield _message_delta_event("tool_use") + + async def create(**kwargs: Any) -> Any: + nonlocal request_count + prompt = kwargs["messages"][0]["content"][0]["text"] + request_count += 1 + if request_count == 2: + requests_ready.set() + await asyncio.wait_for(requests_ready.wait(), timeout=1) + return approval_events(f"{prompt}-response", prompt) + + mock_anthropic_client.beta.messages.create.side_effect = create + first_stream = client.get_response( + messages=[Message(role="user", contents=["first"])], + options={"tools": [first_tool], "max_tokens": 64}, + stream=True, + ) + second_stream = client.get_response( + messages=[Message(role="user", contents=["second"])], + options={"tools": [second_tool], "max_tokens": 64}, + stream=True, + ) + + async def consume( + stream: ResponseStream[ChatResponseUpdate, ChatResponse], + ) -> tuple[list[ChatResponseUpdate], ChatResponse]: + updates = [update async for update in stream] + return updates, await stream.get_final_response() + + (first_updates, first_response), (second_updates, second_response) = await asyncio.gather( + consume(first_stream), consume(second_stream) + ) + + def assert_request_owned( + updates: list[ChatResponseUpdate], + response: ChatResponse, + expected_tool: FunctionTool, + expected_command: str, + ) -> tuple[Content, Content]: + update_contents = [content for update in updates for content in update.contents] + named_update_calls = [ + content for content in update_contents if content.type == "function_call" and content.name + ] + update_requests = [content for content in update_contents if content.type == "function_approval_request"] + assert named_update_calls + assert {content.name for content in named_update_calls} == {expected_tool.name} + assert len(update_requests) == 1 + + response_contents = [content for message in response.messages for content in message.contents] + response_calls = [content for content in response_contents if content.type == "function_call"] + response_requests = [content for content in response_contents if content.type == "function_approval_request"] + assert len(response_calls) == 1 + assert len(response_requests) == 1 + function_call = response_calls[0] + approval_request = response_requests[0] + assert function_call.name == expected_tool.name + assert function_call.call_id == "shared-approval-call" + assert function_call.parse_arguments() == {"command": expected_command} + assert approval_request.function_call is not None + assert approval_request.function_call.name == expected_tool.name + assert approval_request.function_call.call_id == "shared-approval-call" + assert approval_request.function_call.parse_arguments() == {"command": expected_command} + assert approval_request.id == function_call.id + return function_call, approval_request + + first_call, _ = assert_request_owned(first_updates, first_response, first_tool, "first") + second_call, _ = assert_request_owned(second_updates, second_response, second_tool, "second") + assert first_call.id != second_call.id + + +async def test_concurrent_non_streaming_responses_keep_tool_aliases_request_local( + mock_anthropic_client: MagicMock, +) -> None: + """Concurrent non-streaming responses must retain their request's shell alias.""" + client = create_test_anthropic_client(mock_anthropic_client) + + def first_shell(command: str) -> str: + return command + + def second_shell(command: str) -> str: + return command + + first_tool = client.get_shell_tool(func=first_shell, approval_mode="never_require") + second_tool = client.get_shell_tool(func=second_shell, approval_mode="never_require") + requests_ready = asyncio.Event() + request_count = 0 + + def tool_response(response_id: str, command: str) -> MagicMock: + message = MagicMock(spec=BetaMessage) + message.id = response_id + message.model = "claude-test" + message.content = [ + BetaToolUseBlock( + type="tool_use", + id="shared-non-stream-call", + name="bash", + input={"command": command}, + ) + ] + message.usage = None + message.stop_reason = "tool_use" + return message + + async def create(**kwargs: Any) -> Any: + nonlocal request_count + prompt = kwargs["messages"][0]["content"][0]["text"] + request_count += 1 + if request_count == 2: + requests_ready.set() + await asyncio.wait_for(requests_ready.wait(), timeout=1) + return tool_response(f"{prompt}-response", prompt) + + async def get_response(prompt: str, function_tool: FunctionTool) -> ChatResponse: + response = client._inner_get_response( # type: ignore[attr-defined] + messages=[Message(role="user", contents=[prompt])], + options={"tools": [function_tool], "max_tokens": 64}, + ) + assert not isinstance(response, ResponseStream) + return await response + + mock_anthropic_client.beta.messages.create.side_effect = create + first_response, second_response = await asyncio.gather( + get_response("first", first_tool), + get_response("second", second_tool), + ) + + def assert_response_owned(response: ChatResponse, expected_tool: FunctionTool, command: str) -> None: + calls = [ + content for message in response.messages for content in message.contents if content.type == "function_call" + ] + assert len(calls) == 1 + assert calls[0].name == expected_tool.name + assert calls[0].call_id == "shared-non-stream-call" + assert calls[0].parse_arguments() == {"command": command} + + assert_response_owned(first_response, first_tool, "first") + assert_response_owned(second_response, second_tool, "second") + + def test_process_stream_event_message_start_sets_assistant_role(mock_anthropic_client: MagicMock) -> None: """Test that message_start streaming event sets role='assistant'. @@ -2699,7 +2990,6 @@ def test_parse_contents_mcp_tool_result_list_content( ) -> None: """Test parsing MCP tool result with list content.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_123", "test_tool") # Create mock MCP tool result with list content mock_text_block = MagicMock() @@ -2722,7 +3012,6 @@ def test_parse_contents_mcp_tool_result_string_content( ) -> None: """Test parsing MCP tool result with string content.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_123", "test_tool") # Create mock MCP tool result with string content mock_block = MagicMock() @@ -2741,7 +3030,6 @@ def test_parse_contents_mcp_tool_result_bytes_content( ) -> None: """Test parsing MCP tool result with bytes content.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_123", "test_tool") # Create mock MCP tool result with bytes content mock_block = MagicMock() @@ -2760,7 +3048,6 @@ def test_parse_contents_mcp_tool_result_object_content( ) -> None: """Test parsing MCP tool result with object content.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_123", "test_tool") # Create mock MCP tool result with object content mock_content_obj = MagicMock() @@ -2783,7 +3070,6 @@ def test_parse_contents_web_search_tool_result( ) -> None: """Test parsing web search tool result.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_789", "web_search") # Create mock web search tool result mock_block = MagicMock() @@ -2800,7 +3086,6 @@ def test_parse_contents_web_search_tool_result( def test_parse_contents_web_fetch_tool_result(mock_anthropic_client: MagicMock) -> None: """Test parsing web fetch tool result.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_101", "web_fetch") # Create mock web fetch tool result mock_block = MagicMock() @@ -3128,7 +3413,6 @@ def test_parse_code_execution_result_with_error( ) -> None: """Test parsing code execution result with error.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_code1", "code_execution_tool") # Create mock code execution result with error from anthropic.types.beta.beta_code_execution_tool_result_error import ( @@ -3153,7 +3437,6 @@ def test_parse_code_execution_result_with_stdout( ) -> None: """Test parsing code execution result with stdout.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_code2", "code_execution_tool") # Create mock code execution result with stdout mock_content = MagicMock() @@ -3177,7 +3460,6 @@ def test_parse_code_execution_result_with_stderr( ) -> None: """Test parsing code execution result with stderr.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_code3", "code_execution_tool") # Create mock code execution result with stderr mock_content = MagicMock() @@ -3201,7 +3483,6 @@ def test_parse_code_execution_result_with_files( ) -> None: """Test parsing code execution result with file outputs.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_code4", "code_execution_tool") # Create mock file output mock_file = MagicMock() @@ -3232,7 +3513,6 @@ def test_parse_bash_execution_result_with_stdout( ) -> None: """Test parsing bash execution result with stdout.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_bash2", "bash_code_execution") # Create mock bash execution result with stdout mock_content = MagicMock() @@ -3264,7 +3544,6 @@ def test_parse_bash_execution_result_with_stderr( ) -> None: """Test parsing bash execution result with stderr.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_bash3", "bash_code_execution") # Create mock bash execution result with stderr mock_content = MagicMock() @@ -3298,7 +3577,6 @@ def test_parse_bash_execution_result_with_error( ) client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_bash_err", "bash_code_execution") mock_error = MagicMock(spec=BetaBashCodeExecutionToolResultError) mock_error.error_code = "execution_time_exceeded" @@ -3324,7 +3602,6 @@ def test_parse_bash_execution_result_with_error( def test_parse_text_editor_result_error(mock_anthropic_client: MagicMock) -> None: """Test parsing text editor result with error.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_editor1", "text_editor_code_execution") # Create mock text editor result with error mock_content = MagicMock() @@ -3345,7 +3622,6 @@ def test_parse_text_editor_result_error(mock_anthropic_client: MagicMock) -> Non def test_parse_text_editor_result_view(mock_anthropic_client: MagicMock) -> None: """Test parsing text editor view result.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_editor2", "text_editor_code_execution") # Create mock text editor view result mock_content = MagicMock() @@ -3368,7 +3644,6 @@ def test_parse_text_editor_result_view(mock_anthropic_client: MagicMock) -> None def test_parse_text_editor_result_str_replace(mock_anthropic_client: MagicMock) -> None: """Test parsing text editor string replace result.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_editor3", "text_editor_code_execution") # Create mock text editor str_replace result mock_content = MagicMock() @@ -3393,7 +3668,6 @@ def test_parse_text_editor_result_str_replace(mock_anthropic_client: MagicMock) def test_parse_text_editor_result_file_create(mock_anthropic_client: MagicMock) -> None: """Test parsing text editor file create result.""" client = create_test_anthropic_client(mock_anthropic_client) - client._last_call_id_name = ("call_editor4", "text_editor_code_execution") # Create mock text editor create result mock_content = MagicMock() From 249759787350ea9bd937286bce6a6ba72462dde3 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 11 Sep 2026 14:34:41 +0000 Subject: [PATCH 023/108] test(python): validate local shell approval binding (#8233) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../tools/tests/test_local_shell_tool.py | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/python/packages/tools/tests/test_local_shell_tool.py b/python/packages/tools/tests/test_local_shell_tool.py index 41e23b60c41..55df42983e2 100644 --- a/python/packages/tools/tests/test_local_shell_tool.py +++ b/python/packages/tools/tests/test_local_shell_tool.py @@ -1,16 +1,30 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import json import os import sys +from collections.abc import Awaitable, Mapping, Sequence +from typing import Any from unittest.mock import AsyncMock, patch import pytest +from agent_framework import Agent, AgentSession, BaseChatClient, ChatResponse, Content, FunctionInvocationLayer, Message +from agent_framework.security import ( + ContentLabel, + IntegrityLabel, + LabelTrackingFunctionMiddleware, + PolicyEnforcementFunctionMiddleware, +) from agent_framework_tools._feature_usage import FeatureIndex from agent_framework_tools.shell import LocalShellTool, ShellCommandError, ShellPolicy from agent_framework_tools.shell._executor import _popen_kwargs_for_group, run_stateless +_TEST_SHELL = "agent-framework-test-shell" +_APPROVED_COMMAND = "printf '%s' approved-value" +_ALTERNATE_COMMAND = "printf '%s' alternate-value" + class _FakeExecProcess: def __init__( @@ -32,6 +46,69 @@ async def communicate(self) -> tuple[bytes, bytes]: return stdout, stderr +class _ScriptedFunctionClient(FunctionInvocationLayer, BaseChatClient): + def __init__(self, responses: Sequence[ChatResponse]) -> None: + super().__init__() + self._responses = list(responses) + + def _inner_get_response( + self, + *, + messages: Sequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse]: + assert not stream + + async def get_response() -> ChatResponse: + if self._responses: + return self._responses.pop(0) + return ChatResponse(messages=Message(role="assistant", contents=["done"])) + + return get_response() + + +async def _request_variable_shell_approval( + session_id: str, +) -> tuple[Agent[Any], AgentSession, Content, str, LabelTrackingFunctionMiddleware]: + tracker = LabelTrackingFunctionMiddleware() + session = AgentSession(session_id=session_id) + variable_id = tracker.get_variable_store(session).store( + _APPROVED_COMMAND, + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + function_call = Content.from_function_call( + call_id="provider-shell-call", + name="run_shell", + arguments={"command": f"[{variable_id}]"}, + id="shell-call-occurrence", + ) + client = _ScriptedFunctionClient([ + ChatResponse(messages=Message(role="assistant", contents=[function_call])), + ]) + # Isolate the variable-aware policy approval from the tool's independent blanket approval. + shell = LocalShellTool( + mode="stateless", + shell=[_TEST_SHELL], + approval_mode="never_require", + acknowledge_unsafe=True, + ) + agent = Agent( + client=client, + tools=[shell.as_function()], + middleware=[ + tracker, + PolicyEnforcementFunctionMiddleware(approval_on_violation=True), + ], + ) + + response = await agent.run("Run the hidden command", session=session) + + assert len(response.user_input_requests) == 1 + return agent, session, response.user_input_requests[0], variable_id, tracker + + async def test_stateless_echo() -> None: tool = LocalShellTool(mode="stateless", approval_mode="never_require", acknowledge_unsafe=True) cmd = "Write-Output hello" if sys.platform == "win32" else "echo hello" @@ -319,6 +396,96 @@ async def test_as_function_wires_kind_and_approval() -> None: assert ft.approval_mode == "always_require" +async def test_variable_shell_approval_executes_only_the_reviewed_command() -> None: + agent, session, request, variable_id, _ = await _request_variable_shell_approval("exact-shell-approval") + + assert request.id == "shell-call-occurrence" + assert request.function_call is not None + assert request.function_call.call_id == "provider-shell-call" + assert request.function_call.name == "run_shell" + assert request.function_call.parse_arguments() == {"command": f"[{variable_id}]"} + + create_process = AsyncMock(return_value=_FakeExecProcess(communicate_results=[(b"approved-value", b"")])) + with patch("agent_framework_tools.shell._executor.asyncio.create_subprocess_exec", create_process): + await agent.run(request.to_function_approval_response(True), session=session) + + assert create_process.await_count == 1 + assert create_process.await_args is not None + assert create_process.await_args.args == (_TEST_SHELL, "-c", _APPROVED_COMMAND) + + +async def test_variable_shell_approval_blocks_changed_resolved_command() -> None: + agent, session, request, variable_id, _ = await _request_variable_shell_approval("changed-shell-variable") + security_state = session.state["__agent_framework_fides_security__"] + security_state["variables"][variable_id]["content"] = json.dumps(_ALTERNATE_COMMAND) + + create_process = AsyncMock() + with patch("agent_framework_tools.shell._executor.asyncio.create_subprocess_exec", create_process): + response = await agent.run(request.to_function_approval_response(True), session=session) + + create_process.assert_not_awaited() + replacement = response.user_input_requests + assert len(replacement) == 1 + assert replacement[0].id != request.id + assert replacement[0].function_call is not None + assert replacement[0].function_call.parse_arguments() == {"command": f"[{variable_id}]"} + + +@pytest.mark.parametrize("mutation", ["reference", "command", "arguments", "request_id", "call_id"]) +async def test_variable_shell_approval_substitutions_cannot_change_executed_argv(mutation: str) -> None: + agent, session, request, _, tracker = await _request_variable_shell_approval(f"substituted-shell-{mutation}") + response = Content.from_dict(request.to_function_approval_response(True).to_dict()) + assert response.function_call is not None + + if mutation == "reference": + alternate_variable_id = tracker.get_variable_store(session).store( + _ALTERNATE_COMMAND, + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + response.function_call.arguments = {"command": f"[{alternate_variable_id}]"} + elif mutation == "command": + response.function_call.arguments = {"command": _ALTERNATE_COMMAND} + elif mutation == "arguments": + response.function_call.arguments = { + "command": _ALTERNATE_COMMAND, + "unexpected": "substituted", + } + elif mutation == "request_id": + response.id = "different-request" + else: + response.function_call.call_id = "different-provider-call" + + create_process = AsyncMock( + side_effect=lambda *_args, **_kwargs: _FakeExecProcess(communicate_results=[(b"approved-value", b"")]) + ) + with patch("agent_framework_tools.shell._executor.asyncio.create_subprocess_exec", create_process): + await agent.run(response, session=session) + + if mutation == "request_id": + create_process.assert_not_awaited() + else: + assert create_process.await_count == 1 + assert create_process.await_args is not None + assert create_process.await_args.args == (_TEST_SHELL, "-c", _APPROVED_COMMAND) + + +async def test_variable_shell_approval_cannot_be_replayed_on_later_turn_or_session() -> None: + agent, session, request, _, _ = await _request_variable_shell_approval("shell-approval-replay") + approval = request.to_function_approval_response(True) + create_process = AsyncMock( + side_effect=lambda *_args, **_kwargs: _FakeExecProcess(communicate_results=[(b"approved-value", b"")]) + ) + + with patch("agent_framework_tools.shell._executor.asyncio.create_subprocess_exec", create_process): + await agent.run(approval, session=session) + await agent.run(approval, session=session) + await agent.run(approval, session=AgentSession(session_id="different-shell-session")) + + assert create_process.await_count == 1 + assert create_process.await_args is not None + assert create_process.await_args.args == (_TEST_SHELL, "-c", _APPROVED_COMMAND) + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX persistent reanchor test") async def test_persistent_confines_workdir_by_default(tmp_path: os.PathLike[str]) -> None: """With the default ``confine_workdir=True``, a ``cd`` in one call From 119c795cceef4498a19819a9727962885aa690ce Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 11 Sep 2026 14:37:43 +0000 Subject: [PATCH 024/108] Python: [BREAKING] Scope Redis history storage keys (#8236) * Python: Scope Redis history storage keys Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Address Redis history review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/packages/redis/AGENTS.md | 8 +- python/packages/redis/README.md | 41 ++++ .../_history_provider.py | 102 +++++++- python/packages/redis/tests/test_providers.py | 231 +++++++++++++++--- .../redis/azure_redis_conversation.py | 6 +- .../conversations/redis_history_provider.py | 12 + 6 files changed, 350 insertions(+), 50 deletions(-) diff --git a/python/packages/redis/AGENTS.md b/python/packages/redis/AGENTS.md index 93794790c64..52b7070ee66 100644 --- a/python/packages/redis/AGENTS.md +++ b/python/packages/redis/AGENTS.md @@ -4,7 +4,9 @@ Redis-based storage for agent threads and context. ## Main Classes -- **`RedisHistoryProvider`** - Persistent chat history provider using Redis +- **`RedisHistoryProvider`** - Persistent chat history provider using scoped Redis keys. Scoped mode requires an + application ID and non-empty session ID, and can additionally isolate tenants and agents. Use explicit legacy mode + only while deliberately migrating historical keys; scoped mode never accesses them automatically. - **`RedisContextProvider`** - Context provider with Redis-backed retrieval - **`RedisSettings`** - TypedDict connection settings for vector stores, resolved with core `load_settings` from explicit URL overrides, an optional .env file, or `REDIS_URL`. URLs use `SecretString` to mask credentials. @@ -40,7 +42,9 @@ their own indexes/keys, never `FLUSHALL`. Coverage includes both formats and from agent_framework.redis import RedisContextProvider, RedisHistoryProvider context_provider = RedisContextProvider(redis_url="redis://localhost:6379") -history_provider = RedisHistoryProvider(redis_url="redis://localhost:6379") +history_provider = RedisHistoryProvider( + redis_url="redis://localhost:6379", application_id="my-app", agent_id="my-agent" +) ``` ## Import Path diff --git a/python/packages/redis/README.md b/python/packages/redis/README.md index 627f39ed13a..a551741cba1 100644 --- a/python/packages/redis/README.md +++ b/python/packages/redis/README.md @@ -79,6 +79,47 @@ arrays. Indexed strings cannot contain surrounding whitespace, NUL, or U+001F, and must fit Redis's 4096-byte TAG limit; these restrictions do not apply to unindexed payloads. Unsupported operations raise an error. +## Isolate conversation history + +`RedisHistoryProvider` uses scoped keys by default. Supply a stable +`application_id`; also supply `tenant_id` and `agent_id` whenever those +boundaries exist in your application. The provider's `source_id` and each +non-empty session ID are included automatically: + +```python +from agent_framework.redis import RedisHistoryProvider + +history_provider = RedisHistoryProvider( + redis_url="redis://localhost:6379", + application_id="support-app", + tenant_id="contoso", + agent_id="triage-agent", +) +``` + +Scoped mode rejects missing application or session identifiers rather than +placing unrelated conversations under a shared fallback key. Identifiers are +encoded independently, so they do not need to be globally unique across +tenants, applications, agents, and provider sources. + +Releases that predate scoped keys used +`{key_prefix}:{session_id or "default"}`. Existing deployments can temporarily +retain that exact format by opting in explicitly: + +```python +legacy_history_provider = RedisHistoryProvider( + redis_url="redis://localhost:6379", + key_format="legacy", +) +``` + +Legacy mode does not accept scoped identifiers. Scoped mode never reads, +rewrites, or deletes legacy keys. To migrate existing history, copy only the +records belonging to a verified application, tenant, agent, provider source, +and session into the corresponding scoped key using an application-owned +migration process. After verifying the copied history, remove legacy keys +separately according to the application's retention policy. + ## Store and search documents This example uses precomputed vectors, so no embedding service is needed. diff --git a/python/packages/redis/agent_framework_redis/_history_provider.py b/python/packages/redis/agent_framework_redis/_history_provider.py index 8ec968a66e7..b29dc672f7d 100644 --- a/python/packages/redis/agent_framework_redis/_history_provider.py +++ b/python/packages/redis/agent_framework_redis/_history_provider.py @@ -9,12 +9,14 @@ from __future__ import annotations import json +import warnings from collections.abc import Awaitable, Sequence from inspect import isawaitable -from typing import Any, ClassVar, TypeVar, cast +from typing import Any, ClassVar, Literal, TypeVar, cast import redis.asyncio as redis from agent_framework import Message +from agent_framework._filesystem import _storage_key_segment # pyright: ignore[reportPrivateUsage] from agent_framework._sessions import HistoryProvider, filter_new_messages from agent_framework._telemetry import mark_feature_used from redis.credentials import CredentialProvider @@ -43,10 +45,17 @@ class RedisHistoryProvider(HistoryProvider): """Redis-backed history provider using the new HistoryProvider hooks pattern. Stores conversation history in Redis Lists, with each session isolated by a - unique Redis key. + key scoped to the application, optional tenant and agent, and provider source. """ DEFAULT_SOURCE_ID: ClassVar[str] = "redis_memory" + _ENCODED_KEY_PREFIX: ClassVar[str] = "~redis-key-prefix-" + _ENCODED_TENANT_PREFIX: ClassVar[str] = "~redis-tenant-" + _ENCODED_APPLICATION_PREFIX: ClassVar[str] = "~redis-application-" + _ENCODED_AGENT_PREFIX: ClassVar[str] = "~redis-agent-" + _ENCODED_SOURCE_PREFIX: ClassVar[str] = "~redis-source-" + _ENCODED_SESSION_PREFIX: ClassVar[str] = "~redis-session-" + _ABSENT_SCOPE_SEGMENT: ClassVar[str] = "~none" def __init__( self, @@ -59,6 +68,10 @@ def __init__( username: str | None = None, *, key_prefix: str = "chat_messages", + tenant_id: str | None = None, + application_id: str | None = None, + agent_id: str | None = None, + key_format: Literal["scoped", "legacy"] = "scoped", max_messages: int | None = None, load_messages: bool = True, store_outputs: bool = True, @@ -78,7 +91,17 @@ def __init__( port: Redis port number. Defaults to 6380 (Azure Redis SSL port). ssl: Enable SSL/TLS connection. Defaults to True. username: Redis username. - key_prefix: Prefix for Redis keys. Defaults to 'chat_messages'. + key_prefix: Base prefix for Redis keys. Scoped mode appends independently encoded + tenant, application, agent, provider source, and session segments. + Defaults to 'chat_messages'. + tenant_id: Optional tenant identifier used as an independent key boundary. + application_id: Application identifier used as a required key boundary in scoped mode. + agent_id: Optional agent identifier used as an independent key boundary. + key_format: Redis key format. ``"scoped"`` isolates history by tenant, + application, agent, provider source, and session. ``"legacy"`` + preserves the historical ``{key_prefix}:{session_id}`` format for + explicit migration compatibility. Scoped identifiers cannot be + supplied in legacy mode. Defaults to ``"scoped"``. max_messages: Maximum number of messages to retain per session. When exceeded, oldest messages are automatically trimmed. None means unlimited storage; 0 retains nothing, and no message @@ -95,6 +118,7 @@ def __init__( ValueError: If both redis_url and credential_provider are provided. ValueError: If credential_provider is used without host parameter. ValueError: If max_messages is negative. + ValueError: If key_format or its scoped identifiers are invalid. """ super().__init__( source_id, @@ -113,8 +137,30 @@ def __init__( raise ValueError("host is required when using credential_provider") if max_messages is not None and max_messages < 0: raise ValueError("max_messages must be None (unlimited) or a non-negative integer") + if key_format not in ("scoped", "legacy"): + raise ValueError("key_format must be 'scoped' or 'legacy'") + if key_format == "scoped": + if not application_id: + raise ValueError("application_id must be a non-empty string when key_format='scoped'") + if tenant_id == "": + raise ValueError("tenant_id must be non-empty when supplied") + if agent_id == "": + raise ValueError("agent_id must be non-empty when supplied") + elif any(scope is not None for scope in (tenant_id, application_id, agent_id)): + raise ValueError("tenant_id, application_id, and agent_id cannot be used with key_format='legacy'") + if key_format == "legacy": + warnings.warn( + "key_format='legacy' is deprecated and will be removed in a future version. " + "Migrate persisted history to scoped keys and use key_format='scoped'.", + DeprecationWarning, + stacklevel=2, + ) self.key_prefix = key_prefix + self.tenant_id = tenant_id + self.application_id = application_id + self.agent_id = agent_id + self.key_format = key_format self.max_messages = max_messages self.redis_url = redis_url @@ -130,9 +176,47 @@ def __init__( else: self._redis_client = redis.from_url(redis_url, decode_responses=True) # type: ignore[no-untyped-call] + @classmethod + def _optional_scope_segment(cls, value: str | None, *, encoded_prefix: str) -> str: + """Encode an optional isolation boundary without conflating absence with a caller value.""" + if value is None: + return cls._ABSENT_SCOPE_SEGMENT + return _storage_key_segment(value, encoded_prefix=encoded_prefix) + def _redis_key(self, session_id: str | None) -> str: - """Get the Redis key for a given session's messages.""" - return f"{self.key_prefix}:{session_id or 'default'}" + """Get a pipe-delimited scoped key or the historical colon-delimited legacy key.""" + if self.key_format == "legacy": + return f"{self.key_prefix}:{session_id or 'default'}" + if not session_id: + raise ValueError("session_id must be a non-empty string when key_format='scoped'") + + key_prefix_segment = _storage_key_segment( + self.key_prefix, + encoded_prefix=self._ENCODED_KEY_PREFIX, + ) + tenant_segment = self._optional_scope_segment( + self.tenant_id, + encoded_prefix=self._ENCODED_TENANT_PREFIX, + ) + agent_segment = self._optional_scope_segment( + self.agent_id, + encoded_prefix=self._ENCODED_AGENT_PREFIX, + ) + application_segment = _storage_key_segment( + cast("str", self.application_id), + encoded_prefix=self._ENCODED_APPLICATION_PREFIX, + ) + source_segment = _storage_key_segment(self.source_id, encoded_prefix=self._ENCODED_SOURCE_PREFIX) + session_segment = _storage_key_segment(session_id, encoded_prefix=self._ENCODED_SESSION_PREFIX) + return "|".join(( + key_prefix_segment, + "v2", + tenant_segment, + application_segment, + agent_segment, + source_segment, + session_segment, + )) async def get_messages( self, @@ -181,19 +265,17 @@ async def save_messages( **kwargs: Additional arguments (unused). """ mark_feature_used(FeatureIndex.REDIS) + key = self._redis_key(session_id) if not messages: return if self.max_messages == 0: # Retention is disabled. Trimming cannot express this - LTRIM key 0 -1 keeps # the whole list - so return before serializing: no payload reaches Redis, an - # AOF or a replica. Stored history is deliberately left alone. _redis_key omits - # source_id, so the list can belong to a co-located provider, and removing - # stored history is what clear() is for. + # AOF or a replica. Stored history is deliberately left alone; removing stored + # history is what clear() is for. return - key = self._redis_key(session_id) - existing_messages = await self.get_messages(session_id, state=state, **kwargs) new_messages = filter_new_messages(existing_messages, messages) diff --git a/python/packages/redis/tests/test_providers.py b/python/packages/redis/tests/test_providers.py index c6ba48a0afa..9cf10c07bca 100644 --- a/python/packages/redis/tests/test_providers.py +++ b/python/packages/redis/tests/test_providers.py @@ -21,11 +21,12 @@ # --------------------------------------------------------------------------- -async def test_empty_history_save_marks_redis_used() -> None: - provider = object.__new__(RedisHistoryProvider) +async def test_empty_history_save_marks_redis_used(mock_redis_client: MagicMock) -> None: + with patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client): + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") with patch("agent_framework_redis._history_provider.mark_feature_used") as mark_feature_used: - await provider.save_messages(None, []) + await provider.save_messages("session", []) mark_feature_used.assert_called_once_with(FeatureIndex.REDIS) @@ -350,7 +351,7 @@ class TestRedisHistoryProviderInit: def test_basic_construction(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("memory", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("memory", redis_url="redis://localhost:6379", application_id="test-app") assert provider.source_id == "memory" assert provider.key_prefix == "chat_messages" @@ -365,6 +366,7 @@ def test_custom_params(self, mock_redis_client: MagicMock): provider = RedisHistoryProvider( "mem", redis_url="redis://localhost:6379", + application_id="test-app", key_prefix="custom", max_messages=50, load_messages=False, @@ -373,6 +375,8 @@ def test_custom_params(self, mock_redis_client: MagicMock): ) assert provider.key_prefix == "custom" + assert provider.application_id == "test-app" + assert provider.key_format == "scoped" assert provider.max_messages == 50 assert provider.load_messages is False assert provider.store_outputs is False @@ -399,13 +403,15 @@ def test_credential_provider_without_host_raises(self): def test_negative_max_messages_raises(self): with pytest.raises(ValueError, match="max_messages"): - RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=-5) + RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=-5) def test_credential_provider_with_host(self): mock_cred = MagicMock() with patch("agent_framework_redis._history_provider.redis.Redis") as mock_redis_cls: mock_redis_cls.return_value = MagicMock() - provider = RedisHistoryProvider("mem", credential_provider=mock_cred, host="myhost") + provider = RedisHistoryProvider( + "mem", credential_provider=mock_cred, host="myhost", application_id="test-app" + ) mock_redis_cls.assert_called_once_with( host="myhost", @@ -419,14 +425,162 @@ def test_credential_provider_with_host(self): class TestRedisHistoryProviderRedisKey: - def test_key_format(self, mock_redis_client: MagicMock): + def test_scoped_key_is_deterministic_and_encodes_opaque_components(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", key_prefix="msgs") + provider = RedisHistoryProvider( + "source:one", + redis_url="redis://localhost:6379", + key_prefix="messages:one", + tenant_id="tenant/one", + application_id="Application One", + agent_id="agent:one", + ) + + key = provider._redis_key("session/one") + + assert key == provider._redis_key("session/one") + assert key.split("|")[1] == "v2" + assert ":" not in key + raw_components = ("messages:one", "tenant/one", "Application One", "agent:one", "source:one", "session/one") + assert all(raw_component not in key for raw_component in raw_components) + + def test_scoped_key_isolates_each_boundary(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: + mock_from_url.return_value = mock_redis_client + providers = [ + RedisHistoryProvider( + source_id, + redis_url="redis://localhost:6379", + tenant_id=tenant_id, + application_id=application_id, + agent_id=agent_id, + ) + for source_id, tenant_id, application_id, agent_id in ( + ("source-a", "tenant-a", "app-a", "agent-a"), + ("source-b", "tenant-a", "app-a", "agent-a"), + ("source-a", "tenant-b", "app-a", "agent-a"), + ("source-a", "tenant-a", "app-b", "agent-a"), + ("source-a", "tenant-a", "app-a", "agent-b"), + ) + ] + + keys = {provider._redis_key("shared-session") for provider in providers} + + assert len(keys) == len(providers) + + @pytest.mark.parametrize("application_id", [None, ""]) + def test_scoped_format_requires_application_id( + self, mock_redis_client: MagicMock, application_id: str | None + ) -> None: + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.raises(ValueError, match="application_id"), + ): + RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id=application_id) + + @pytest.mark.parametrize(("tenant_id", "agent_id", "field"), [("", None, "tenant_id"), (None, "", "agent_id")]) + def test_scoped_format_rejects_empty_optional_scope( + self, mock_redis_client: MagicMock, tenant_id: str | None, agent_id: str | None, field: str + ) -> None: + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.raises(ValueError, match=field), + ): + RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + application_id="app", + tenant_id=tenant_id, + agent_id=agent_id, + ) + + @pytest.mark.parametrize("session_id", [None, ""]) + def test_scoped_format_requires_session_id(self, mock_redis_client: MagicMock, session_id: str | None) -> None: + with patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client): + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + application_id="app", + ) + + with pytest.raises(ValueError, match="session_id"): + provider._redis_key(session_id) + + @pytest.mark.parametrize("max_messages", [None, 0]) + async def test_scoped_save_validates_session_before_noop( + self, mock_redis_client: MagicMock, max_messages: int | None + ) -> None: + with patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client): + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + application_id="app", + max_messages=max_messages, + ) + + messages = [] if max_messages is None else [Message(role="user", contents=["hello"])] + with pytest.raises(ValueError, match="session_id"): + await provider.save_messages(None, messages) + + async def test_scoped_operations_use_only_the_scoped_key(self, mock_redis_client: MagicMock): + with patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client): + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + tenant_id="tenant", + application_id="app", + agent_id="agent", + ) + + expected_key = provider._redis_key("session") + message = Message(role="user", contents=["hello"]) + + await provider.get_messages("session") + await provider.save_messages("session", [message]) + await provider.clear("session") + + expected_range_call = ((expected_key, 0, -1),) + assert mock_redis_client.lrange.call_args_list == [expected_range_call, expected_range_call] + pipeline = mock_redis_client.pipeline.return_value.__aenter__.return_value + pipeline.rpush.assert_awaited_once_with(expected_key, provider._serialize_json(message)) + mock_redis_client.delete.assert_awaited_once_with(expected_key) + assert expected_key != "chat_messages:session" + + def test_legacy_format_warns_and_preserves_historical_keys(self, mock_redis_client: MagicMock): + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.warns(DeprecationWarning, match="key_format='legacy'.*deprecated"), + ): + provider = RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + key_prefix="msgs", + key_format="legacy", + ) assert provider._redis_key("session-123") == "msgs:session-123" assert provider._redis_key(None) == "msgs:default" + def test_legacy_format_rejects_scoped_configuration(self, mock_redis_client: MagicMock): + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.raises(ValueError, match="legacy"), + ): + RedisHistoryProvider( + "mem", + redis_url="redis://localhost:6379", + application_id="app", + key_format="legacy", + ) + + def test_rejects_unsupported_key_format(self, mock_redis_client: MagicMock): + with ( + patch("agent_framework_redis._history_provider.redis.from_url", return_value=mock_redis_client), + pytest.raises(ValueError, match="key_format"), + ): + RedisHistoryProvider("mem", redis_url="redis://localhost:6379", key_format=cast(Any, "unsupported")) + class TestRedisHistoryProviderGetMessages: async def test_returns_deserialized_messages(self, mock_redis_client: MagicMock): @@ -436,7 +590,7 @@ async def test_returns_deserialized_messages(self, mock_redis_client: MagicMock) with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") messages = await provider.get_messages("s1") assert len(messages) == 2 @@ -450,7 +604,7 @@ async def test_empty_returns_empty(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") messages = await provider.get_messages("s1") assert messages == [] @@ -462,7 +616,7 @@ async def test_returns_messages_when_lrange_is_synchronous(self, mock_redis_clie with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") messages = await provider.get_messages("s1") assert len(messages) == 1 @@ -484,7 +638,7 @@ class TestRedisHistoryProviderSaveMessages: async def test_saves_serialized_messages(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") msgs = [Message(role="user", contents=["Hello"]), Message(role="assistant", contents=["Hi"])] await provider.save_messages("s1", msgs) @@ -496,7 +650,7 @@ async def test_saves_serialized_messages(self, mock_redis_client: MagicMock): async def test_empty_messages_noop(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", []) mock_redis_client.pipeline.assert_not_called() @@ -506,18 +660,22 @@ async def test_max_messages_trimming(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=10) + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=10 + ) await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) - mock_redis_client.ltrim.assert_called_once_with("chat_messages:s1", -10, -1) + mock_redis_client.ltrim.assert_called_once_with(provider._redis_key("s1"), -10, -1) async def test_no_trim_when_under_limit(self, mock_redis_client: MagicMock): mock_redis_client.llen = AsyncMock(return_value=3) with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=10) + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=10 + ) await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) @@ -533,7 +691,9 @@ async def test_max_messages_zero_retains_nothing(self, mock_redis_client: MagicM with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=0) + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=0 + ) await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) @@ -542,17 +702,12 @@ async def test_max_messages_zero_retains_nothing(self, mock_redis_client: MagicM mock_redis_client.ltrim.assert_not_called() async def test_max_messages_zero_leaves_stored_history_alone(self, mock_redis_client: MagicMock): - """Disabling retention must not delete history this provider does not own. - - ``_redis_key`` omits ``source_id``, so two providers with the default prefix - share ``{key_prefix}:{session_id}``. Persisting runs in reverse provider order, - so a zero-retention provider that deleted the key would drop a co-located - provider's just-written history on every turn. Removing stored history is - ``clear()``'s job, not a retention setting's. - """ + """Disabling retention must leave previously stored history unchanged.""" with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", max_messages=0) + provider = RedisHistoryProvider( + "mem", redis_url="redis://localhost:6379", application_id="test-app", max_messages=0 + ) await provider.save_messages("s1", [Message(role="user", contents=["msg"])]) @@ -563,10 +718,10 @@ class TestRedisHistoryProviderClear: async def test_clear_calls_delete(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.clear("session-1") - mock_redis_client.delete.assert_called_once_with("chat_messages:session-1") + mock_redis_client.delete.assert_called_once_with(provider._redis_key("session-1")) class TestRedisHistoryProviderBeforeAfterRun: @@ -578,7 +733,7 @@ async def test_before_run_loads_history(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") session = AgentSession(session_id="test") ctx = SessionContext(input_messages=[Message(role="user", contents=["new msg"])], session_id="s1") @@ -597,7 +752,7 @@ async def test_before_run_loads_history(self, mock_redis_client: MagicMock): async def test_after_run_stores_input_and_response(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") session = AgentSession(session_id="test") ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1") @@ -618,7 +773,11 @@ async def test_after_run_skips_when_no_messages(self, mock_redis_client: MagicMo with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client provider = RedisHistoryProvider( - "mem", redis_url="redis://localhost:6379", store_inputs=False, store_outputs=False + "mem", + redis_url="redis://localhost:6379", + application_id="test-app", + store_inputs=False, + store_outputs=False, ) session = AgentSession(session_id="test") @@ -645,7 +804,7 @@ async def test_deduplicates_identical_messages(self, mock_redis_client: MagicMoc with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", [msg1, msg2]) @@ -662,7 +821,7 @@ async def test_only_appends_new_messages(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", [msg1, msg2, msg3]) @@ -680,7 +839,7 @@ async def test_different_roles_same_text_not_deduplicated(self, mock_redis_clien with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") msg2 = Message(role="assistant", contents=["ping"]) await provider.save_messages("s1", [msg1, msg2]) @@ -699,7 +858,7 @@ async def test_trimmed_messages_not_reappended(self, mock_redis_client: MagicMoc with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", [msg_old, msg_new]) @@ -716,7 +875,7 @@ async def test_preserves_duplicate_content(self, mock_redis_client: MagicMock): with patch("agent_framework_redis._history_provider.redis.from_url") as mock_from_url: mock_from_url.return_value = mock_redis_client - provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379") + provider = RedisHistoryProvider("mem", redis_url="redis://localhost:6379", application_id="test-app") await provider.save_messages("s1", [yes_1, yes_2]) diff --git a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py index a5baf84b1c1..d94ece6c7e2 100644 --- a/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py +++ b/python/samples/02-agents/context_providers/redis/azure_redis_conversation.py @@ -77,6 +77,8 @@ async def main() -> None: port=10000, ssl=True, key_prefix="chat_messages", + application_id="azure-redis-conversation", + agent_id="azure-redis-assistant", max_messages=100, ) @@ -96,8 +98,8 @@ async def main() -> None: ) # 5. Create a session to provide conversation identity. - # The session ID is used as the Redis key — all runs sharing the same session - # will read/write the same conversation history in Redis. + # The session ID is part of the scoped Redis key, so runs sharing this provider + # configuration and session read/write the same conversation history. session = agent.create_session() # 6. Conversation — each run passes the same session for continuity diff --git a/python/samples/02-agents/conversations/redis_history_provider.py b/python/samples/02-agents/conversations/redis_history_provider.py index 6829382dc85..69dafd36b18 100644 --- a/python/samples/02-agents/conversations/redis_history_provider.py +++ b/python/samples/02-agents/conversations/redis_history_provider.py @@ -23,6 +23,7 @@ # Default Redis URL for local Redis Stack. # Override via the REDIS_URL environment variable for remote or authenticated instances. REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") +APPLICATION_ID = "redis-history-sample" async def example_manual_memory_store() -> None: @@ -33,6 +34,8 @@ async def example_manual_memory_store() -> None: redis_provider = RedisHistoryProvider( source_id="redis_basic_chat", redis_url=REDIS_URL, + application_id=APPLICATION_ID, + agent_id="redis-bot", ) # Create agent with Redis history provider @@ -72,6 +75,9 @@ async def example_user_session_management() -> None: redis_provider = RedisHistoryProvider( source_id=f"redis_{user_id}", redis_url=REDIS_URL, + tenant_id="sample-tenant", + application_id=APPLICATION_ID, + agent_id="session-bot", max_messages=10, # Keep only last 10 messages ) @@ -114,6 +120,8 @@ async def example_conversation_persistence() -> None: redis_provider = RedisHistoryProvider( source_id="redis_persistent_chat", redis_url=REDIS_URL, + application_id=APPLICATION_ID, + agent_id="persistent-bot", ) agent = Agent( @@ -164,6 +172,8 @@ async def example_session_serialization() -> None: redis_provider = RedisHistoryProvider( source_id="redis_serialization_chat", redis_url=REDIS_URL, + application_id=APPLICATION_ID, + agent_id="serialization-bot", ) agent = Agent( @@ -207,6 +217,8 @@ async def example_message_limits() -> None: redis_provider = RedisHistoryProvider( source_id="redis_limited_chat", redis_url=REDIS_URL, + application_id=APPLICATION_ID, + agent_id="limit-bot", max_messages=3, # Keep only 3 most recent messages ) From e0162ab4bea059ea1e784133621d57dff5d6ee26 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 11 Sep 2026 14:45:19 +0000 Subject: [PATCH 025/108] .NET: Canonicalize Hyperlight sandbox fingerprints (#8295) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b9da797e-a9ff-492f-b5c1-8e9c7af1d8dc --- .../HyperlightCodeActProvider.cs | 4 +- .../HyperlightExecuteCodeFunction.cs | 4 +- .../Internal/SandboxExecutor.cs | 183 +++++++++++---- .../ProvideAIContextTests.cs | 104 +++++++++ .../SandboxExecutorTests.cs | 208 ++++++++++++++++++ 5 files changed, 453 insertions(+), 50 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs index d3442a32442..a96c47d3abd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -75,7 +75,7 @@ public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable public HyperlightCodeActProvider(HyperlightCodeActProviderOptions? options = null) { this._options = options ?? new HyperlightCodeActProviderOptions(); - this._executor = new SandboxExecutor(this._options); + this._executor = new SandboxExecutor(); if (this._options.Tools is not null) { @@ -280,7 +280,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co this._tools.Values.ToList(), this._fileMounts.Values.ToList(), this._allowedDomains.Values.ToList(), - this._options.HostInputDirectory, + this._options, this._toolRegistryVersion); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs index c5e61d89acc..c99eedb1f1f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs @@ -65,7 +65,7 @@ public sealed class HyperlightExecuteCodeFunction : AIFunction, IDisposable public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = null) { var effective = options ?? new HyperlightCodeActProviderOptions(); - this._executor = new SandboxExecutor(effective); + this._executor = new SandboxExecutor(); var tools = (effective.Tools?.Where(t => t is not null) ?? []).ToList(); var fileMounts = (effective.FileMounts?.Where(m => m is not null) ?? []).ToList(); @@ -75,7 +75,7 @@ public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = tools, fileMounts, allowedDomains, - effective.HostInputDirectory, + effective, toolRegistryVersion: Guid.Empty); this._description = InstructionBuilder.BuildExecuteCodeDescription( diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs index 578fc10734b..4c13b10d60a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -1,10 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Buffers; using System.Collections.Generic; -using System.Globalization; using System.Linq; -using System.Text; +using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -21,7 +21,6 @@ namespace Microsoft.Agents.AI.Hyperlight.Internal; /// internal sealed class SandboxExecutor : IDisposable { - private readonly HyperlightCodeActProviderOptions _options; private readonly SemaphoreSlim _executionLock = new(1, 1); private Sandbox? _sandbox; @@ -29,11 +28,6 @@ internal sealed class SandboxExecutor : IDisposable private string? _lastConfigFingerprint; private bool _disposed; - public SandboxExecutor(HyperlightCodeActProviderOptions options) - { - this._options = options; - } - /// /// Immutable snapshot of provider state at the start of a run. /// Used to build a run-scoped execute_code function that is @@ -45,20 +39,30 @@ public RunSnapshot( IReadOnlyList tools, IReadOnlyList fileMounts, IReadOnlyList allowedDomains, - string? hostInputDirectory, + HyperlightCodeActProviderOptions options, Guid toolRegistryVersion = default) { - this.Tools = tools; - this.FileMounts = fileMounts; - this.AllowedDomains = allowedDomains; - this.HostInputDirectory = hostInputDirectory; + this.Tools = tools.ToList(); + this.FileMounts = fileMounts.ToList(); + this.AllowedDomains = allowedDomains + .Select(domain => new AllowedDomain(domain.Target, domain.Methods?.ToArray())) + .ToList(); + this.Backend = options.Backend; + this.ModulePath = options.ModulePath; + this.HeapSize = options.HeapSize; + this.StackSize = options.StackSize; + this.HostInputDirectory = options.HostInputDirectory; this.ToolRegistryVersion = toolRegistryVersion; this.ConfigFingerprint = ComputeFingerprint( - tools, - fileMounts, - allowedDomains, - hostInputDirectory, - toolRegistryVersion); + this.Tools, + this.FileMounts, + this.AllowedDomains, + this.HostInputDirectory, + toolRegistryVersion, + this.Backend, + this.ModulePath, + this.HeapSize, + this.StackSize); } public IReadOnlyList Tools { get; } @@ -67,6 +71,14 @@ public RunSnapshot( public IReadOnlyList AllowedDomains { get; } + public SandboxBackend Backend { get; } + + public string? ModulePath { get; } + + public string? HeapSize { get; } + + public string? StackSize { get; } + public string? HostInputDirectory { get; } public Guid ToolRegistryVersion { get; } @@ -75,7 +87,7 @@ public RunSnapshot( /// Stable fingerprint of the configuration that materially affects how /// the sandbox must be built. Used by to /// decide whether a previously-built sandbox can be reused or must be - /// rebuilt because tools / mounts / allow-list entries have changed. + /// rebuilt because its capabilities or runtime options have changed. /// public string ConfigFingerprint { get; } @@ -84,35 +96,114 @@ internal static string ComputeFingerprint( IReadOnlyList fileMounts, IReadOnlyList allowedDomains, string? hostInputDirectory, - Guid toolRegistryVersion = default) + Guid toolRegistryVersion = default, + SandboxBackend backend = SandboxBackend.JavaScript, + string? modulePath = null, + string? heapSize = null, + string? stackSize = null) { - var sb = new StringBuilder(); - sb.Append("tools="); - foreach (var name in tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal)) + var buffer = new ArrayBufferWriter(); + using (var writer = new Utf8JsonWriter(buffer)) { - sb.Append(name).Append('|'); + writer.WriteStartObject(); + writer.WriteNumber("backend", (int)backend); + writer.WriteString("modulePath", modulePath); + writer.WriteString("heapSize", heapSize); + writer.WriteString("stackSize", stackSize); + writer.WriteString("hostInputDirectory", hostInputDirectory); + writer.WriteString("toolRegistryVersion", toolRegistryVersion); + + writer.WritePropertyName("tools"); + writer.WriteStartArray(); + foreach (var name in tools.Select(tool => tool.Name).OrderBy(name => name, StringComparer.Ordinal)) + { + writer.WriteStringValue(name); + } + + writer.WriteEndArray(); + + writer.WritePropertyName("fileMounts"); + writer.WriteStartArray(); + foreach (var mount in fileMounts + .OrderBy(mount => mount.MountPath, StringComparer.Ordinal) + .ThenBy(mount => mount.HostPath, StringComparer.Ordinal)) + { + writer.WriteStartObject(); + writer.WriteString("mountPath", mount.MountPath); + writer.WriteString("hostPath", mount.HostPath); + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + + var domains = allowedDomains + .Select(domain => ( + domain.Target, + Methods: domain.Methods?.OrderBy(method => method, StringComparer.Ordinal).ToArray())) + .ToList(); + domains.Sort(static (left, right) => + { + var targetComparison = StringComparer.Ordinal.Compare(left.Target, right.Target); + return targetComparison != 0 + ? targetComparison + : CompareStringArrays(left.Methods, right.Methods); + }); + + writer.WritePropertyName("allowedDomains"); + writer.WriteStartArray(); + foreach (var domain in domains) + { + writer.WriteStartObject(); + writer.WriteString("target", domain.Target); + writer.WritePropertyName("methods"); + if (domain.Methods is null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStartArray(); + foreach (var method in domain.Methods) + { + writer.WriteStringValue(method); + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); } - sb.Append(";toolVersion=").Append(toolRegistryVersion.ToString("D", CultureInfo.InvariantCulture)); + return Convert.ToHexString(SHA256.HashData(buffer.WrittenSpan)); + } + + private static int CompareStringArrays(string[]? left, string[]? right) + { + if (left is null) + { + return right is null ? 0 : -1; + } - sb.Append(";mounts="); - foreach (var m in fileMounts - .Select(m => m.MountPath + "->" + m.HostPath) - .OrderBy(s => s, StringComparer.Ordinal)) + if (right is null) { - sb.Append(m).Append('|'); + return 1; } - sb.Append(";allow="); - foreach (var d in allowedDomains - .Select(d => d.Target + "/" + (d.Methods is null ? "*" : string.Join(",", d.Methods))) - .OrderBy(s => s, StringComparer.Ordinal)) + var sharedLength = Math.Min(left.Length, right.Length); + for (var index = 0; index < sharedLength; index++) { - sb.Append(d).Append('|'); + var comparison = StringComparer.Ordinal.Compare(left[index], right[index]); + if (comparison != 0) + { + return comparison; + } } - sb.Append(";input=").Append(hostInputDirectory ?? string.Empty); - return sb.ToString(); + return left.Length.CompareTo(right.Length); } } @@ -162,7 +253,7 @@ private void EnsureInitialized(RunSnapshot snapshot) } // Configuration changed (or first run) — dispose the previous sandbox - // so the new one picks up the new tool/mount/allow-list set. + // so the new one picks up the current capabilities and runtime options. this._warmSnapshot?.Dispose(); this._sandbox?.Dispose(); this._warmSnapshot = null; @@ -174,21 +265,21 @@ private void EnsureInitialized(RunSnapshot snapshot) private void BuildAndWarmUp(RunSnapshot snapshot) { var builder = new SandboxBuilder() - .WithBackend(this._options.Backend); + .WithBackend(snapshot.Backend); - if (!string.IsNullOrEmpty(this._options.ModulePath)) + if (!string.IsNullOrEmpty(snapshot.ModulePath)) { - builder = builder.WithModulePath(this._options.ModulePath!); + builder = builder.WithModulePath(snapshot.ModulePath!); } - if (!string.IsNullOrEmpty(this._options.HeapSize)) + if (!string.IsNullOrEmpty(snapshot.HeapSize)) { - builder = builder.WithHeapSize(this._options.HeapSize!); + builder = builder.WithHeapSize(snapshot.HeapSize!); } - if (!string.IsNullOrEmpty(this._options.StackSize)) + if (!string.IsNullOrEmpty(snapshot.StackSize)) { - builder = builder.WithStackSize(this._options.StackSize!); + builder = builder.WithStackSize(snapshot.StackSize!); } var hostInput = snapshot.HostInputDirectory; @@ -221,7 +312,7 @@ private void BuildAndWarmUp(RunSnapshot snapshot) // Backend-specific no-op used to trigger lazy guest runtime initialization // before the warm snapshot is captured. Matches the values used by the // upstream HyperlightSandbox.Extensions.AI CodeExecutionTool reference. - _ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "void 0;" : "None"); + _ = sandbox.Run(snapshot.Backend == SandboxBackend.JavaScript ? "void 0;" : "None"); this._warmSnapshot = sandbox.Snapshot(); this._sandbox = sandbox; this._lastConfigFingerprint = snapshot.ConfigFingerprint; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs index 21e4273c0cd..726a98d608e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.Agents.AI.Hyperlight.Internal; @@ -101,4 +102,107 @@ public async Task ProvideAIContextAsync_AddToolsSameNameReplacement_ChangesSnaps var secondFunction = Assert.IsType(secondContext!.Tools!.First()); Assert.NotEqual(firstFunction.ConfigFingerprint, secondFunction.ConfigFingerprint); } + + [Fact] + public async Task ProvideAIContextAsync_ToolAddRemoveAndClear_ChangeSnapshotFingerprintAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var firstTool = AIFunctionFactory.Create(() => "one", name: "first_tool"); + var secondTool = AIFunctionFactory.Create(() => "two", name: "second_tool"); + var fingerprints = new List(); + + // Act + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddTools(firstTool); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.RemoveTools(firstTool.Name); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddTools(firstTool, secondTool); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.ClearTools(); + fingerprints.Add(await GetFingerprintAsync(provider)); + + // Assert + AssertFingerprintsChanged(fingerprints); + } + + [Fact] + public async Task ProvideAIContextAsync_FileMountAddReplaceRemoveAndClear_ChangeSnapshotFingerprintAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var fingerprints = new List(); + + // Act + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddFileMounts(new FileMount("/host/one", "/input/data")); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddFileMounts(new FileMount("/host/two", "/input/data")); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.RemoveFileMounts("/input/data"); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddFileMounts(new FileMount("/host/three", "/input/other")); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.ClearFileMounts(); + fingerprints.Add(await GetFingerprintAsync(provider)); + + // Assert + AssertFingerprintsChanged(fingerprints); + } + + [Fact] + public async Task ProvideAIContextAsync_AllowedDomainAddReplaceRemoveAndClear_ChangeSnapshotFingerprintAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var fingerprints = new List(); + + // Act + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddAllowedDomains(new AllowedDomain("https://example.com", ["GET"])); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddAllowedDomains(new AllowedDomain("https://example.com", ["POST"])); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.RemoveAllowedDomains("https://example.com"); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.AddAllowedDomains(new AllowedDomain("https://contoso.com")); + fingerprints.Add(await GetFingerprintAsync(provider)); + provider.ClearAllowedDomains(); + fingerprints.Add(await GetFingerprintAsync(provider)); + + // Assert + AssertFingerprintsChanged(fingerprints); + } + + [Fact] + public async Task ProvideAIContextAsync_SandboxOptionMutation_ChangesSnapshotFingerprintAsync() + { + // Arrange + var options = new HyperlightCodeActProviderOptions { HeapSize = "10Mi", StackSize = "5Mi" }; + using var provider = new HyperlightCodeActProvider(options); + + // Act + var firstFingerprint = await GetFingerprintAsync(provider); + options.HeapSize = "20Mi"; + options.StackSize = "10Mi"; + var secondFingerprint = await GetFingerprintAsync(provider); + + // Assert + Assert.NotEqual(firstFingerprint, secondFingerprint); + } + + private static void AssertFingerprintsChanged(List fingerprints) + { + for (var index = 1; index < fingerprints.Count; index++) + { + Assert.NotEqual(fingerprints[index - 1], fingerprints[index]); + } + } + + private static async Task GetFingerprintAsync(HyperlightCodeActProvider provider) + { + var context = await provider.InvokingAsync(NewInvokingContext()); + return Assert.IsType(context!.Tools!.First()).ConfigFingerprint; + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs index 174c5b78679..5a528606b60 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using HyperlightSandbox.Api; using Microsoft.Agents.AI.Hyperlight.Internal; using Microsoft.Extensions.AI; @@ -38,6 +40,24 @@ public void Fingerprint_OrderInsensitive_OnTools() Assert.Equal(fp1, fp2); } + [Fact] + public void Fingerprint_StructuredToolNames_DifferentFingerprints() + { + // Arrange + var joinedName = AIFunctionFactory.Create(() => "joined", name: "a|b"); + var firstTool = AIFunctionFactory.Create(() => "first", name: "a"); + var secondTool = AIFunctionFactory.Create(() => "second", name: "b"); + + // Act + var joinedFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [joinedName], [], [], hostInputDirectory: null); + var separateFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [firstTool, secondTool], [], [], hostInputDirectory: null); + + // Assert + Assert.NotEqual(joinedFingerprint, separateFingerprint); + } + [Fact] public void Fingerprint_SameNameDifferentToolRegistryVersionIds_DifferentFingerprints() { @@ -80,6 +100,44 @@ [new FileMount("/host/a", "/input/a")], Assert.NotEqual(fpEmpty, fpMount); } + [Fact] + public void Fingerprint_StructuredMountPaths_DifferentFingerprints() + { + // Act + var joinedFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [new FileMount("b|c->d", "a")], + [], + hostInputDirectory: null); + var separateFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [new FileMount("b", "a"), new FileMount("d", "c")], + [], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(joinedFingerprint, separateFingerprint); + } + + [Fact] + public void Fingerprint_DistinctMountPathComponents_DifferentFingerprints() + { + // Act + var firstFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [new FileMount("b->c", "a")], + [], + hostInputDirectory: null); + var secondFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [new FileMount("c", "a->b")], + [], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(firstFingerprint, secondFingerprint); + } + [Fact] public void Fingerprint_DifferentAllowedDomains_DifferentFingerprints() { @@ -99,6 +157,156 @@ [new AllowedDomain("https://b")], Assert.NotEqual(fp1, fp2); } + [Fact] + public void Fingerprint_DistinctDomainTargetAndMethod_DifferentFingerprints() + { + // Act + var firstFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("https://a/b", ["GET"])], + hostInputDirectory: null); + var secondFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("https://a", ["b/GET"])], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(firstFingerprint, secondFingerprint); + } + + [Fact] + public void Fingerprint_StructuredDomainMethods_DifferentFingerprints() + { + // Act + var joinedFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("https://a", ["GET,POST"])], + hostInputDirectory: null); + var separateFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("https://a", ["GET", "POST"])], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(joinedFingerprint, separateFingerprint); + } + + [Fact] + public void Fingerprint_StructuredAllowedDomains_DifferentFingerprints() + { + // Act + var joinedFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("a", ["GET|b/POST"])], + hostInputDirectory: null); + var separateFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [], + [new AllowedDomain("a", ["GET"]), new AllowedDomain("b", ["POST"])], + hostInputDirectory: null); + + // Assert + Assert.NotEqual(joinedFingerprint, separateFingerprint); + } + + [Fact] + public void Fingerprint_OrderInsensitive_OnMountsDomainsAndMethods() + { + // Arrange + var firstMount = new FileMount("/host/a", "/input/a"); + var secondMount = new FileMount("/host/b", "/input/b"); + var firstDomain = new AllowedDomain("https://a", ["POST", "GET"]); + var secondDomain = new AllowedDomain("https://b", ["DELETE"]); + + // Act + var firstFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [firstMount, secondMount], + [firstDomain, secondDomain], + hostInputDirectory: null); + var secondFingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], + [secondMount, firstMount], + [secondDomain, new AllowedDomain("https://a", ["GET", "POST"])], + hostInputDirectory: null); + + // Assert + Assert.Equal(firstFingerprint, secondFingerprint); + } + + [Fact] + public void Fingerprint_DifferentSandboxOptions_DifferentFingerprints() + { + // Arrange + var baseline = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: null); + + // Act + var differentBackend = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], [], [], hostInputDirectory: null, backend: SandboxBackend.Wasm); + var differentModule = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], [], [], hostInputDirectory: null, modulePath: "/guest/module.wasm"); + var differentHeap = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], [], [], hostInputDirectory: null, heapSize: "20Mi"); + var differentStack = SandboxExecutor.RunSnapshot.ComputeFingerprint( + [], [], [], hostInputDirectory: null, stackSize: "10Mi"); + + // Assert + Assert.NotEqual(baseline, differentBackend); + Assert.NotEqual(baseline, differentModule); + Assert.NotEqual(baseline, differentHeap); + Assert.NotEqual(baseline, differentStack); + } + + [Fact] + public void Fingerprint_IsSha256Hex() + { + // Act + var fingerprint = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: null); + + // Assert + Assert.Matches("^[0-9A-F]{64}$", fingerprint); + } + + [Fact] + public void RunSnapshot_CapturesMutableInputs() + { + // Arrange + var tools = new List { AIFunctionFactory.Create(() => "ok", name: "tool") }; + var mounts = new List { new("/host", "/input") }; + var methods = new List { "GET" }; + var domains = new List { new("https://example.com", methods) }; + var options = new HyperlightCodeActProviderOptions + { + HeapSize = "10Mi", + StackSize = "5Mi", + HostInputDirectory = "/host/input", + }; + + // Act + var snapshot = new SandboxExecutor.RunSnapshot(tools, mounts, domains, options); + tools.Clear(); + mounts.Clear(); + domains.Clear(); + methods.Add("POST"); + options.HeapSize = "20Mi"; + options.StackSize = "10Mi"; + options.HostInputDirectory = "/host/other"; + + // Assert + Assert.Single(snapshot.Tools); + Assert.Single(snapshot.FileMounts); + var domain = Assert.Single(snapshot.AllowedDomains); + Assert.Equal(["GET"], domain.Methods); + Assert.Equal("10Mi", snapshot.HeapSize); + Assert.Equal("5Mi", snapshot.StackSize); + Assert.Equal("/host/input", snapshot.HostInputDirectory); + } + [Fact] public void Fingerprint_DifferentHostInputDirectory_DifferentFingerprints() { From c47da07e22943d238a94f0a094180c1c2b13d2a7 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 11 Sep 2026 14:49:44 +0000 Subject: [PATCH 026/108] Python: scope Secure MCP URL headers to origin (#8285) * Python: scope Secure MCP URL headers to origin Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: bf1fc8c4-eff7-42e7-9342-d385036223be * Python: preserve concurrent Secure MCP calls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: bf1fc8c4-eff7-42e7-9342-d385036223be --- python/packages/core/AGENTS.md | 2 +- python/packages/core/agent_framework/_mcp.py | 55 +++--- .../packages/core/agent_framework/security.py | 28 +-- python/packages/core/tests/test_security.py | 162 ++++++++++++++++++ 4 files changed, 204 insertions(+), 43 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 471883c9fdc..08d6790bd9c 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -164,7 +164,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing. - **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, OTel, and task behavior. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries. - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through — but note this constrains the *model*, not the *server*, which still widens the effective allowlist through its schema. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used. -- **`header_provider` request scoping** - When sharing an `http_client`, keep provider processing scoped to the originating `MCPStreamableHTTPTool` and strip injected headers from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. +- **MCP HTTP header request scoping** - `static_headers` supplies fixed, origin-scoped headers without serializing concurrent calls; `header_provider` resolves dynamic per-call headers under the instance call lock. When both are configured, dynamic values override fixed values with the same name. With a shared `http_client`, processing stays scoped to the originating `MCPStreamableHTTPTool`, and every injected header is stripped from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. - **MCP lifecycle caller cancellation** - The lifecycle owner skips cancelled queued connect requests and waits for the caller to acknowledge successful setup. If the caller cancels before accepting a newly established connection, the owner tears it down before processing the next request. Cancelling a close waiter does not interrupt teardown, and cancelling a redundant connect does not discard a previously established session. - **`function_invocation_kwargs` and MCP servers** - That dict is shared across every tool in the run, including every attached `MCPTool`, and any name in it reaches a server that declares a matching `inputSchema` property. `header_provider` does not mitigate this — it reads the kwargs without consuming them. To keep a credential out of tool arguments, source it outside `function_invocation_kwargs`: read a `ContextVar` inside the provider (this still allows a different value per request), configure a custom `http_client`, or use `env` for `MCPStdioTool`. - **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`. diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 83410bd9d56..d683e99493e 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -642,11 +642,13 @@ def _inject_otel_into_mcp_meta( return meta -def _url_origin(url: Any) -> tuple[str, str, int | None]: +def _url_origin(url: Any) -> tuple[str, str, int]: + if url.scheme not in {"http", "https"} or not url.host: + raise ValueError("MCP URL must be an absolute HTTP(S) URL with a host.") port = url.port if port is None: - port = 443 if url.scheme == "https" else 80 if url.scheme == "http" else None - return (url.scheme, url.host or "", port) + port = 443 if url.scheme == "https" else 80 + return (url.scheme, url.host, port) # Internal polling bounds for MCP long-running tasks. Not user-tunable today; @@ -3479,6 +3481,7 @@ def __init__( sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS, additional_properties: dict[str, Any] | None = None, http_client: AsyncClient | None = None, + static_headers: Mapping[str, str] | None = None, header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None, task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, @@ -3550,8 +3553,8 @@ def __init__( requests are rejected. Resets on reconnect. ``None`` disables it. http_client: Optional asyncClient to use. If not provided, the ``streamable_http_client`` API will create and manage a default client. - To configure headers, timeouts, or other HTTP client settings, create - and pass your own ``asyncClient`` instance. + Use ``static_headers`` for fixed headers. To configure timeouts or other + HTTP client settings, create and pass your own ``asyncClient`` instance. Security: when you attach sensitive headers (e.g. authentication tokens) via a custom ``http_client``, you are responsible for enforcing the same origin-scoped header policy that the built-in ``header_provider`` hook @@ -3561,6 +3564,13 @@ def __init__( client that sets headers unconditionally (e.g. via ``AsyncClient(headers=...)`` or ``follow_redirects=True`` without an origin check) can leak those headers to other origins; scope them to the target origin yourself. + static_headers: Optional fixed HTTP headers to inject into requests to the + configured ``url`` origin. The headers are copied at construction, included + on connection-lifetime requests and tool calls, retained across same-origin + redirects, and removed on cross-origin redirects. Unlike ``header_provider``, + fixed headers do not serialize concurrent tool calls. Use ``header_provider`` + instead when header values depend on runtime invocation arguments. When both + are provided, dynamic headers override fixed headers with the same name. header_provider: Optional callable that receives the runtime keyword arguments (from ``FunctionInvocationContext.kwargs``) and returns a ``dict[str, str]`` of HTTP headers to inject into every outbound request to the MCP server. @@ -3647,6 +3657,7 @@ def __init__( self.url = url self.terminate_on_close = terminate_on_close self._httpx_client: AsyncClient | None = http_client + self._static_headers = dict(static_headers or {}) self._header_provider = header_provider # Headers for the in-flight call_tool invocation. The streamable HTTP transport # sends requests from tasks spawned at connect time, whose contexts never observe @@ -3689,7 +3700,7 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: from httpx import URL, AsyncClient, Timeout http_client = self._httpx_client - if self._header_provider is not None: + if self._static_headers or self._header_provider is not None: target_origin = _url_origin(URL(self.url)) if http_client is None: http_client = AsyncClient( @@ -3709,26 +3720,28 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async for key in request.extensions.pop(_MCP_INJECTED_HEADER_KEYS_EXTENSION, ()): request.headers.pop(key, None) return - # The transport may send this request from a task whose context was - # captured before call_tool set the ContextVar; fall back to the - # instance-level snapshot of the active call's headers. Both are None - # only when this is an ambient request outside call_tool; an active - # call that legitimately produced no headers yields an empty dict and - # must not trigger the ambient fallback below. - headers = _mcp_call_headers.get(None) - if headers is None: - headers = self._active_call_headers - if headers is None: + headers = self._static_headers.copy() + if self._header_provider is not None: + # The transport may send this request from a task whose context was + # captured before call_tool set the ContextVar; fall back to the + # instance-level snapshot of the active call's headers. Both are None + # only when this is an ambient request outside call_tool; an active + # call that legitimately produced no headers yields an empty dict and + # must not trigger the ambient fallback below. + dynamic_headers = _mcp_call_headers.get(None) + if dynamic_headers is None: + dynamic_headers = self._active_call_headers + else: + dynamic_headers = None + if dynamic_headers is None and self._header_provider is not None: # Ambient request made outside call_tool (the initialize handshake, # load_tools/load_prompts discovery, or background pings). Invoke the # provider with the kwargs seeded by the run that established this # connection, so static providers and run-supplied credentials both # authenticate these requests. Provider failures propagate, matching the # call_tool path, except the one case below that no caller can avoid. - if self._header_provider is None: - raise RuntimeError("Header injection hook invoked without a header_provider.") try: - headers = self._header_provider(self._connection_kwargs or {}) + dynamic_headers = self._header_provider(self._connection_kwargs or {}) except KeyError: # Unavoidable only when no run seeded this connection: the provider # wants per-call values a connection-lifetime request cannot have. Once @@ -3742,7 +3755,9 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async self.name, exc_info=True, ) - headers = {} + dynamic_headers = {} + if dynamic_headers is not None: + headers.update(dynamic_headers) for key in request.extensions.pop(_MCP_INJECTED_HEADER_KEYS_EXTENSION, ()): request.headers.pop(key, None) for key, value in headers.items(): diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index a655a9971af..d20f0d67586 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -3968,8 +3968,8 @@ class SecureMCPToolProxy: url: URL of a remote MCP server. When provided, the proxy creates an ``MCPStreamableHTTPTool`` internally. Mutually exclusive with *mcp_tool*. - headers: HTTP headers (e.g. auth tokens) sent with every request - when using *url* mode. + headers: HTTP headers (e.g. auth tokens) sent with requests to the + configured origin when using *url* mode. name: Tool name used when creating the internal ``MCPStreamableHTTPTool`` (defaults to ``"mcp"``). description: Tool description for the internal tool. @@ -4007,8 +4007,8 @@ def __init__( Keyword Args: url: URL of a remote MCP server. When provided, the proxy creates an ``MCPStreamableHTTPTool`` internally. Mutually exclusive with ``mcp_tool``. - headers: HTTP headers (e.g. auth tokens) sent with every request when using - ``url`` mode. + headers: HTTP headers (e.g. auth tokens) sent with requests to the configured + origin when using ``url`` mode. name: Tool name used when creating the internal ``MCPStreamableHTTPTool`` (defaults to ``"mcp"``). description: Tool description for the internal tool. @@ -4031,30 +4031,14 @@ def __init__( raise ValueError("Provide either 'mcp_tool' (an MCPTool instance) or 'url' (a remote MCP server URL).") if url is not None: - from httpx import AsyncClient, Timeout - - from ._mcp import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT, MCPStreamableHTTPTool + from ._mcp import MCPStreamableHTTPTool static_headers = dict(headers or {}) - # Pass headers via an AsyncClient so they are included on ALL requests - # (including session.initialize()), not just tool calls. Using - # header_provider alone only sets headers via a ContextVar that is - # populated during call_tool() and would be empty during initialization, - # causing 401s that silently manifest as anyio cancel-scope errors. - http_client = ( - AsyncClient( - headers=static_headers, - follow_redirects=True, - timeout=Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT), - ) - if static_headers - else None - ) mcp_tool = MCPStreamableHTTPTool( name=name or "mcp", url=url, - http_client=http_client, description=description, + static_headers=static_headers, ) # The validation above guarantees a tool is set (passed directly or built diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index d8b500fde98..4fae7108ab5 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -4772,6 +4772,168 @@ def test_user_identity_to_user_identity_allowed(self): # --------------------------------------------------------------------------- +class TestSecureMCPToolProxyURLMode: + """Tests for origin-scoped headers in the proxy's URL mode.""" + + async def test_static_headers_do_not_serialize_tool_calls(self) -> None: + from unittest.mock import patch + + from agent_framework._mcp import MCPTool + from agent_framework.security import SecureMCPToolProxy + + both_started = asyncio.Event() + started = 0 + + async def overlapping_call(_tool: MCPTool, tool_name: str, **_kwargs: Any) -> str: + nonlocal started + started += 1 + if started == 2: + both_started.set() + await asyncio.wait_for(both_started.wait(), timeout=1) + return tool_name + + proxy = SecureMCPToolProxy( + url="https://mcp.example/mcp", + headers={"Authorization": "auth-value"}, + ) + + with patch.object(MCPTool, "call_tool", overlapping_call): + results = await asyncio.gather( + proxy.mcp_tool.call_tool("first"), + proxy.mcp_tool.call_tool("second"), + ) + + assert results == ["first", "second"] + + async def test_headers_are_sent_only_to_the_configured_origin(self) -> None: + from unittest.mock import patch + + import httpx + + from agent_framework._mcp import _MCPHeaderScopedClient + from agent_framework.security import SecureMCPToolProxy + + configured_headers = { + "Authorization": "auth-value", + "X-API-Key": "api-value", + "Cookie": "session=value", + "Proxy-Authorization": "proxy-value", + "X-Custom-Credential": "custom-value", + } + observed: list[tuple[str, str, dict[str, str | None]]] = [] + connection_methods: list[str] = [] + + async def handle(request: httpx.Request) -> httpx.Response: + observed.append(( + request.url.host, + request.url.path, + {name: request.headers.get(name) for name in configured_headers}, + )) + if request.url.path == "/redirect-start": + return httpx.Response(307, headers={"location": "/redirect-same-origin"}) + if request.url.path == "/redirect-same-origin": + return httpx.Response(307, headers={"location": "https://other.example/redirect-final"}) + if request.url.path == "/redirect-final": + return httpx.Response(200) + if request.url.path == "/loop-a": + return httpx.Response(307, headers={"location": "/loop-b"}) + if request.url.path == "/loop-b": + return httpx.Response(307, headers={"location": "/loop-a"}) + if request.url.path != "/mcp": + return httpx.Response(404) + if request.method == "GET": + return httpx.Response(405) + if request.method == "DELETE": + return httpx.Response(200) + + body = json.loads(request.content) + method = body.get("method") + if isinstance(method, str): + connection_methods.append(method) + response_headers: dict[str, str] = {} + result: dict[str, Any] = {} + if method == "initialize": + response_headers["mcp-session-id"] = "secure-session" + result = { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}, "prompts": {}}, + "serverInfo": {"name": "secure-test", "version": "1"}, + } + elif method == "tools/list": + result = {"tools": []} + elif method == "prompts/list": + result = {"prompts": []} + if "id" not in body: + return httpx.Response(202) + return httpx.Response( + 200, + headers=response_headers, + json={"jsonrpc": "2.0", "id": body["id"], "result": result}, + ) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handle), + follow_redirects=True, + max_redirects=2, + ) as client: + + def create_client(*_args: Any, **kwargs: Any) -> httpx.AsyncClient: + client.headers.update(kwargs.get("headers", {})) + client.follow_redirects = kwargs.get("follow_redirects", client.follow_redirects) + return client + + with patch("httpx.AsyncClient", side_effect=create_client): + proxy = SecureMCPToolProxy( + url="https://mcp.example/mcp", + headers=configured_headers, + ) + async with proxy: + tool = proxy.mcp_tool + transport_client = _MCPHeaderScopedClient(client, tool._header_request_owner) + + response = await transport_client.send( + client.build_request("POST", "https://mcp.example/redirect-start"), + follow_redirects=True, + ) + assert response.status_code == 200 + + with pytest.raises(httpx.TooManyRedirects): + await transport_client.send( + client.build_request("POST", "https://mcp.example/loop-a"), + follow_redirects=True, + ) + + connection_requests = [headers for host, path, headers in observed if host == "mcp.example" and path == "/mcp"] + assert connection_requests + assert all(headers == configured_headers for headers in connection_requests) + assert {"initialize", "tools/list"}.issubset(connection_methods) + + redirect_requests = [entry for entry in observed if "redirect" in entry[1]] + assert redirect_requests == [ + ("mcp.example", "/redirect-start", configured_headers), + ("mcp.example", "/redirect-same-origin", configured_headers), + ("other.example", "/redirect-final", dict.fromkeys(configured_headers)), + ] + + loop_requests = [entry for entry in observed if entry[1].startswith("/loop-")] + assert loop_requests + assert all(host == "mcp.example" and headers == configured_headers for host, _, headers in loop_requests) + + @pytest.mark.parametrize("url", ["not-a-url", "ftp://mcp.example/path", "https:///missing-host"]) + def test_headers_require_an_absolute_http_origin(self, url: str) -> None: + from unittest.mock import patch + + from agent_framework.security import SecureMCPToolProxy + + with patch("httpx.AsyncClient") as create_client: + proxy = SecureMCPToolProxy(url=url, headers={"X-Custom-Credential": "custom-value"}) + + with pytest.raises(ValueError, match="absolute HTTP.*URL with a host"): + proxy.mcp_tool.get_mcp_client() + + create_client.assert_not_called() + + class TestMCPAnnotationMapping: """Tests for hint-based mapping from MCP annotations to FIDES labels.""" From 14b9f1d7e9a440e247a17fc8d47be0b69e554d8a Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 11 Sep 2026 16:38:57 +0000 Subject: [PATCH 027/108] .NET: fix: race concdition in workflow formula state (#8252) * fix: race concdition in workflow formula state * fix: build issue with key --- .../DeclarativeWorkflowBuilder.cs | 1 + .../Interpreter/DeclarativeActionExecutor.cs | 1 + .../DeclarativeWorkflowExecutor.cs | 5 + .../Interpreter/DelegateActionExecutor.cs | 1 + .../PowerFx/WorkflowFormulaState.cs | 45 ++++++- .../DeclarativeWorkflowTest.cs | 121 ++++++++++++++++++ 6 files changed, 173 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs index aeb3d2b6e95..054aa38b237 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DeclarativeWorkflowBuilder.cs @@ -74,6 +74,7 @@ public static Workflow Build( WorkflowFormulaState state = new(options.CreateRecalcEngine()); state.Initialize(workflowElement.WrapWithBot(), options.Configuration); + state.CaptureInitialState(); DeclarativeWorkflowExecutor rootExecutor = new(rootId, options, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs index 776caebda60..5d052c64d3d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeActionExecutor.cs @@ -64,6 +64,7 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui /// public virtual ValueTask ResetAsync() { + this._state.Reset(); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs index 053f28c89ac..1126b43d8ff 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DeclarativeWorkflowExecutor.cs @@ -42,9 +42,14 @@ internal sealed class DeclarativeWorkflowExecutor( /// public ValueTask ResetAsync() { + state.Reset(); return default; } + /// + protected override ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) => + state.RestoreAsync(context, cancellationToken); + /// [SendsMessage(typeof(ActionExecutorResult))] public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs index 0f51eacc7c1..716a3656dac 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Interpreter/DelegateActionExecutor.cs @@ -52,6 +52,7 @@ protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBui /// public ValueTask ResetAsync() { + this._state.Reset(); return default; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs index c739cb3bf95..aaff60b08b4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowFormulaState.cs @@ -29,6 +29,8 @@ internal sealed class WorkflowFormulaState private readonly Dictionary _scopes; + private Dictionary _initialScopes; + private int _isInitialized; public RecalcEngine Engine { get; } @@ -38,6 +40,7 @@ internal sealed class WorkflowFormulaState public WorkflowFormulaState(RecalcEngine engine) { this._scopes = VariableScopeNames.AllScopes.ToDictionary(scopeName => GetScopeName(scopeName), _ => new WorkflowScope()); + this._initialScopes = this.CreateScopeSnapshot(); this.Engine = engine; this.Evaluator = new WorkflowExpressionEngine(engine); @@ -61,6 +64,18 @@ public void Set(string variableName, FormulaValue value, string? scopeName = nul public bool SetInitialized() => Interlocked.CompareExchange(ref this._isInitialized, 1, 0) == 0; + public void CaptureInitialState() + { + this._initialScopes = this.CreateScopeSnapshot(); + } + + public void Reset() + { + this.RestoreInitialState(); + Interlocked.Exchange(ref this._isInitialized, 0); + this.Bind(); + } + public async ValueTask RestoreAsync(IWorkflowContext context, CancellationToken cancellationToken) { if (!this.SetInitialized()) @@ -68,6 +83,8 @@ public async ValueTask RestoreAsync(IWorkflowContext context, CancellationToken return; } + this.RestoreInitialState(); + Stopwatch timer = Stopwatch.StartNew(); Debug.WriteLine("RESTORE CHECKPOINT - BEGIN"); await Task.WhenAll(RestorableScopes.Select(scopeName => ReadScopeAsync(scopeName))).ConfigureAwait(false); @@ -93,6 +110,22 @@ async Task ReadScopeAsync(string scopeName) } } + private Dictionary CreateScopeSnapshot() => + this._scopes.ToDictionary(scope => scope.Key, scope => new WorkflowScope(scope.Value)); + + private void RestoreInitialState() + { + foreach (KeyValuePair initialScopeEntry in this._initialScopes) + { + WorkflowScope scope = this._scopes[initialScopeEntry.Key]; + scope.Clear(); + foreach (KeyValuePair initialValueEntry in initialScopeEntry.Value) + { + scope[initialValueEntry.Key] = initialValueEntry.Value; + } + } + } + public void Bind(string? scopeNameToBind = null) { if (scopeNameToBind is not null) @@ -143,5 +176,15 @@ public static string GetScopeName(string? scopeName) /// /// The set of variables for a specific action scope. /// - private sealed class WorkflowScope : Dictionary; + private sealed class WorkflowScope : Dictionary + { + public WorkflowScope() + { + } + + public WorkflowScope(IDictionary values) + : base(values) + { + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index f01b91e5e1a..7bb92f913c2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -79,6 +79,52 @@ public async Task EndConversationActionAsync() this.AssertNotExecuted("sendActivity_1"); } + [Fact] + public async Task HostedWorkflowAgentIsolatesDeclarativeStateBySessionAsync() + { + // Arrange + RecordingAgentProvider provider = new(); + AIAgent agent = CreateStateEchoWorkflow(provider).AsAIAgent(id: "host", name: "host"); + AgentSession aliceSession = await agent.CreateSessionAsync(); + AgentSession mallorySession = await agent.CreateSessionAsync(); + + // Act + AgentResponse aliceSeedResponse = await agent.RunAsync("EMBER-QUARTZ-7319", aliceSession); + AgentResponse aliceResumeResponse = await agent.RunAsync("inspect-alice", aliceSession); + AgentResponse malloryInspectResponse = await agent.RunAsync("inspect-mallory", mallorySession); + _ = await agent.RunAsync("ONYX-CEDAR-4826", mallorySession); + AgentResponse aliceFinalResponse = await agent.RunAsync("inspect-alice-again", aliceSession); + + // Assert + Assert.NotSame(aliceSession, mallorySession); + Assert.Contains("Marker: \"\"", aliceSeedResponse.Text, StringComparison.Ordinal); + Assert.Contains("EMBER-QUARTZ-7319", aliceResumeResponse.Text, StringComparison.Ordinal); + Assert.DoesNotContain("EMBER-QUARTZ-7319", malloryInspectResponse.Text, StringComparison.Ordinal); + Assert.DoesNotContain("ONYX-CEDAR-4826", aliceFinalResponse.Text, StringComparison.Ordinal); + Assert.Contains("inspect-alice", aliceFinalResponse.Text, StringComparison.Ordinal); + + Assert.True(provider.MessageConversations.Count >= 3); + Assert.Equal(provider.MessageConversations[0], provider.MessageConversations[1]); + Assert.NotEqual(provider.MessageConversations[0], provider.MessageConversations[2]); + } + + [Fact] + public async Task HostedWorkflowAgentIsolatesDeclarativeStateForImplicitSessionsAsync() + { + // Arrange + RecordingAgentProvider provider = new(); + AIAgent agent = CreateStateEchoWorkflow(provider).AsAIAgent(id: "host", name: "host"); + + // Act + _ = await agent.RunAsync("EMBER-QUARTZ-7319"); + AgentResponse secondImplicitResponse = await agent.RunAsync("inspect-implicit"); + + // Assert + Assert.DoesNotContain("EMBER-QUARTZ-7319", secondImplicitResponse.Text, StringComparison.Ordinal); + Assert.True(provider.MessageConversations.Count >= 2); + Assert.NotEqual(provider.MessageConversations[0], provider.MessageConversations[1]); + } + [Fact] public async Task GotoActionAsync() { @@ -386,6 +432,81 @@ private Workflow CreateWorkflow(string workflowPath, TInput workflowInpu return DeclarativeWorkflowBuilder.Build(yamlReader, workflowContext); } + private static Workflow CreateStateEchoWorkflow(ResponseAgentProvider provider) + { + using StringReader yamlReader = new( + """ + kind: Workflow + trigger: + + kind: OnConversationStart + id: state_echo_workflow + actions: + + - kind: SendActivity + id: show_marker + activity: |- + Marker: "{Local.Marker}" + + - kind: SetVariable + id: set_marker + variable: Local.Marker + value: =System.LastMessageText + """); + DeclarativeWorkflowOptions options = new(provider); + + return DeclarativeWorkflowBuilder.Build(yamlReader, options); + } + + private sealed class RecordingAgentProvider : ResponseAgentProvider + { + public List MessageConversations { get; } = []; + + private int _conversationCount; + + public override Task CreateConversationAsync(CancellationToken cancellationToken = default) => + Task.FromResult($"conversation-{Interlocked.Increment(ref this._conversationCount):D2}"); + + public override Task CreateMessageAsync( + string conversationId, + ChatMessage conversationMessage, + CancellationToken cancellationToken = default) + { + this.MessageConversations.Add(conversationId); + return Task.FromResult(conversationMessage); + } + + public override Task GetMessageAsync( + string conversationId, + string messageId, + CancellationToken cancellationToken = default) => + Task.FromResult(new ChatMessage(ChatRole.Assistant, string.Empty) { MessageId = messageId }); + + public override async IAsyncEnumerable InvokeAgentAsync( + string agentId, + string? agentVersion, + string? conversationId, + IEnumerable? messages, + IDictionary? inputArguments, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + + public override async IAsyncEnumerable GetMessagesAsync( + string conversationId, + int? limit = null, + string? after = null, + string? before = null, + bool newestFirst = false, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + } + private static Mock CreateMockProvider(string input) { Mock mockAgentProvider = new(MockBehavior.Strict); From a096549746f22fe0a3e7cdb8ad359adca61cf89c Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 11 Sep 2026 18:33:03 +0000 Subject: [PATCH 028/108] .NET: update remaining dependencies (#8307) * chore: update Microsoft.Testing.Extensions.CodeCoverage to version 18.11.2 Signed-off-by: Vincent Biret * chore: update Microsoft.Identity.Client.Extensions.Msal to version 4.89.0 Signed-off-by: Vincent Biret * chore: update Microsoft.Agents.AI.Foundry to version 1.5.0 Signed-off-by: Vincent Biret * chore: update Google.GenAI to version 1.21.0 Signed-off-by: Vincent Biret * chore: update Microsoft.ML.OnnxRuntimeGenAI to version 0.15.2 Signed-off-by: Vincent Biret * chore: update Microsoft.Agents.CopilotStudio.Client and related packages to version 1.9.35-beta Signed-off-by: Vincent Biret * chore: update Microsoft.Agents.AI to version 1.20.0 and Microsoft.Extensions.Hosting to version 10.0.12 Signed-off-by: Vincent Biret * chore: update AgentFrameworkVersion to 1.20.0-preview.260831.1 Signed-off-by: Vincent Biret * chore: update AgentFrameworkVersion to 1.20.0 and Azure.AI.AgentServer.Invocations to version 1.0.0-beta.7 Signed-off-by: Vincent Biret * chore: update Aspire.Azure.AI.OpenAI to version 13.5.3-preview.1.26425.3 Signed-off-by: Vincent Biret * chore: update Mscc.GenerativeAI.Microsoft to version 3.1.0 Signed-off-by: Vincent Biret * chore: update Microsoft.CodeAnalysis.Analyzers and Microsoft.CodeAnalysis.CSharp to version 5.9.0 Signed-off-by: Vincent Biret * chore: update Neo4j.Driver to version 6.3.0 Signed-off-by: Vincent Biret * chore: update System.CommandLine to version 2.0.12 Signed-off-by: Vincent Biret * chore: downgrade Neo4j.Driver to version 5.28.0 Signed-off-by: Vincent Biret * chore: Downgrade Microsoft.Agents.AI package version to maintain alignment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Signed-off-by: Vincent Biret Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 24 +++++++++---------- ...emory_Step06_MemoryUsingAgentMemory.csproj | 2 +- .../AgentWithRAG_Step05_Neo4jGraphRAG.csproj | 3 ++- .../Hosted-Invocations-EchoAgent.csproj | 4 ++-- .../HostedLocalCodeAct.csproj | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index c4e154e75e7..806eef9ebde 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -14,7 +14,7 @@ - + @@ -35,8 +35,8 @@ - - + + @@ -49,7 +49,7 @@ - + @@ -110,11 +110,11 @@ - + - - + + @@ -126,12 +126,12 @@ - + - + @@ -151,13 +151,13 @@ - + - - + + diff --git a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj index 8373e63ef0e..e46226783cd 100644 --- a/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj +++ b/dotnet/samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj @@ -44,7 +44,7 @@ - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj index 8162f9a84fa..cb95a06f290 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj @@ -27,12 +27,12 @@ HostedInvocationsEchoAgent HostedInvocationsEchoAgent 27e5c7df-546c-477b-ab05-d1e070a1b78a - 1.15.0 + 1.20.0 - + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj index 23f1e667125..250ecc025f7 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalCodeAct/HostedLocalCodeAct.csproj @@ -27,7 +27,7 @@ HostedLocalCodeAct HostedLocalCodeAct dd574e14-75e7-41f0-8ee6-b7c62d6952cb - 1.17.0-preview.260804.1 + 1.20.0-preview.260831.1 From e844e8f5a00719ba19d3f5e93a1b43ba8436be7e Mon Sep 17 00:00:00 2001 From: Vincent Biret Date: Fri, 11 Sep 2026 18:35:12 +0000 Subject: [PATCH 029/108] .NET: fix: inspect for delemiter value before adding headers (#8301) * fix: inspect for delemiter value before adding headers * chore: Simplify error message for invalid HTTP header name Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test: Cover invalid body content type rejection Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> * fix: address CRLF test build issue Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 241dc909-4fc9-4e7c-8f7c-fb969e4ce2e9 --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baywet <7905502+baywet@users.noreply.github.com> Copilot-Session: 241dc909-4fc9-4e7c-8f7c-fb969e4ce2e9 --- .../DefaultHttpRequestHandler.cs | 24 +++ .../DefaultHttpRequestHandlerTests.cs | 141 ++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs index 72ad1d57409..4ef1d80c9cd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/DefaultHttpRequestHandler.cs @@ -230,6 +230,7 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques httpRequest.Content = new StringContent(request.Body, Encoding.UTF8); // Replace the default content-type header (including charset) with the declared type. httpRequest.Content.Headers.Remove("Content-Type"); + ValidateHeaderValue("Content-Type", contentType); httpRequest.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); } @@ -242,6 +243,8 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques continue; } + ValidateHeader(header.Key, header.Value); + // Content-* headers belong on HttpContent; all others belong on the request. if (header.Key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && httpRequest.Content is not null) { @@ -260,6 +263,27 @@ private static HttpRequestMessage BuildHttpRequestMessage(HttpRequestInfo reques return httpRequest; } + private static void ValidateHeader(string name, string value) + { + if (ContainsHttpHeaderDelimiter(name)) + { + throw new ArgumentException("HTTP header name contains invalid characters.", nameof(name)); + } + + ValidateHeaderValue(name, value); + } + + private static void ValidateHeaderValue(string name, string value) + { + if (ContainsHttpHeaderDelimiter(value)) + { + throw new ArgumentException($"HTTP header '{name}' contains invalid characters.", nameof(value)); + } + } + + private static bool ContainsHttpHeaderDelimiter(string value) => + value.IndexOfAny(['\r', '\n', '\0']) >= 0; + private static HttpClient CreateOwnedHttpClient() { HttpClientHandler handler = new() diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs index 33dbfd17cef..d4669923a84 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DefaultHttpRequestHandlerTests.cs @@ -5,6 +5,7 @@ using System.IO; using System.Net; using System.Net.Http; +using System.Net.Sockets; using System.Reflection; using System.Text; using System.Threading; @@ -257,6 +258,57 @@ public async Task SendAsyncAppliesRequestHeadersAsync() Assert.Contains(messageHandler.LastRequest.Headers.Accept, mediaType => mediaType.MediaType == "application/json"); } + [Fact] + public async Task SendAsyncRejectsHeaderValuesContainingCrlfBeforeSendingAsync() + { + // Arrange + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + await using RawHttpServer server = new(); + using HttpClient httpClient = new(); + await using DefaultHttpRequestHandler handler = new(httpClient); + HttpRequestInfo request = new() + { + Method = "GET", + Url = server.Url, + Headers = new Dictionary + { + ["X-User-Note"] = "safe\r\n\r\nDELETE /admin HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + }, + }; + + // Act + Exception? exception = await Record.ExceptionAsync(() => handler.SendAsync(request, cancellationToken)); + string? rawRequest = await server.TryReadRequestAsync(TimeSpan.FromMilliseconds(500)); + + // Assert + Assert.Null(rawRequest); + Assert.IsType(exception); + } + + [Fact] + public async Task SendAsyncRejectsBodyContentTypeContainingCrlfBeforeSendingAsync() + { + // Arrange + TestHttpMessageHandler messageHandler = new((_, _) => + throw new InvalidOperationException("The request should be rejected before transport.")); + using HttpClient httpClient = new(messageHandler); + await using DefaultHttpRequestHandler handler = new(httpClient); + HttpRequestInfo request = new() + { + Method = "POST", + Url = TestUrl, + Body = "safe", + BodyContentType = "text/plain\r\nX-Injected: value", + }; + + // Act + async Task actAsync() => await handler.SendAsync(request); + + // Assert + await Assert.ThrowsAsync(actAsync); + Assert.Null(messageHandler.LastRequest); + } + [Fact] public async Task SendAsyncRoutesContentHeadersToBodyAsync() { @@ -1074,6 +1126,95 @@ protected override async Task SendAsync(HttpRequestMessage } } + private sealed class RawHttpServer : IAsyncDisposable + { + private readonly TcpListener _listener; + private readonly Task _rawRequestTask; + + public RawHttpServer() + { + this._listener = new TcpListener(IPAddress.Loopback, 0); + this._listener.Start(); + int port = ((IPEndPoint)this._listener.LocalEndpoint).Port; + this.Url = $"http://127.0.0.1:{port}/public"; + this._rawRequestTask = Task.Run(this.AcceptAndRespond); + } + + public string Url { get; } + + public async Task TryReadRequestAsync(TimeSpan timeout) + { + Task completedTask = await Task.WhenAny(this._rawRequestTask, Task.Delay(timeout)).ConfigureAwait(false); + return completedTask == this._rawRequestTask + ? await this._rawRequestTask.ConfigureAwait(false) + : null; + } + + public async ValueTask DisposeAsync() + { +#if NET + this._listener.Dispose(); +#else + this._listener.Stop(); +#endif + await this._rawRequestTask.ConfigureAwait(false); + } + + private string? AcceptAndRespond() + { + TcpClient client; + try + { + client = this._listener.AcceptTcpClient(); + } + catch (SocketException) + { + return null; + } + catch (ObjectDisposedException) + { + return null; + } + + using (client) + { + client.ReceiveTimeout = 250; + using NetworkStream stream = client.GetStream(); + using MemoryStream rawRequest = new(); + byte[] buffer = new byte[1024]; + + while (true) + { + int bytesRead; + try + { + bytesRead = stream.Read(buffer, 0, buffer.Length); + } + catch (IOException) + { + break; + } + + if (bytesRead == 0) + { + break; + } + + rawRequest.Write(buffer, 0, bytesRead); + string currentRequest = Encoding.ASCII.GetString(rawRequest.ToArray()); + if (currentRequest.Contains("DELETE /admin", StringComparison.Ordinal)) + { + break; + } + } + + byte[] responseBytes = Encoding.ASCII.GetBytes("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"); + stream.Write(responseBytes, 0, responseBytes.Length); + return Encoding.ASCII.GetString(rawRequest.ToArray()); + } + } + } + private sealed class TrackingContent : HttpContent { private readonly string _content; From 393fbf26184495f6d13d276123a5ecb07f91e044 Mon Sep 17 00:00:00 2001 From: mrchatam Date: Fri, 11 Sep 2026 23:47:37 +0000 Subject: [PATCH 030/108] docs: fix 'availble' typo in ADR 0001 (#8325) Co-authored-by: mrchatam --- docs/decisions/0001-agent-run-response.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0001-agent-run-response.md b/docs/decisions/0001-agent-run-response.md index 68626cb1818..bdecde7743b 100644 --- a/docs/decisions/0001-agent-run-response.md +++ b/docs/decisions/0001-agent-run-response.md @@ -200,7 +200,7 @@ var primaryContentOnly = response.Messages.FirstOrDefault(); ``` - **PROS**: Simple getting started experience, Reusing IChatClient response types. -- **CONS**: Intermediate updates are only availble in streaming mode. +- **CONS**: Intermediate updates are only available in streaming mode. ### Option 4: Remove Run API and retain RunStreaming API only, which returns a Stream of Primary + Secondary From f30187e974cb6de8f1a346b3b8f431a5365aa221 Mon Sep 17 00:00:00 2001 From: mrchatam Date: Fri, 11 Sep 2026 23:49:28 +0000 Subject: [PATCH 031/108] docs: use 'a FunctionApprovalRequestContent' in ADR 0006 (#8322) Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> --- docs/decisions/0006-userapproval.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0006-userapproval.md b/docs/decisions/0006-userapproval.md index a2e24c76fdd..747708660c2 100644 --- a/docs/decisions/0006-userapproval.md +++ b/docs/decisions/0006-userapproval.md @@ -390,7 +390,7 @@ Chosen option 5. 1. Agent calls IChatClient with any functions registered on the agent. (IChatClient has FunctionInvokingChatClient) 1. Model responds with FunctionCallContent indicating function calls required. -1. FunctionInvokingChatClient decorator identifies any function calls that require user approval and returns an FunctionApprovalRequestContent. +1. FunctionInvokingChatClient decorator identifies any function calls that require user approval and returns a FunctionApprovalRequestContent. (If there are multiple parallel function calls, all function calls will be returned as FunctionApprovalRequestContent even if only some require approval.) 1. Agent updates the thread with the FunctionApprovalRequestContent (or this may have already been done by a service threaded agent). 1. Agent returns the FunctionApprovalRequestContent to the caller which shows it to the user in the appropriate format. From ebaa4033cee9d0d0e6bb2929bdb400df66d8a111 Mon Sep 17 00:00:00 2001 From: mrchatam Date: Fri, 11 Sep 2026 23:51:26 +0000 Subject: [PATCH 032/108] =?UTF-8?q?docs:=20fix=20'older=20then'=20?= =?UTF-8?q?=E2=86=92=20'older=20than'=20in=20ADR=200019=20(#8321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> --- docs/decisions/0019-python-context-compaction-strategy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0019-python-context-compaction-strategy.md b/docs/decisions/0019-python-context-compaction-strategy.md index 8fffb185d1a..1cdd15bb021 100644 --- a/docs/decisions/0019-python-context-compaction-strategy.md +++ b/docs/decisions/0019-python-context-compaction-strategy.md @@ -1044,7 +1044,7 @@ How each option addresses the three primary compaction points and the current ar A compaction strategy takes a list of messages and returns a (potentially shorter) list, in almost all cases, there is certain logic that needs to be applied universally, such as retaining system messages, not breaking up function call and result pairs (for Responses that includes Reasoning as well, see [context section above](#message-list-correctness-constraint-atomic-group-preservation) for more info) as tool calls, etc. Beyond that, strategies can be as simple or complex as needed: - **Truncation**: Keep only the last N messages or N tokens, this is a likely done as a kind of zigzag, where the history grows, then get's truncated to some value below the token limit, then grows again, etc. This can be done on a simple message count basis, a character count basis, or more complex token counting basis. -- **Summarization**: Replace older messages with an LLM-generated summary (depending on the implementation this could be done, by replacing the summarized messages, or by inserting a summary message in between and not loading messages older then the summarized ones) +- **Summarization**: Replace older messages with an LLM-generated summary (depending on the implementation this could be done, by replacing the summarized messages, or by inserting a summary message in between and not loading messages older than the summarized ones) - **Selective removal**: Remove tool call/result pairs while keeping user/assistant turns - **Sliding window with anchor**: Keep system message + last N messages - **Custom logic**: The design should be extendible so that users can implement their own strategies. From ff15faf50578c4f04bb563eee9d4942a678b23ab Mon Sep 17 00:00:00 2001 From: mrchatam Date: Fri, 11 Sep 2026 23:52:12 +0000 Subject: [PATCH 033/108] docs: fix 'valueable' typo in ADR 0001 (#8318) Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> --- docs/decisions/0001-agent-run-response.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0001-agent-run-response.md b/docs/decisions/0001-agent-run-response.md index bdecde7743b..d98ad63634e 100644 --- a/docs/decisions/0001-agent-run-response.md +++ b/docs/decisions/0001-agent-run-response.md @@ -406,7 +406,7 @@ We need to consider abstractions for `AIContent` derived types for tool call res ## StructuredOutputs -Structured outputs is a valueable aspect of any Agent system, since it forces an Agent to produce output in a required format, and may include required fields. This allows turning unstructured data into structured data easily using a general purpose language model. +Structured outputs is a valuable aspect of any Agent system, since it forces an Agent to produce output in a required format, and may include required fields. This allows turning unstructured data into structured data easily using a general purpose language model. Not all agent types necessarily support this or necessarily support this in the same way. Requesting a specific output schema at invocation time is widely supported by inference services though, and therefore inference based agents would support this well. From 3ccb3aff1e8664101aea761da86f2510f75b5f7d Mon Sep 17 00:00:00 2001 From: mrchatam Date: Fri, 11 Sep 2026 23:52:43 +0000 Subject: [PATCH 034/108] docs: remove duplicate 'turns' in ADR 0006 diagram note (#8317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "turns turns" → "turns". Co-authored-by: mrchatam --- docs/decisions/0006-userapproval.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0006-userapproval.md b/docs/decisions/0006-userapproval.md index 747708660c2..4b59b104f8c 100644 --- a/docs/decisions/0006-userapproval.md +++ b/docs/decisions/0006-userapproval.md @@ -510,7 +510,7 @@ sequenceDiagram note right of Developer: Developer approves one function call and rejects the other. Developer->>+ApprovalGeneratingChatClient: [FunctionApprovalResponseContent(GetMenu, approved=true)]
[FunctionApprovalResponseContent(GetSpecials, approved=false)] - note right of ApprovalGeneratingChatClient: AGCC turns turns approval requests
into FCC or failed function calls + note right of ApprovalGeneratingChatClient: AGCC turns approval requests
into FCC or failed function calls ApprovalGeneratingChatClient->>+FunctionInvokingChatClient: [FunctionCallContent(GetMenu)]
[FunctionCallContent(GetSpecials)
[FunctionResultContent(GetSpecials, "Function invocation denied"))] note right of FunctionInvokingChatClient: FICC invokes GetMenu since it's the only remaining one. FunctionInvokingChatClient->>+ResponseChatClient: [FunctionCallContent(GetMenu)]
[FunctionResultContent(GetMenu, "mains.... deserts...")]
[FunctionCallContent(GetSpecials)
[FunctionResultContent(GetSpecials, "Function invocation denied"))] From 3c670707766a8455da6491a9049cc9d575e019f0 Mon Sep 17 00:00:00 2001 From: mrchatam Date: Fri, 11 Sep 2026 23:53:32 +0000 Subject: [PATCH 035/108] docs: fix 'extention' typo in ADR 0001 (#8316) extension, not extention. Co-authored-by: mrchatam --- docs/decisions/0001-agent-run-response.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0001-agent-run-response.md b/docs/decisions/0001-agent-run-response.md index d98ad63634e..e8a5ac0776a 100644 --- a/docs/decisions/0001-agent-run-response.md +++ b/docs/decisions/0001-agent-run-response.md @@ -212,7 +212,7 @@ we add helpers to process the streaming responses and extract information from i var responses = agent.RunStreamingAsync("Do Something"); // E.g. an extension method that builds the primary content text. Console.WriteLine(await responses.AggregateFinalResult()); -// Or an extention method that builds complete messages from the updates. +// Or an extension method that builds complete messages from the updates. Console.WriteLine(await responses.BuildMessage().Text); // Callers can also iterate through all updates if needed From df03d32151dc56a06429fa5d9c33fc77cecad157 Mon Sep 17 00:00:00 2001 From: mrchatam Date: Sun, 13 Sep 2026 12:29:49 +0000 Subject: [PATCH 036/108] Python: docs: use 'an Azure' in ADR 0008 (#8324) * Python: docs: use 'an Azure' in ADR 0008 * docs: use 'implements' for singular azure-purview extra Address review feedback on ADR 0008. --------- Co-authored-by: mrchatam --- docs/decisions/0008-python-subpackages.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0008-python-subpackages.md b/docs/decisions/0008-python-subpackages.md index fdd5a792b26..fc2db44f807 100644 --- a/docs/decisions/0008-python-subpackages.md +++ b/docs/decisions/0008-python-subpackages.md @@ -78,7 +78,7 @@ The goal is to design a subpackage structure for the Python agent framework that - Integrations that are mature and that have released (GA) dependencies and or features on the service side will be moved into the main package, the dependencies of those packages will stay installable under the same `extra` name, so that users do not have to change anything, and we then remove the subpackage itself. - All subpackage imports in the code should be from a stable place, mostly vendor-based, so that when something moves from a subpackage to the main package, the import path does not change, so `from agent_framework.google import GoogleChatClient` will always work, even if it moves from the `agent-framework-google` package to the main `agent-framework` package. - The imports in those vendor namespaces (these won't be actual python namespaces, just the folders with a __init__.py file and any code) will do lazy loading and raise a meaningful error if the subpackage or dependencies are not installed, so that users know which extra to install with ease. - - On a case by case basis we can decide to create additional `extras`, that combine multiple subpackages into one extra, so that users that work primarily with one platform can install everything they need with a single extra, for instance you can install with the `agent-framework[azure-purview]` extra that only implement a Azure Purview Middleware, or you can install with the `agent-framework[azure]` extra that includes all Azure related connectors, like `purview`, `content safety` and others (all examples, not actual packages (yet)), regardless of where the code sits, these should always be importable from `agent_framework.azure`. + - On a case by case basis we can decide to create additional `extras`, that combine multiple subpackages into one extra, so that users that work primarily with one platform can install everything they need with a single extra, for instance you can install with the `agent-framework[azure-purview]` extra that only implements an Azure Purview Middleware, or you can install with the `agent-framework[azure]` extra that includes all Azure related connectors, like `purview`, `content safety` and others (all examples, not actual packages (yet)), regardless of where the code sits, these should always be importable from `agent_framework.azure`. - Subpackage naming should also follow this, so in principle a package name is `-`, so `google-gemini`, `azure-purview`, `microsoft-copilotstudio`, etc. For smaller vendors, with less likely to have a multitude of connectors, we can skip the feature/brand part, so `mem0`, `redis`, etc. ## Decision Outcome From 1cd06c5a2058a172eebadf5d9d7c3fa45c519520 Mon Sep 17 00:00:00 2001 From: mrchatam Date: Sun, 13 Sep 2026 12:34:50 +0000 Subject: [PATCH 037/108] docs: fix 'allow to' grammar in ADR 0011 (#8335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "will allow to create" → "will allow creating". Co-authored-by: mrchatam --- docs/decisions/0011-create-get-agent-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0011-create-get-agent-api.md b/docs/decisions/0011-create-get-agent-api.md index 4703c1271d2..cd008fc7b7a 100644 --- a/docs/decisions/0011-create-get-agent-api.md +++ b/docs/decisions/0011-create-get-agent-api.md @@ -107,7 +107,7 @@ var agent3 = new AssistantClient(...).CreateAIAgent(...); // Creates a remote ag ### Possible Python implementations -Methods like `create_agent` and `get_agent` should be implemented separately or defined on some stateless component that will allow to create multiple agents from the same instance/place. +Methods like `create_agent` and `get_agent` should be implemented separately or defined on some stateless component that will allow creating multiple agents from the same instance/place. Possible options: From 926b4ecacdaa95cb909c9d08f77b4fd898f273c0 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:45:40 +0000 Subject: [PATCH 038/108] Python: Reserve HA session id as reserved for AGUI (#8199) * Reserve HA session id as reserved for AGUI * Fix build error --- python/packages/ag-ui/AGENTS.md | 10 ++ .../ag-ui/agent_framework_ag_ui/_agent_run.py | 29 +++- python/packages/ag-ui/tests/ag_ui/test_run.py | 86 ++++++++++++ .../foundry/agent_framework_foundry/_agent.py | 8 ++ .../agent_framework_foundry/_constants.py | 7 + .../tests/foundry/test_foundry_agent.py | 132 +++++++++++++++++- 6 files changed, 265 insertions(+), 7 deletions(-) diff --git a/python/packages/ag-ui/AGENTS.md b/python/packages/ag-ui/AGENTS.md index 59bb2cfc17c..683db67bdda 100644 --- a/python/packages/ag-ui/AGENTS.md +++ b/python/packages/ag-ui/AGENTS.md @@ -59,6 +59,16 @@ AG-UI protocol integration for building agent UIs with the AG-UI standard. migrations and must warn because it disables that isolation; it is unsafe for shared multi-tenant deployments. A configured endpoint resolver must return a non-empty string. Reject invalid results before accessing state or invoking the runner; absence of a resolver, not an invalid result, selects intentionally unscoped operation. +- Request `state` is client-owned Shared State and is merged into `AgentSession.state`, minus a protected set: + tool-approval state, history/context-provider namespaces, message-injection state, and provider-owned keys. + Agents declare the latter through a `service_session_state_keys` attribute, but that resolves against the agent + object AG-UI is handed, so a wrapper agent that does not forward it would silently drop the protection. + `_RESERVED_SERVICE_SESSION_STATE_KEYS` therefore reserves such keys unconditionally; add a key there whenever + it names a remote resource that the server's own credentialed call addresses. +- Provider-owned keys identify remote resources that the server's own credentialed call addresses — for example + the Foundry hosted-agent session ID, which selects a VM-isolated sandbox with a persistent filesystem. Never let + request state choose one. `AgentSession.service_session_id` is deliberately a separate attribute rather than a + `state` entry, so conversation continuation is unreachable from client input by construction; keep it that way. - `confirm_changes` snapshot cleanup resolves the synthetic confirmation back to its original `function_call_id`; it must never concatenate unrelated tool results or record accepted changes without a matching real result. - SSE keepalive is endpoint-owned transport behavior configured through diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index eece20fdcc6..e6ecb54a82d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -8,7 +8,7 @@ import json import logging import uuid -from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence +from collections.abc import AsyncIterable, Awaitable, Collection, Mapping, Sequence from dataclasses import dataclass, field from functools import partial from typing import TYPE_CHECKING, Any, cast @@ -139,6 +139,18 @@ _COLLECTED_APPROVAL_RESPONSES_KEY = "collected_approval_responses" _PROVIDER_SERVICE_SESSION_ID_STATE_KEY = "__ag_ui_provider_service_session_id" +# Provider-owned session-state keys reserved unconditionally, independent of what the agent declares through +# ``service_session_state_keys``. Agents are expected to declare their own keys, but that resolves through the +# agent object AG-UI is handed, so a wrapper agent that does not forward the attribute would silently drop the +# protection. Keys naming a remote resource that the server's own credentialed call addresses are reserved here +# instead. The Foundry key is spelled literally rather than imported, because agent-framework-ag-ui does not +# depend on agent-framework-foundry and must protect the key even when it is absent or outdated. +# +# agent_framework_foundry.FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY. Selects the Foundry hosted-agent runtime session, +# a VM-isolated sandbox with a persistent filesystem, so a client-supplied value would run the server's +# credentialed call inside another session's sandbox. +_RESERVED_SERVICE_SESSION_STATE_KEYS: frozenset[str] = frozenset({"foundry_hosted_agent_session_id"}) + @dataclass class _LocalApprovalOccurrence: @@ -2324,11 +2336,16 @@ def _request_state_protected_keys(agent: SupportsAgentRun) -> set[str]: def _provider_service_session_state_keys(agent: SupportsAgentRun) -> set[str]: - """Return provider-owned session-state keys that must not cross stateless runs.""" - keys = getattr(agent, "service_session_state_keys", ()) - if not isinstance(keys, (list, tuple, set, frozenset)): - return set() - return {key for key in keys if isinstance(key, str)} + """Return provider-owned session-state keys that must not cross stateless runs. + + Always includes :data:`_RESERVED_SERVICE_SESSION_STATE_KEYS`, so the reserved keys stay server-owned even + when ``agent`` declares nothing, such as a wrapper agent that does not forward the attribute. + """ + keys = set(_RESERVED_SERVICE_SESSION_STATE_KEYS) + declared = getattr(agent, "service_session_state_keys", ()) + if isinstance(declared, (list, tuple, set, frozenset)): + keys.update(key for key in cast(Collection[Any], declared) if isinstance(key, str)) + return keys def _serialize_session_continuation_state( diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index e76fad0715b..a6fab9957a6 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -2922,3 +2922,89 @@ def capture_state(*args: Any, **kwargs: Any) -> Any: {"client_value": "available"}, {"private": "preserved", "client_value": "available"}, ] + + +async def test_reserved_service_session_keys_protected_without_agent_declaration(): + """Reserved provider keys stay server-owned even if the agent declares nothing. + + ``service_session_state_keys`` is resolved from the agent object AG-UI is handed, so an agent that does not + declare it, such as a wrapper that forgets to forward the attribute or an older provider package, would + otherwise silently fall back to accepting a client-supplied value. Reserving the key in + ``_RESERVED_SERVICE_SESSION_STATE_KEYS`` is the backstop that keeps those combinations safe. + """ + from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports] + + from agent_framework_ag_ui import AgentFrameworkAgent + + stub = StubAgent() + assert not hasattr(stub, "service_session_state_keys") + + observed_state: list[dict[str, Any]] = [] + original_run = stub.run + + def capture_state(*args: Any, **kwargs: Any) -> Any: + observed_state.append(dict(kwargs["session"].state)) + return original_run(*args, **kwargs) + + stub.run = capture_state # type: ignore[assignment, method-assign] # ty: ignore[invalid-assignment] + + agent = AgentFrameworkAgent(agent=stub) + _ = [ + event + async for event in agent.run( + { + "thread_id": "frontend-thread", + "run_id": "attacker-run", + "messages": [{"role": "user", "content": "Hello"}], + "state": { + "foundry_hosted_agent_session_id": "victim-sandbox", + "client_value": "available", + }, + } + ) + ] + + assert observed_state == [{"client_value": "available"}] + + +async def test_request_state_cannot_assign_service_session_id(): + """Chat history held by the provider is addressed by ``service_session_id``, never by client state. + + ``AgentSession`` keeps ``service_session_id`` as its own attribute rather than a ``state`` entry, and only + trusted snapshot storage or a provider response may set it. This test locks in that boundary so a future + refactor cannot start sourcing conversation continuation from request Shared State. + """ + from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports] + + from agent_framework_ag_ui import AgentFrameworkAgent, InMemoryAGUIThreadSnapshotStore + + for use_service_session in (False, True): + stub = StubAgent() + observed: list[Any] = [] + original_run = stub.run + + def capture(*args: Any, **kwargs: Any) -> Any: + observed.append(kwargs["session"].service_session_id) + return original_run(*args, **kwargs) + + stub.run = capture # type: ignore[assignment, method-assign] # ty: ignore[invalid-assignment] + + agent = AgentFrameworkAgent( + agent=stub, + use_service_session=use_service_session, + snapshot_store=InMemoryAGUIThreadSnapshotStore(), + ) + _ = [ + event + async for event in agent.run( + { + "thread_id": "frontend-thread", + "run_id": "attacker-run", + "__ag_ui_snapshot_scope": "test", + "messages": [{"role": "user", "content": "Hello"}], + "state": {"__ag_ui_provider_service_session_id": "victim-conversation"}, + } + ) + ] + + assert observed == [None], f"client state selected a conversation (use_service_session={use_service_session})" diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 8175c071a78..393a7109385 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -658,6 +658,14 @@ class RawFoundryAgent( """ service_session_state_keys: ClassVar[frozenset[str]] = frozenset({FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY}) + """Session-state keys this agent owns, which untrusted input must never supply. + + Holds the Foundry hosted-agent session ID. Despite the attribute name, that value is not a conversation or + an ``AgentSession.service_session_id``: it identifies the hosted agent's *runtime session*, a VM-isolated + sandbox with a persistent filesystem. Hosts such as AG-UI read this to reject client-supplied values, since + honouring one would run the server's own credentialed call inside another session's sandbox. Hosts are + expected to reserve this key independently as well, because a wrapper agent may not forward this attribute. + """ def __init__( self, diff --git a/python/packages/foundry/agent_framework_foundry/_constants.py b/python/packages/foundry/agent_framework_foundry/_constants.py index 66f2f70cf4b..dbafaca9944 100644 --- a/python/packages/foundry/agent_framework_foundry/_constants.py +++ b/python/packages/foundry/agent_framework_foundry/_constants.py @@ -1,3 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY = "foundry_hosted_agent_session_id" +"""``AgentSession.state`` key holding the Foundry hosted-agent session ID. + +This is the hosted agent's runtime session, a VM-isolated sandbox with a persistent filesystem, sent as +``extra_body["agent_session_id"]``. It is distinct from ``AgentSession.service_session_id``, which continues the +model-side response or conversation chain. The value is server-owned; see +``RawFoundryAgent.service_session_state_keys``. +""" diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 4c3b6876e68..3fe96541499 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -6,7 +6,7 @@ import json import os import sys -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable from importlib import import_module from types import SimpleNamespace from typing import Any, cast @@ -24,6 +24,7 @@ ChatMiddleware, ChatResponse, ChatResponseUpdate, + Content, FunctionInvocationContext, FunctionMiddleware, Message, @@ -31,6 +32,7 @@ WorkflowBuilder, tool, ) +from agent_framework._types import ResponseStream from agent_framework.foundry import FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY from agent_framework_ag_ui import AgentFrameworkAgent, InMemoryAGUIThreadSnapshotStore from agent_framework_openai._chat_client import RawOpenAIChatClient @@ -1095,6 +1097,134 @@ def my_func() -> str: assert agent.default_options.get("tools") is not None +async def test_agui_request_state_cannot_choose_foundry_hosted_agent_sandbox() -> None: + """A client must not be able to pick which Foundry sandbox the server's credentialed call addresses. + + Reproduces the full reported chain without network access: an ordinary AG-UI request carries + ``foundry_hosted_agent_session_id`` in its client-owned ``state``, and the resulting session is then used to + prepare the outbound Foundry Responses call. ``agent_session_id`` selects a VM-isolated sandbox with a + persistent filesystem, so honouring a caller-supplied value would run the server's own credentialed request + inside another session's sandbox. + """ + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + foundry_agent = RawFoundryAgent(project_client=mock_project, agent_name="test-agent") + + observed_state: list[dict[str, Any]] = [] + + def fake_run(messages: Any = None, **kwargs: Any) -> Any: + session = kwargs.get("session") + observed_state.append(dict(session.state) if session is not None else {}) + + async def _stream() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant") + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + foundry_agent.run = fake_run # type: ignore[method-assign] # ty: ignore[invalid-assignment] + + runner = AgentFrameworkAgent(agent=foundry_agent) + events = [ + event + async for event in runner.run({ + "runId": "attacker-run", + "threadId": "attacker-thread", + "messages": [{"role": "user", "content": "hello"}], + "state": { + FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY: "VICTIM-SANDBOX-9f31c2", + "benign": "ok", + }, + }) + ] + + assert not [event for event in events if getattr(event, "type", None) == "RUN_ERROR"] + # The sandbox handle is stripped, while ordinary client Shared State still reaches the agent. + assert observed_state == [{"benign": "ok"}] + + # The outbound Foundry call therefore pins no sandbox at all. + session = AgentSession() + session.state.update(observed_state[0]) + with patch( + "agent_framework._agents.RawAgent._prepare_run_context", + new=AsyncMock(return_value={"ok": True}), + ) as mock_prepare_run_context: + await foundry_agent._prepare_run_context( + messages="hi", + session=session, + tools=None, + options={}, + compaction_strategy=None, + tokenizer=None, + function_invocation_kwargs=None, + client_kwargs=None, + ) + + assert mock_prepare_run_context.await_args + assert "agent_session_id" not in mock_prepare_run_context.await_args.kwargs["options"].get("extra_body", {}) + + +async def test_agui_request_state_cannot_overwrite_established_foundry_sandbox() -> None: + """A server-established sandbox handle stays authoritative when a later request tries to replace it.""" + store = InMemoryAGUIThreadSnapshotStore() + mock_project = MagicMock() + mock_openai = MagicMock() + mock_openai.conversations.create = AsyncMock(return_value=SimpleNamespace(id="conv_server_owned")) + mock_project.get_openai_client.return_value = mock_openai + foundry_agent = RawFoundryAgent(project_client=mock_project, agent_name="test-agent") + + observed_state: list[dict[str, Any]] = [] + + def fake_run(messages: Any = None, **kwargs: Any) -> Any: + session = kwargs.get("session") + observed_state.append(dict(session.state) if session is not None else {}) + + async def _stream() -> AsyncIterator[AgentResponseUpdate]: + # The provider establishes the real sandbox handle, exactly as a live response would. + if session is not None: + session.state[FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY] = "server-owned-sandbox" + yield AgentResponseUpdate(contents=[Content.from_text(text="ok")], role="assistant") + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + foundry_agent.run = fake_run # type: ignore[method-assign] # ty: ignore[invalid-assignment] + + runner = AgentFrameworkAgent( + agent=foundry_agent, + use_service_session=True, + snapshot_store=store, + ) + payload: dict[str, Any] = { + "threadId": "victim-thread", + "__ag_ui_snapshot_scope": "scope", + "messages": [{"role": "user", "content": "hello"}], + } + _ = [event async for event in runner.run({**payload, "runId": "run-1"})] + + attacker_payload = { + **payload, + "runId": "run-2", + "state": {FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY: "attacker-chosen-sandbox"}, + } + _ = [event async for event in runner.run(attacker_payload)] + + assert observed_state[0] == {} + # The second turn resumes the server's own sandbox and ignores the attacker's value. + assert observed_state[1] == {FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY: "server-owned-sandbox"} + + +def test_foundry_agents_declare_hosted_agent_session_id_as_server_owned() -> None: + """The hosted-agent session ID must stay server-owned so hosts reject client-supplied values. + + ``agent_session_id`` selects the Foundry hosted-agent runtime session, which is a VM-isolated sandbox with a + persistent filesystem. A caller-supplied value would redirect the server's own credentialed call into another + session's sandbox, so hosts discover this key through ``service_session_state_keys`` and refuse to let + untrusted input populate it. Without this test nothing fails if the declaration is dropped. + """ + assert FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY in RawFoundryAgent.service_session_state_keys + # FoundryAgent is the recommended production class, so it must inherit the same protection. + assert FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY in FoundryAgent.service_session_state_keys + + async def test_raw_foundry_agent_prepare_run_context_injects_agent_session_id_from_state() -> None: """Test that hosted-agent session state is sent separately from response continuation.""" From b0bfd61c138377e08cc89970ace89f972d62f771 Mon Sep 17 00:00:00 2001 From: Sun Haoyuan <58774225+Shy7777@users.noreply.github.com> Date: Mon, 14 Sep 2026 05:28:41 +0000 Subject: [PATCH 039/108] Python: await Task and Future sources in ResponseStream (#8331) Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../packages/core/agent_framework/_types.py | 5 ++-- python/packages/core/tests/core/test_types.py | 29 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 7b0efa634e8..e9b1fcc31b7 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -9,7 +9,6 @@ import re import sys import warnings -from asyncio import iscoroutine from collections.abc import ( AsyncGenerator, AsyncIterable, @@ -3379,8 +3378,8 @@ async def _get_stream(self) -> AsyncIterable[UpdateT]: if hasattr(self._stream_source, "__aiter__"): self._stream = self._stream_source # type: ignore[assignment] else: - if not iscoroutine(self._stream_source): - self._stream = self._stream_source # type: ignore[assignment] + if not isawaitable(self._stream_source): + self._stream = self._stream_source else: self._stream = await self._stream_source if isinstance(self._stream, ResponseStream) and self._wrap_inner: diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index f7669221bd3..9589f1b0324 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1,9 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import base64 import json import warnings -from collections.abc import AsyncIterable, Sequence +from collections.abc import AsyncIterable, Awaitable, Sequence from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Literal, cast @@ -4529,13 +4530,22 @@ async def async_map(update: ChatResponseUpdate) -> ChatResponseUpdate: assert collected == ["async_update_0", "async_update_1"] - async def test_from_awaitable(self) -> None: + @pytest.mark.parametrize("source_kind", ["coroutine", "task", "future"]) + async def test_from_awaitable(self, source_kind: str) -> None: """from_awaitable() wraps an awaitable ResponseStream.""" async def get_stream() -> ResponseStream[ChatResponseUpdate, ChatResponse]: return ResponseStream(_generate_updates(2), finalizer=_combine_updates) - outer = ResponseStream.from_awaitable(get_stream()) + source: Awaitable[ResponseStream[ChatResponseUpdate, ChatResponse]] + if source_kind == "task": + source = asyncio.create_task(get_stream()) + elif source_kind == "future": + source = asyncio.get_running_loop().create_future() + source.set_result(await get_stream()) + else: + source = get_stream() + outer = ResponseStream.from_awaitable(source) collected: list[str] = [] async for update in outer: @@ -4614,13 +4624,22 @@ def finalizer(updates: list[ChatResponseUpdate]) -> ChatResponse: class TestResponseStreamAwaitableSource: """Tests for ResponseStream with awaitable stream sources.""" - async def test_awaitable_stream_source(self) -> None: + @pytest.mark.parametrize("source_kind", ["coroutine", "task", "future"]) + async def test_awaitable_stream_source(self, source_kind: str) -> None: """ResponseStream can accept an awaitable that resolves to an async iterable.""" async def get_stream() -> AsyncIterable[ChatResponseUpdate]: return _generate_updates(2) - stream = ResponseStream(get_stream(), finalizer=_combine_updates) + source: Awaitable[AsyncIterable[ChatResponseUpdate]] + if source_kind == "task": + source = asyncio.create_task(get_stream()) + elif source_kind == "future": + source = asyncio.get_running_loop().create_future() + source.set_result(await get_stream()) + else: + source = get_stream() + stream = ResponseStream(source, finalizer=_combine_updates) collected: list[str] = [] async for update in stream: From db418ad75a1e4276e79ef9a35451145d194c3eed Mon Sep 17 00:00:00 2001 From: mrchatam Date: Mon, 14 Sep 2026 05:38:55 +0000 Subject: [PATCH 040/108] Python: docs: fix 'rather then' typo in CODING_STANDARD (#8323) Co-authored-by: mrchatam --- python/CODING_STANDARD.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/CODING_STANDARD.md b/python/CODING_STANDARD.md index e1324bdbdc3..6756c860f96 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -31,7 +31,7 @@ We use typing as a helper, it is not a goal in and of itself, so be pragmatic ab In general, the public interfaces of our classes, are important to get right, internally it is okay to have loosely typed code, as long as tests cover the code itself. This includes making a conscious choice when to program defensively, you can always do `getattr(item, 'attribute')` but that might end up causing you issues down the road because the type of `item` in this case, should have that attribute and if it doesn't it points to a larger issue, so if the type is expected to have that attribute, you should -use `item.attribute` to ensure it fails at that point, rather then somewhere downstream where a value is expected but none was found. +use `item.attribute` to ensure it fails at that point, rather than somewhere downstream where a value is expected but none was found. ### Future Annotations From 567f1695711fa9766c82bbd373b9b1fe892420be Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Mon, 14 Sep 2026 05:48:58 +0000 Subject: [PATCH 041/108] =?UTF-8?q?fix(orchestrations):=20give=20the=20Gro?= =?UTF-8?q?upChat=20orchestrator=20agent=20the=20workflow=20run=20kwargs?= =?UTF-8?q?=20=F0=9F=94=8C=20(#8312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #8304. workflow.run(function_invocation_kwargs=..., client_kwargs=...) is stored in workflow state under WORKFLOW_RUN_KWARGS_KEY and AgentExecutor forwards it to every participant agent. The GroupChat orchestrator agent runs outside AgentExecutor, in AgentBasedGroupChatOrchestrator._invoke_agent, which called self._agent.run() with only messages, session and options. So a host that puts request-scoped values there (a user id for tool ACL, an id for metrics) had them reach the participants and silently not reach the orchestrator, even though the orchestrator is an agent with its own tools and middleware reading AgentContext.function_invocation_kwargs. _invoke_agent now takes the WorkflowContext both of its callers already hold, reads the same state key AgentExecutor reads, and forwards both kwargs. The resolution itself is not reimplemented. AgentExecutor._prepare_agent_run_args and _resolve_executor_kwargs depended on nothing but self.id, so they move to _agent_utils as prepare_agent_run_args(executor_id, ...) and resolve_executor_kwargs(executor_id, ...), and both AgentExecutor methods stay as one-line delegates. The orchestrator is itself an Executor, so passing self.id gives it the same semantics participants get: __global__ kwargs apply, per-executor entries are keyed by the orchestrator's own id, and specific values override global ones. Not changed: the Magentic manager's _complete, named in the issue as the same pattern at a separate call site. Its callers are plan/replan/ create_progress_ledger/prepare_final_answer on MagenticManagerBase, which is not an Executor and holds no WorkflowContext, so reaching the run kwargs there means widening a public extension point rather than reading state that is already in hand. That is a design call for the team, not a drive-by. 2 tests added in packages/orchestrations/tests/test_group_chat.py. The first fails against unpatched sources with AssertionError: assert None == {'user_id': 'user-123'} the orchestrator having been invoked with only {'options': {'response_format': AgentOrchestrationOutput}}. The second pins the no-kwargs case so the resolution keeps returning None rather than an empty dict, which also holds before the change. packages/core/tests and packages/orchestrations/tests: 5381 tests, 0 failures, 0 errors, 142 skipped. poe syntax and poe test-typing clean for both packages; poe pyright reports the same 155 pre-existing core errors before and after, none in the touched files. --- .../_workflows/_agent_executor.py | 37 ++-------- .../_workflows/_agent_utils.py | 70 ++++++++++++++++++ .../_group_chat.py | 21 ++++-- .../orchestrations/tests/test_group_chat.py | 72 +++++++++++++++++++ 4 files changed, 162 insertions(+), 38 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 1c3a7203f49..cd69504c71d 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -14,8 +14,8 @@ from .._agents import SupportsAgentRun from .._sessions import AgentSession from .._types import AgentResponse, AgentResponseUpdate, Message, ResponseStream -from ._agent_utils import resolve_agent_id -from ._const import GLOBAL_KWARGS_KEY, INTERNAL_SOURCE_ID, WORKFLOW_RUN_KWARGS_KEY +from ._agent_utils import prepare_agent_run_args, resolve_agent_id, resolve_executor_kwargs +from ._const import INTERNAL_SOURCE_ID, WORKFLOW_RUN_KWARGS_KEY from ._executor import Executor, handler from ._message_utils import normalize_messages_input from ._request_info_mixin import response_handler @@ -594,12 +594,7 @@ def _prepare_agent_run_args( Returns: A 2-tuple of (function_invocation_kwargs, client_kwargs). """ - fi_resolved = raw_run_kwargs.get("function_invocation_kwargs") - ci_resolved = raw_run_kwargs.get("client_kwargs") - function_invocation_kwargs = self._resolve_executor_kwargs(fi_resolved) - client_kwargs = self._resolve_executor_kwargs(ci_resolved) - - return function_invocation_kwargs, client_kwargs + return prepare_agent_run_args(self.id, raw_run_kwargs) def _resolve_executor_kwargs(self, resolved: dict[str, Any] | None) -> dict[str, Any] | None: """Extract this executor's kwargs from a resolved invocation kwargs dict. @@ -612,28 +607,4 @@ def _resolve_executor_kwargs(self, resolved: dict[str, Any] | None) -> dict[str, Returns: The kwargs for this executor, or ``None`` if not applicable. """ - if not isinstance(resolved, dict): - return None - global_kwargs: Any = resolved.get(GLOBAL_KWARGS_KEY) - executor_kwargs: Any = resolved.get(self.id) - if global_kwargs is None and executor_kwargs is None: - return None - - if global_kwargs is not None and not isinstance(global_kwargs, dict): - logger.warning( - "Executor %s expected a dict for global kwargs, but got %s. Ignoring.", - self.id, - cast(type[Any], type(global_kwargs)), - ) - return None - - if executor_kwargs is not None and not isinstance(executor_kwargs, dict): - logger.warning( - "Executor %s expected a dict for its kwargs, but got %s. Ignoring.", - self.id, - cast(type[Any], type(executor_kwargs)), - ) - return None - - # Specific values override global values for the same function argument. - return {**(global_kwargs or {}), **(executor_kwargs or {})} + return resolve_executor_kwargs(self.id, resolved) diff --git a/python/packages/core/agent_framework/_workflows/_agent_utils.py b/python/packages/core/agent_framework/_workflows/_agent_utils.py index f70d524ceeb..08034123328 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_utils.py +++ b/python/packages/core/agent_framework/_workflows/_agent_utils.py @@ -1,6 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. +import logging +from typing import Any, cast + from .._agents import SupportsAgentRun +from ._const import GLOBAL_KWARGS_KEY + +logger = logging.getLogger(__name__) def resolve_agent_id(agent: SupportsAgentRun) -> str: @@ -15,3 +21,67 @@ def resolve_agent_id(agent: SupportsAgentRun) -> str: The resolved unique identifier for the agent. """ return agent.name if agent.name else agent.id + + +def resolve_executor_kwargs(executor_id: str, resolved: dict[str, Any] | None) -> dict[str, Any] | None: + """Extract one executor's kwargs from a resolved invocation kwargs dict. + + Args: + executor_id: The id of the executor whose kwargs are wanted. + resolved: The resolved dict produced by ``Workflow._resolve_invocation_kwargs``, + containing either a ``__global__`` key (global kwargs) or executor-ID keys + (per-executor kwargs). May also be ``None``. + + Returns: + The kwargs for that executor, or ``None`` if not applicable. + """ + if not isinstance(resolved, dict): + return None + global_kwargs: Any = resolved.get(GLOBAL_KWARGS_KEY) + executor_kwargs: Any = resolved.get(executor_id) + if global_kwargs is None and executor_kwargs is None: + return None + + if global_kwargs is not None and not isinstance(global_kwargs, dict): + logger.warning( + "Executor %s expected a dict for global kwargs, but got %s. Ignoring.", + executor_id, + cast(type[Any], type(global_kwargs)), + ) + return None + + if executor_kwargs is not None and not isinstance(executor_kwargs, dict): + logger.warning( + "Executor %s expected a dict for its kwargs, but got %s. Ignoring.", + executor_id, + cast(type[Any], type(executor_kwargs)), + ) + return None + + # Specific values override global values for the same function argument. + return {**(global_kwargs or {}), **(executor_kwargs or {})} + + +def prepare_agent_run_args( + executor_id: str, + raw_run_kwargs: dict[str, Any], +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + """Prepare function_invocation_kwargs and client_kwargs for agent.run(). + + Extracts ``function_invocation_kwargs`` and ``client_kwargs`` from the workflow state + dict, resolving per-executor entries using ``executor_id``. The ``__global__`` sentinel + key (set by ``Workflow._resolve_invocation_kwargs``) denotes global kwargs that apply to + all executors. Per-executor dicts use executor IDs as keys; only the entry for + ``executor_id`` is extracted. + + Args: + executor_id: The id of the executor about to invoke the agent. + raw_run_kwargs: The workflow state dict stored under ``WORKFLOW_RUN_KWARGS_KEY``. + + Returns: + A 2-tuple of (function_invocation_kwargs, client_kwargs). + """ + function_invocation_kwargs = resolve_executor_kwargs(executor_id, raw_run_kwargs.get("function_invocation_kwargs")) + client_kwargs = resolve_executor_kwargs(executor_id, raw_run_kwargs.get("client_kwargs")) + + return function_invocation_kwargs, client_kwargs diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index bbb61edae28..879e09236cf 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -32,8 +32,9 @@ from agent_framework import Agent, AgentResponse, AgentResponseUpdate, AgentSession, Message, SupportsAgentRun from agent_framework._telemetry import mark_feature_used from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse -from agent_framework._workflows._agent_utils import resolve_agent_id +from agent_framework._workflows._agent_utils import prepare_agent_run_args, resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage +from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY from agent_framework._workflows._executor import Executor from agent_framework._workflows._workflow import Workflow from agent_framework._workflows._workflow_context import WorkflowContext @@ -353,7 +354,7 @@ async def _handle_messages( ): return - agent_orchestration_output = await self._invoke_agent() + agent_orchestration_output = await self._invoke_agent(cast(WorkflowContext[Any, Any], ctx)) if await self._check_agent_terminate_and_yield( agent_orchestration_output, cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx), @@ -393,7 +394,7 @@ async def _handle_response( ): return - agent_orchestration_output = await self._invoke_agent() + agent_orchestration_output = await self._invoke_agent(cast(WorkflowContext[Any, Any], ctx)) if await self._check_agent_terminate_and_yield( agent_orchestration_output, cast(WorkflowContext[Never, AgentResponse | AgentResponseUpdate], ctx), @@ -486,8 +487,16 @@ def _parse_agent_output(cls, agent_response: Any) -> AgentOrchestrationOutput: raise ValueError("Failed to parse agent orchestration output.") from last_error - async def _invoke_agent(self) -> AgentOrchestrationOutput: - """Invoke the orchestrator agent to determine the next speaker and termination.""" + async def _invoke_agent(self, ctx: WorkflowContext[Any, Any]) -> AgentOrchestrationOutput: + """Invoke the orchestrator agent to determine the next speaker and termination. + + Args: + ctx: The workflow context, read for the run kwargs stored by ``Workflow.run``. + The orchestrator agent runs outside ``AgentExecutor``, so it has to resolve + those itself or it is the only agent in the group chat that does not see them. + """ + raw_run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) + function_invocation_kwargs, client_kwargs = prepare_agent_run_args(self.id, raw_run_kwargs) async def _invoke_agent_helper(conversation: list[Message]) -> AgentOrchestrationOutput: # Run the agent in non-streaming mode for simplicity @@ -495,6 +504,8 @@ async def _invoke_agent_helper(conversation: list[Message]) -> AgentOrchestratio messages=conversation, session=self._session, options={"response_format": AgentOrchestrationOutput}, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, ) # Parse and validate the structured output agent_orchestration_output = self._parse_agent_output(agent_response) diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 935111ccaad..92d600ba9ce 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -275,6 +275,78 @@ async def test_group_chat_as_agent_accepts_conversation() -> None: assert response.messages, "Expected agent conversation output" +class KwargsRecordingManagerAgent(StubManagerAgent): + """Manager agent that records the run kwargs it was invoked with.""" + + def __init__(self) -> None: + super().__init__() + self.seen_kwargs: list[dict[str, Any]] = [] + + async def run( # type: ignore[override] # ty: ignore[invalid-method-override] + self, + messages: str | Content | Message | Sequence[str | Content | Message] | None = None, + *, + session: AgentSession | None = None, + **kwargs: Any, + ) -> AgentResponse[Any]: + self.seen_kwargs.append(dict(kwargs)) + return await super().run(messages, session=session, **kwargs) + + +async def test_agent_manager_receives_workflow_run_kwargs() -> None: + """The orchestrator agent gets the same run kwargs the participants already get. + + ``workflow.run(function_invocation_kwargs=...)`` is stored in workflow state and + ``AgentExecutor`` forwards it to every participant agent. The orchestrator agent runs + outside ``AgentExecutor``, so it has to resolve the same state itself or hosts that put + request-scoped values there (a user id for tool ACL, say) silently get them on the + participants and not on the orchestrator. + """ + manager = KwargsRecordingManagerAgent() + worker = StubAgent("agent", "worker response") + + workflow = GroupChatBuilder( + participants=[worker], + orchestrator_agent=manager, + ).build() + + async for _ in workflow.run( + "coordinate task", + stream=True, + function_invocation_kwargs={"user_id": "user-123"}, + client_kwargs={"trace_id": "trace-abc"}, + ): + pass + + assert manager.seen_kwargs, "Expected the orchestrator agent to be invoked" + for call in manager.seen_kwargs: + assert call.get("function_invocation_kwargs") == {"user_id": "user-123"} + assert call.get("client_kwargs") == {"trace_id": "trace-abc"} + + +async def test_agent_manager_receives_no_run_kwargs_when_none_supplied() -> None: + """With nothing declared on the run, the orchestrator is invoked with both kwargs as None. + + This pins the shape rather than just the happy path: the resolution has to return None, + not an empty dict, so a client that distinguishes the two is not handed a stray {}. + """ + manager = KwargsRecordingManagerAgent() + worker = StubAgent("agent", "worker response") + + workflow = GroupChatBuilder( + participants=[worker], + orchestrator_agent=manager, + ).build() + + async for _ in workflow.run("coordinate task", stream=True): + pass + + assert manager.seen_kwargs, "Expected the orchestrator agent to be invoked" + for call in manager.seen_kwargs: + assert call.get("function_invocation_kwargs") is None + assert call.get("client_kwargs") is None + + async def test_agent_manager_handles_concatenated_json_output() -> None: manager = ConcatenatedJsonManagerAgent() worker = StubAgent("agent", "worker response") From 21f6fe72c6e4c2e88a0f520aa6ce57207fee3ecf Mon Sep 17 00:00:00 2001 From: WhaleTech Date: Mon, 14 Sep 2026 06:07:10 +0000 Subject: [PATCH 042/108] Fix WorkflowAgent non-streaming return type (#8287) Co-authored-by: WhaleTech <304937387+ryo-whaletech@users.noreply.github.com> --- .../packages/core/agent_framework/_workflows/_agent.py | 2 +- .../packages/core/tests/workflow/test_workflow_agent.py | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 8198ad297e7..9a8d496da69 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -162,7 +162,7 @@ def run( | Mapping[str, Any] | None = None, client_kwargs: WorkflowInvocationKwargs | Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None, - ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... + ) -> Awaitable[AgentResponse]: ... @overload def run( diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 70fd94468ca..69fbcbd9b4b 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -6,7 +6,7 @@ from typing import Any, Literal, overload import pytest -from typing_extensions import Never +from typing_extensions import Never, assert_type from agent_framework import ( AgentExecutorRequest, @@ -33,6 +33,13 @@ from agent_framework._workflows._typing_utils import deserialize_type +def _assert_workflow_agent_run_return_types(agent: WorkflowAgent) -> None: + """Verify WorkflowAgent.run overloads expose distinct non-streaming and streaming types.""" + assert_type(agent.run(), Awaitable[AgentResponse]) + assert_type(agent.run(stream=False), Awaitable[AgentResponse]) + assert_type(agent.run(stream=True), ResponseStream[AgentResponseUpdate, AgentResponse]) + + @dataclass class HandoffRequest: """Module-level dataclass used by request_info tests. From 3347c43ea886f2dba1b582ef68bdcd01c950c42c Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan <35326118+manjunathshiva@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:39:40 +0000 Subject: [PATCH 043/108] Python: register built-in orchestration types for checkpoint restore (#8258) * Python: register built-in orchestration types for checkpoint restore Checkpoint restore runs pickle through a restricted unpickler whose default allowlist auto-trusts the `agent_framework.` prefix. That prefix is dotted, so it covers core but not sibling distributions, and every built-in group-chat, handoff and Magentic envelope therefore failed to restore unless the user hand-maintained `allowed_checkpoint_types` with framework-internal module paths. Registering the ten framework-owned types that actually cross a checkpoint boundary, via the existing public `register_checkpoint_type`. Each was verified against the tree rather than assumed: four are executor-to-executor message envelopes, four are `request_info` payloads or their response types, and two are nested inside `MagenticPlanReviewRequest.current_progress`. Deliberately not broadening `_FRAMEWORK_MODULE_PREFIX` to `agent_framework` without the dot. That would fix the whole class in one line but auto-trust any installed distribution merely named `agent_framework_*` on a pickle allowlist, which is a security boundary and a maintainer decision. `_MagenticTaskLedger` is excluded on purpose: it is persisted through `to_dict()`/`from_dict()` and never reaches the unpickler. The tests round-trip fully populated payloads through a storage built with no `allowed_checkpoint_types`, because the unpickler resolves nested classes as well as outer ones -- omitting only `MagenticProgressLedger` and `MagenticProgressLedgerItem` from the registration leaves the other cases passing and fails exactly these three tests. * Python: cover the group-chat scenario that actually produces the envelopes The type-level round trips proved the registration works but not that any real orchestration reaches it, so this adds the end-to-end case and it corrected two things I had wrong. The orchestrator only wraps traffic in the group-chat envelopes for participants that are not agents (`_base_group_chat_orchestrator.py:434,467`); agent participants get a core `AgentExecutorRequest`, which the default allowlist already trusts. A group chat built purely from agents never hits this, and the suite's other checkpoint tests use `InMemoryCheckpointStorage`, which deepcopies and never reaches the unpickler -- between them, that is why the bug shipped. The symptom is also worse than a failed `load()`. `list_checkpoints` logs and skips a checkpoint it cannot decode, so the run's six checkpoints list as two and `get_latest` returns a stale one instead of raising. The test therefore counts the files on disk and asserts the listing agrees with them, rather than trusting the listing it is trying to verify. --- .../__init__.py | 38 ++++ .../tests/test_checkpoint_types.py | 215 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 python/packages/orchestrations/tests/test_checkpoint_types.py diff --git a/python/packages/orchestrations/agent_framework_orchestrations/__init__.py b/python/packages/orchestrations/agent_framework_orchestrations/__init__.py index d1acb7af53e..59eda95c75e 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/__init__.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/__init__.py @@ -17,10 +17,14 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode +from agent_framework import register_checkpoint_type + from ._base_group_chat_orchestrator import ( BaseGroupChatOrchestrator, + GroupChatParticipantMessage, GroupChatRequestMessage, GroupChatRequestSentEvent, + GroupChatResponseMessage, GroupChatResponseReceivedEvent, TerminationCondition, ) @@ -108,3 +112,37 @@ "clean_conversation_for_handoff", "create_completion_message", ] + + +# Framework-owned types that cross a checkpoint boundary. +# +# Checkpoint restore runs pickle through a restricted unpickler whose default allowlist +# auto-trusts the ``agent_framework.`` prefix. That prefix is dotted, so it covers core but +# not sibling distributions such as this one -- deliberately, since dropping the dot would +# auto-trust any installed package merely named ``agent_framework_*``. Built-in orchestration +# envelopes therefore have to opt in by name, or users have to hand-maintain +# ``allowed_checkpoint_types`` with framework-internal module paths (#7789). +# +# Each entry below crosses the boundary for a stated reason. Nothing belongs here that only +# travels as a dict: ``_MagenticTaskLedger``, for instance, is persisted through +# ``to_dict()``/``from_dict()`` and never reaches the unpickler. +for _checkpoint_type in ( + # Executor-to-executor message envelopes. + GroupChatRequestMessage, + GroupChatParticipantMessage, + GroupChatResponseMessage, + MagenticResetSignal, + # ``request_info`` payloads and their response types, which are checkpointed as + # pending request-info events while a workflow waits on a human. + HandoffAgentUserRequest, + AgentRequestInfoResponse, + MagenticPlanReviewRequest, + MagenticPlanReviewResponse, + # Nested inside ``MagenticPlanReviewRequest.current_progress``. The unpickler resolves + # nested classes too, so registering only the outer request is not enough. + MagenticProgressLedger, + MagenticProgressLedgerItem, +): + register_checkpoint_type(_checkpoint_type) + +del _checkpoint_type diff --git a/python/packages/orchestrations/tests/test_checkpoint_types.py b/python/packages/orchestrations/tests/test_checkpoint_types.py new file mode 100644 index 00000000000..c673e7fef39 --- /dev/null +++ b/python/packages/orchestrations/tests/test_checkpoint_types.py @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Built-in orchestration types must survive a checkpoint round trip. + +Checkpoint restore runs pickle through a restricted unpickler. Its default allowlist +auto-trusts the dotted ``agent_framework.`` prefix, which covers core but not sibling +distributions like ``agent_framework_orchestrations``, so framework-owned orchestration +envelopes have to opt in by name. Importing the package is what registers them (#7789). + +These tests use a ``FileCheckpointStorage`` built with **no** ``allowed_checkpoint_types``, +because the bug was precisely that users had to supply framework-internal module paths +themselves. Each payload is fully populated: the unpickler resolves nested classes as well +as the outer one, so a test that only round-trips an empty shell would still pass with a +nested type left unregistered. +""" + +from __future__ import annotations + +import asyncio +import tempfile +from pathlib import Path +from typing import Any + +import pytest +from agent_framework import AgentResponse, Executor, Message, WorkflowCheckpoint, WorkflowContext, handler +from agent_framework._workflows._checkpoint import FileCheckpointStorage + +from agent_framework_orchestrations import ( + AgentRequestInfoResponse, + GroupChatBuilder, + HandoffAgentUserRequest, + MagenticPlanReviewRequest, + MagenticPlanReviewResponse, + MagenticProgressLedger, + MagenticProgressLedgerItem, + MagenticResetSignal, +) +from agent_framework_orchestrations._base_group_chat_orchestrator import ( + GroupChatParticipantMessage, + GroupChatRequestMessage, + GroupChatResponseMessage, +) + + +def _ledger() -> MagenticProgressLedger: + """A fully populated progress ledger; every field is a nested dataclass.""" + return MagenticProgressLedger( + is_request_satisfied=MagenticProgressLedgerItem(reason="not yet", answer=False), + is_in_loop=MagenticProgressLedgerItem(reason="no repeats", answer=False), + is_progress_being_made=MagenticProgressLedgerItem(reason="advancing", answer=True), + next_speaker=MagenticProgressLedgerItem(reason="their turn", answer="researcher"), + instruction_or_question=MagenticProgressLedgerItem(reason="needs detail", answer="Find the source."), + ) + + +def _payloads() -> list[tuple[str, Any]]: + """One populated instance of every orchestration type that crosses a checkpoint.""" + msg = Message(role="assistant", contents=["hello"]) + return [ + ("GroupChatRequestMessage", GroupChatRequestMessage(additional_instruction="go", metadata={"round": 1})), + ("GroupChatParticipantMessage", GroupChatParticipantMessage(messages=[msg])), + ("GroupChatResponseMessage", GroupChatResponseMessage(message=msg)), + ("MagenticResetSignal", MagenticResetSignal()), + ("HandoffAgentUserRequest", HandoffAgentUserRequest(agent_response=AgentResponse(messages=[msg]))), + ("AgentRequestInfoResponse", AgentRequestInfoResponse(messages=[msg])), + ("MagenticPlanReviewResponse", MagenticPlanReviewResponse(review=[msg])), + ( + # The nested case: `current_progress` is non-None, so restoring this request + # forces the unpickler to resolve MagenticProgressLedger and + # MagenticProgressLedgerItem as well. Registering only the outer request would + # leave this failing. + "MagenticPlanReviewRequest", + MagenticPlanReviewRequest(plan=msg, current_progress=_ledger(), is_stalled=True), + ), + ] + + +@pytest.mark.parametrize("name,payload", _payloads(), ids=[n for n, _ in _payloads()]) +async def test_orchestration_type_survives_checkpoint_round_trip(name: str, payload: Any) -> None: + """Saving and restoring a built-in orchestration payload needs no user allowlist.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) # deliberately no allowed_checkpoint_types + await storage.save( + WorkflowCheckpoint( + workflow_name="orchestration", + graph_signature_hash="test-hash", + checkpoint_id=f"ck-{name}", + state={"payload": payload}, + ) + ) + restored = await storage.load(f"ck-{name}") + + assert type(restored.state["payload"]) is type(payload) + + +async def test_plan_review_request_restores_its_nested_progress_ledger() -> None: + """The nested ledger must come back with its values, not merely resolve as a class. + + Separate from the round-trip case above because the failure it guards is different: a + missing registration for `MagenticProgressLedgerItem` raises during unpickling, while a + ledger that resolves but loses its contents would pass a type-only assertion. + """ + request = MagenticPlanReviewRequest( + plan=Message(role="assistant", contents=["the plan"]), + current_progress=_ledger(), + is_stalled=True, + ) + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + await storage.save( + WorkflowCheckpoint( + workflow_name="magentic", + graph_signature_hash="test-hash", + checkpoint_id="ck-nested", + state={"payload": request}, + ) + ) + restored = await storage.load("ck-nested") + + payload = restored.state["payload"] + assert isinstance(payload, MagenticPlanReviewRequest) + assert payload.is_stalled is True + + progress = payload.current_progress + assert isinstance(progress, MagenticProgressLedger) + assert isinstance(progress.next_speaker, MagenticProgressLedgerItem) + assert progress.next_speaker.answer == "researcher" + assert progress.is_request_satisfied.answer is False + assert progress.instruction_or_question.reason == "needs detail" + + +def test_registration_happens_on_package_import() -> None: + """Importing the package is what makes restore work, so pin that it registers. + + A checkpoint written by a process that imported the orchestrations package can only be + restored by one that also imports it. That import-order dependency is the cost of + opting in by name rather than widening the trusted module prefix, and it is worth + stating in a test rather than leaving implicit. + """ + from agent_framework._workflows._checkpoint_encoding import _REGISTERED_CHECKPOINT_TYPE_KEYS + + prefix = "agent_framework_orchestrations." + registered = {key for key in _REGISTERED_CHECKPOINT_TYPE_KEYS if key.startswith(prefix)} + + expected = { + "agent_framework_orchestrations._base_group_chat_orchestrator:GroupChatRequestMessage", + "agent_framework_orchestrations._base_group_chat_orchestrator:GroupChatParticipantMessage", + "agent_framework_orchestrations._base_group_chat_orchestrator:GroupChatResponseMessage", + "agent_framework_orchestrations._handoff:HandoffAgentUserRequest", + "agent_framework_orchestrations._orchestration_request_info:AgentRequestInfoResponse", + "agent_framework_orchestrations._magentic:MagenticResetSignal", + "agent_framework_orchestrations._magentic:MagenticPlanReviewRequest", + "agent_framework_orchestrations._magentic:MagenticPlanReviewResponse", + "agent_framework_orchestrations._magentic:MagenticProgressLedger", + "agent_framework_orchestrations._magentic:MagenticProgressLedgerItem", + } + assert expected <= registered + + +class _CustomParticipant(Executor): + """A non-agent group-chat participant. + + Defined at module scope on purpose: with ``from __future__ import annotations`` a + handler declared inside a function body cannot resolve its own context annotation. + """ + + @handler + async def on_request( + self, message: GroupChatRequestMessage, ctx: WorkflowContext[GroupChatResponseMessage] + ) -> None: + await ctx.send_message(GroupChatResponseMessage(message=Message(role="assistant", contents=["custom reply"]))) + + @handler + async def on_broadcast(self, message: GroupChatParticipantMessage, ctx: WorkflowContext) -> None: + pass + + +async def test_group_chat_with_a_custom_executor_participant_keeps_every_checkpoint_readable() -> None: + """End-to-end: the scenario that actually produces the envelopes, restored from disk. + + The orchestrator only wraps traffic in the group-chat envelopes for participants that + are **not** agents (``_base_group_chat_orchestrator.py:434,467``); agent participants + get a core ``AgentExecutorRequest``, which the default allowlist already trusts. So a + group chat built purely from agents never hit this bug, and neither did the existing + suite -- which is why it shipped. + + The symptom is worse than a failed ``load()``. ``list_checkpoints`` logs and skips a + checkpoint it cannot decode, so unregistered types make checkpoints silently vanish + from listings and ``get_latest`` hands back a stale one instead of raising. This test + therefore counts the files on disk rather than trusting the listing. + """ + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) # deliberately no allowed_checkpoint_types + workflow = GroupChatBuilder( + participants=[_CustomParticipant(id="custom")], + max_rounds=2, + checkpoint_storage=storage, + selection_func=lambda state: "custom", + ).build() + + async for _ in workflow.run("test task", stream=True): + pass + + on_disk = await asyncio.to_thread(lambda: sorted(p.stem for p in Path(temp_dir).glob("*.json"))) + assert on_disk, "the run produced no checkpoints, so this test proves nothing" + + # Every checkpoint on disk must load individually... + for checkpoint_id in on_disk: + await storage.load(checkpoint_id) + + # ...and the listing must agree with the disk, rather than quietly dropping the + # ones it could not decode. + listed = await storage.list_checkpoints(workflow_name=workflow.name) + assert sorted(cp.checkpoint_id for cp in listed) == on_disk From 003ae93fd476252f4ed518db357ed1a4f71ecce3 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:45:12 +0000 Subject: [PATCH 044/108] Improve issue triage (#8362) * Improve issue triage * Avoid retriggering triage on issue edits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6eba561c-6e40-49e9-9d82-0846662c8038 --------- Copilot-Session: 6eba561c-6e40-49e9-9d82-0846662c8038 --- .github/workflows/issue-triage.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 840f65eb1d7..f06875c830c 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -216,11 +216,15 @@ jobs: AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} ISSUE_REPO: ${{ needs.team_check.outputs.repo }} ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }} + ISSUE_TYPE: ${{ needs.team_check.outputs.issue_type }} + ISSUE_SNAPSHOT_PATH: ${{ runner.temp }}/issue-triage-snapshot.json run: | uv run python scripts/classify_issue_spam.py \ --repo "$ISSUE_REPO" \ --issue-number "$ISSUE_NUMBER" \ + --issue-type "$ISSUE_TYPE" \ --repo-path "${TARGET_REPO_PATH}" \ + --issue-snapshot-output "$ISSUE_SNAPSHOT_PATH" \ --apply-labels - name: Stop after spam gate @@ -244,6 +248,8 @@ jobs: ISSUE_REPO: ${{ needs.team_check.outputs.repo }} ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }} ISSUE_TYPE: ${{ needs.team_check.outputs.issue_type }} + ISSUE_SNAPSHOT_PATH: ${{ runner.temp }}/issue-triage-snapshot.json + ISSUE_SNAPSHOT_SHA256: ${{ steps.spam.outputs.issue_snapshot_sha256 }} # Model-provider settings for generated repro code. Never enter the # agent prompt; consumed by SDK constructors via os.environ. Azure # OpenAI and Foundry auth via AAD from the azure/login step above. @@ -271,4 +277,6 @@ jobs: --repo "$ISSUE_REPO" \ --issue-number "$ISSUE_NUMBER" \ --issue-type "$ISSUE_TYPE" \ + --issue-snapshot "$ISSUE_SNAPSHOT_PATH" \ + --issue-snapshot-sha256 "$ISSUE_SNAPSHOT_SHA256" \ --github-username "$GITHUB_ACTOR" From ae2876262194935918c982c0efcb99c188c96272 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 14 Sep 2026 08:01:24 +0000 Subject: [PATCH 045/108] Python: Add Azure Cosmos DB NoSQL vector store connector (#8186) * Python: add Azure Cosmos DB NoSQL vector store Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: address Cosmos vector review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: preserve Cosmos star imports on older core Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: fix Cosmos vector type re-exports Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --- python/AGENTS.md | 2 +- python/packages/azure-cosmos/AGENTS.md | 17 +- python/packages/azure-cosmos/README.md | 219 ++- .../agent_framework_azure_cosmos/__init__.py | 48 +- .../_vector_store.py | 1425 +++++++++++++++++ .../agent_framework_azure_cosmos/py.typed | 0 python/packages/azure-cosmos/pyproject.toml | 6 +- .../tests/azure_cosmos/test_vector_store.py | 1277 +++++++++++++++ .../test_vector_store_integration.py | 322 ++++ .../core/agent_framework/azure/__init__.py | 3 + .../core/agent_framework/azure/__init__.pyi | 4 + .../core/tests/core/test_azure_namespace.py | 14 +- python/uv.lock | 2 +- 13 files changed, 3237 insertions(+), 102 deletions(-) create mode 100644 python/packages/azure-cosmos/agent_framework_azure_cosmos/_vector_store.py create mode 100644 python/packages/azure-cosmos/agent_framework_azure_cosmos/py.typed create mode 100644 python/packages/azure-cosmos/tests/azure_cosmos/test_vector_store.py create mode 100644 python/packages/azure-cosmos/tests/azure_cosmos/test_vector_store_integration.py diff --git a/python/AGENTS.md b/python/AGENTS.md index 7f5a870ebda..6cdac7c462f 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -112,7 +112,7 @@ python/ - [foundry](packages/foundry/README.md) - Microsoft Foundry chat, agent, memory, and embedding integrations - [azure-contentunderstanding](packages/azure-contentunderstanding/AGENTS.md) - Azure Content Understanding context provider - [azure-ai-search](packages/azure-ai-search/AGENTS.md) - Azure AI Search RAG -- [azure-cosmos](packages/azure-cosmos/AGENTS.md) - Azure Cosmos DB-backed history provider +- [azure-cosmos](packages/azure-cosmos/AGENTS.md) - Azure Cosmos DB for NoSQL vector, history, and checkpoint integrations Durable Task and Azure Functions integrations are maintained in the [Durable Agent Framework extension](https://github.com/microsoft/agent-framework-durable-extension). diff --git a/python/packages/azure-cosmos/AGENTS.md b/python/packages/azure-cosmos/AGENTS.md index 9bb7f76da97..b151077c3df 100644 --- a/python/packages/azure-cosmos/AGENTS.md +++ b/python/packages/azure-cosmos/AGENTS.md @@ -1,10 +1,14 @@ # Azure Cosmos DB Package (agent-framework-azure-cosmos) -Azure Cosmos DB history provider integration for Agent Framework. +Azure Cosmos DB for NoSQL integrations for Agent Framework. ## Main Classes - **`CosmosHistoryProvider`** - Persistent conversation history storage backed by Azure Cosmos DB +- **`CosmosCheckpointStorage`** - Workflow checkpoint storage backed by Azure Cosmos DB +- **`CosmosCollection`** - Vector collection using NoSQL `VectorDistance` queries +- **`CosmosStore`** - Factory and database administration for vector collections +- **`AzureCosmosSettings`** - Shared vector connector settings shape ## Usage @@ -21,10 +25,17 @@ provider = CosmosHistoryProvider( Container name is configured on the provider. `session_id` is used as the partition key. +Vector collections require an application-provided string key stored as `id` and a +container partition key of `/id`. Container vector and indexing policies are created +only by `ensure_collection_exists()` and validated before use. Existing incompatible +containers are never updated or recreated. +Supported vector element types are float32, int8, and uint8. Euclidean search is supported, +but Euclidean score thresholds fail before service I/O. + ## Import Path ```python -from agent_framework.azure import CosmosHistoryProvider +from agent_framework.azure import CosmosCollection, CosmosHistoryProvider, CosmosStore # or directly: -from agent_framework_azure_cosmos import CosmosHistoryProvider +from agent_framework_azure_cosmos import CosmosCollection, CosmosHistoryProvider, CosmosStore ``` diff --git a/python/packages/azure-cosmos/README.md b/python/packages/azure-cosmos/README.md index e458bcd9eb7..72b29774506 100644 --- a/python/packages/azure-cosmos/README.md +++ b/python/packages/azure-cosmos/README.md @@ -1,126 +1,171 @@ -# Get Started with Microsoft Agent Framework Azure Cosmos DB +# Microsoft Agent Framework integrations for Azure Cosmos DB -Please install this package via pip: +Use Azure Cosmos DB for NoSQL as an Agent Framework vector store, conversation history +provider, or workflow checkpoint store. + +## Contents + +- `CosmosCollection` and `CosmosStore` for vector CRUD and `VectorDistance` search +- `CosmosHistoryProvider` for persistent conversation history +- `CosmosCheckpointStorage` for durable workflow checkpoints + +## Install ```bash pip install agent-framework-azure-cosmos --pre ``` -## Azure Cosmos DB History Provider +The package requires Python 3.10 or later, an Azure Cosmos DB for NoSQL account, +and `azure-cosmos` 4.7 or later. Vector collections also require the account's +[NoSQL vector search capability](https://learn.microsoft.com/azure/cosmos-db/vector-search) +to be enabled before use. -The Azure Cosmos DB integration provides `CosmosHistoryProvider` for persistent conversation history storage. +## Authentication and settings -### Basic Usage Example +Pass a caller-owned Azure credential or account key. If constructor values are omitted, +the package reads an explicitly selected `.env` file and then these environment variables: -```python -from azure.identity.aio import DefaultAzureCredential -from agent_framework_azure_cosmos import CosmosHistoryProvider +| Variable | Purpose | +| --- | --- | +| `AZURE_COSMOS_ENDPOINT` | Azure Cosmos DB account endpoint | +| `AZURE_COSMOS_DATABASE_NAME` | Database name | +| `AZURE_COSMOS_CONTAINER_NAME` | Container name for direct collection/provider use | +| `AZURE_COSMOS_KEY` | Account key; omit when passing an Azure credential | -provider = CosmosHistoryProvider( +Explicit constructor values take precedence over a selected `.env` file, which takes +precedence over process environment variables. Injected asynchronous `CosmosClient`, +`DatabaseProxy`, and `ContainerProxy` objects bypass connection settings and remain +caller-owned. + +## Vector store + +Vector collections require: + +- one application-provided string key with storage name `id`; +- a single Hash partition key path `/id`; +- top-level dense vector fields; and +- the container vector and indexing policies derived from the collection definition. + +This makes `get()` and `delete()` unambiguous point operations because the item ID is also +its partition key. IDs must contain 1-1,023 UTF-8 bytes and cannot contain `/`, `\`, +`?`, or `#`; the connector never encodes them. Custom and hierarchical partition keys +are not supported. + +```python +from dataclasses import dataclass +from typing import Annotated + +from agent_framework import VectorStoreField, vectorstoremodel +from agent_framework.azure import CosmosStore +from azure.identity.aio import AzureCliCredential + + +@vectorstoremodel(collection_name="documents") +@dataclass +class Document: + id: Annotated[str, VectorStoreField("key", storage_name="id")] + text: Annotated[str, VectorStoreField("data", is_indexed=True)] + embedding: Annotated[ + list[float], + VectorStoreField( + "vector", + dimensions=1536, + distance_function="cosine_similarity", + ), + ] + + +async with CosmosStore( endpoint="https://.documents.azure.com:443/", - credential=DefaultAzureCredential(), database_name="agent-framework", - container_name="chat-history", -) + credential=AzureCliCredential(), +) as store: + collection = store.get_collection(Document) + await collection.ensure_collection_exists() + await collection.upsert( + [Document(id="doc-1", text="Vector search", embedding=[1.0] + [0.0] * 1535)], + generate_vectors=False, + ) + results = await collection.search(vector=[1.0] + [0.0] * 1535, top=3) + async for result in results: + print(result["record"].text, result["score"]) ``` -Credentials follow the same pattern used by other Azure connectors in the repository: - -- Pass a credential object (for example `DefaultAzureCredential`) -- Or pass a key string directly -- Or set `AZURE_COSMOS_KEY` in the environment +The default vector index is `quantizedFlat`, which supports up to 4,096 dimensions and +uses quantized rather than exact-float recall. Below 1,000 indexed vectors, Azure Cosmos DB +falls back to a full scan. For exact search, explicitly set `index_kind="flat"` on vector +fields with at most 505 dimensions. -Container naming behavior: +Supported vector element types are `float32`, `int8`, and `uint8`. Supported metrics are +cosine similarity, dot product, and Euclidean distance. Scores are returned unchanged: +higher is better for cosine and dot product, while lower is better for Euclidean distance. +Score thresholds are supported for cosine and dot product; Euclidean search is supported +without `score_threshold` because direct `VectorDistance` predicates do not reliably apply +Euclidean cutoffs. +After results are consumed, `SearchResults.metadata` contains the bounded request charge, +last activity ID, and whether the last response page advertised more results; continuation +tokens are not exposed. -- Container name is configured on the provider (`container_name` or `AZURE_COSMOS_CONTAINER_NAME`) -- `session_id` is used as the Cosmos partition key for reads/writes +Vector index tuning is available through the field's `azure_cosmos` provider annotations: -See `samples/02-agents/conversations/cosmos_history_provider.py` for a runnable example. +```python +VectorStoreField( + "vector", + dimensions=1536, + index_kind="disk_ann", + provider_annotations={ + "azure_cosmos": { + "quantizer_type": "spherical", + "quantization_byte_size": 256, + "indexing_search_list_size": 200, + } + }, +) +``` -## Cosmos DB Workflow Checkpoint Storage +Search operation options support `search_list_size_multiplier`, +`quantized_vector_list_multiplier`, `filter_priority`, and `brute_force`. +Values and vectors are always sent as query parameters. -`CosmosCheckpointStorage` implements the `CheckpointStorage` protocol, enabling -durable workflow checkpointing backed by Azure Cosmos DB NoSQL. Workflows can be -paused and resumed across process restarts by persisting checkpoint state in Cosmos DB. +Writes and deletes span `/id` partitions and are not multi-item transactions. The connector +validates the complete input batch before the first request, but a service failure can still +leave an operation partially applied. Retry with stable application keys for idempotent +upserts and deletes. -### Basic Usage +See the [Azure Cosmos DB vector search documentation](https://learn.microsoft.com/azure/cosmos-db/vector-search) +and the [Agent Framework vector-store samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents/vector_stores). -#### Managed Identity / RBAC (recommended for production) +## Conversation history ```python -from azure.identity.aio import DefaultAzureCredential -from agent_framework import WorkflowBuilder -from agent_framework_azure_cosmos import CosmosCheckpointStorage +from agent_framework.azure import CosmosHistoryProvider +from azure.identity.aio import AzureCliCredential -checkpoint_storage = CosmosCheckpointStorage( +provider = CosmosHistoryProvider( endpoint="https://.documents.azure.com:443/", - credential=DefaultAzureCredential(), + credential=AzureCliCredential(), database_name="agent-framework", - container_name="workflow-checkpoints", + container_name="chat-history", ) ``` -#### Account Key +`CosmosHistoryProvider` stores each conversation under its `session_id` partition key. +See the [conversation sample](https://github.com/microsoft/agent-framework/blob/main/python/samples/02-agents/conversations/cosmos_history_provider.py). + +## Workflow checkpoints ```python from agent_framework_azure_cosmos import CosmosCheckpointStorage +from azure.identity.aio import AzureCliCredential -checkpoint_storage = CosmosCheckpointStorage( +storage = CosmosCheckpointStorage( endpoint="https://.documents.azure.com:443/", - credential="", + credential=AzureCliCredential(), database_name="agent-framework", container_name="workflow-checkpoints", ) ``` -#### Then use with a workflow - -```python -from agent_framework import WorkflowBuilder - -# Build a workflow with checkpointing enabled -workflow = WorkflowBuilder( - start_executor=start, - checkpoint_storage=checkpoint_storage, -).build() - -# Run the workflow — checkpoints are automatically saved after each superstep -result = await workflow.run(message="input data") - -# Resume from a checkpoint -latest = await checkpoint_storage.get_latest(workflow_name=workflow.name) -if latest: - resumed = await workflow.run(checkpoint_id=latest.checkpoint_id) -``` - -### Authentication Options - -`CosmosCheckpointStorage` supports the same authentication modes as `CosmosHistoryProvider`: - -- **Managed identity / RBAC** (recommended): Pass `DefaultAzureCredential()`, - `ManagedIdentityCredential()`, or any Azure `TokenCredential` -- **Account key**: Pass a key string via `credential` parameter -- **Environment variables**: Set `AZURE_COSMOS_ENDPOINT`, `AZURE_COSMOS_DATABASE_NAME`, - `AZURE_COSMOS_CONTAINER_NAME`, and `AZURE_COSMOS_KEY` (key not required when using - Azure credentials) -- **Pre-created client**: Pass an existing `CosmosClient` or `ContainerProxy` - -### Database and Container Setup - -The database and container are created automatically on first use (via -`create_database_if_not_exists` and `create_container_if_not_exists`). The container -uses `/workflow_name` as the partition key. You can also pre-create them in the Azure -portal with this partition key configuration. - -### Environment Variables - -| Variable | Description | -|---|---| -| `AZURE_COSMOS_ENDPOINT` | Cosmos DB account endpoint | -| `AZURE_COSMOS_DATABASE_NAME` | Database name | -| `AZURE_COSMOS_CONTAINER_NAME` | Container name | -| `AZURE_COSMOS_KEY` | Account key (optional if using Azure credentials) | - -See `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py` for a standalone example, -or `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py` for an end-to-end -example with Microsoft Foundry agents. +`CosmosCheckpointStorage` uses `/workflow_name` as its partition key and creates its +database and container on first use. See the +[checkpoint sample](https://github.com/microsoft/agent-framework/blob/main/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py). diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py index 66373b0f1d0..1fc45f54a2d 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py @@ -1,17 +1,55 @@ # Copyright (c) Microsoft. All rights reserved. +import importlib import importlib.metadata +from typing import TYPE_CHECKING, Any + +import agent_framework from ._checkpoint_storage import CosmosCheckpointStorage from ._history_provider import CosmosHistoryProvider +if TYPE_CHECKING: + from ._vector_store import AzureCosmosSettings, CosmosCollection, CosmosStore # pyright: ignore[reportUnusedImport] + +_VECTOR_EXPORTS = frozenset({"AzureCosmosSettings", "CosmosCollection", "CosmosStore"}) +_HAS_VECTOR_CORE = all( + hasattr(agent_framework, name) for name in ("BaseVectorCollection", "BaseVectorSearch", "BaseVectorStore") +) + try: __version__ = importlib.metadata.version(__name__) except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode -__all__ = [ - "CosmosCheckpointStorage", - "CosmosHistoryProvider", - "__version__", -] + +def __getattr__(name: str) -> Any: + if name not in _VECTOR_EXPORTS: + raise AttributeError(f"Module {__name__!r} has no attribute {name!r}.") + try: + return getattr(importlib.import_module("._vector_store", __name__), name) + except ImportError as exc: + raise ImportError( + "Azure Cosmos DB vector APIs require agent-framework-core with vector-store support." + ) from exc + + +def __dir__() -> list[str]: + return sorted((*globals(), *_VECTOR_EXPORTS)) + + +if _HAS_VECTOR_CORE: + __all__ = [ + "AzureCosmosSettings", + "CosmosCheckpointStorage", + "CosmosCollection", + "CosmosHistoryProvider", + "CosmosStore", + "__version__", + ] +else: + __all__ = [ + "CosmosCheckpointStorage", + "CosmosHistoryProvider", + "__version__", + ] diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_vector_store.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_vector_store.py new file mode 100644 index 00000000000..13141c589df --- /dev/null +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_vector_store.py @@ -0,0 +1,1425 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Cosmos DB for NoSQL vector collections and stores.""" + +from __future__ import annotations + +import json +import math +import re +from collections.abc import AsyncIterable, AsyncIterator, Callable, Mapping, Sequence +from contextlib import suppress +from typing import Any, ClassVar, Generic, TypeAlias, cast +from weakref import WeakSet + +from agent_framework import ( + BaseVectorCollection, + BaseVectorSearch, + BaseVectorStore, + FilterGroup, + SearchResults, + SecretString, + VectorStoreCollectionDefinition, + VectorStoreField, + load_settings, +) +from agent_framework._telemetry import get_user_agent, mark_feature_used +from agent_framework._vector_filters import FilterExpression +from agent_framework._vectors import EmbeddingClient, SearchType, Vector +from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.core.serialization import AzureJSONEncoder +from azure.cosmos import PartitionKey +from azure.cosmos.aio import ContainerProxy, CosmosClient, DatabaseProxy +from azure.cosmos.exceptions import ( + CosmosHttpResponseError, + CosmosResourceExistsError, + CosmosResourceNotFoundError, +) +from typing_extensions import TypedDict, TypeVar + +from ._feature_usage import FeatureIndex + +ModelT = TypeVar("ModelT", default=Any) +AzureCredentialTypes: TypeAlias = TokenCredential | AsyncTokenCredential + +_ITEM_SIZE_LIMIT = 2 * 1024 * 1024 +_ID_BYTE_LIMIT = 1023 +_MAX_SAFE_INTEGER = 2**53 - 1 +_MAX_JSON_DEPTH = 128 +_VECTOR_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z") +_VECTOR_TYPES = { + None: "float32", + "float": "float32", + "float32": "float32", + "int8": "int8", + "uint8": "uint8", +} +_VECTOR_RANGES: dict[str, tuple[int | float, int | float, bool]] = { + "float32": (-3.4028234663852886e38, 3.4028234663852886e38, False), + "int8": (-128, 127, True), + "uint8": (0, 255, True), +} +_DISTANCES = { + "DEFAULT": "cosine", + "cosine": "cosine", + "cosine_similarity": "cosine", + "dotproduct": "dotproduct", + "dot_prod": "dotproduct", + "euclidean": "euclidean", + "euclidean_distance": "euclidean", +} +_INDEX_KINDS = { + "default": "quantizedFlat", + "flat": "flat", + "quantized_flat": "quantizedFlat", + "quantizedFlat": "quantizedFlat", + "disk_ann": "diskANN", + "diskANN": "diskANN", +} +_FIELD_ANNOTATIONS = { + "data_type", + "quantizer_type", + "quantization_byte_size", + "indexing_search_list_size", +} +_SEARCH_OPTIONS = { + "search_list_size_multiplier", + "quantized_vector_list_multiplier", + "filter_priority", + "brute_force", +} +_FILTER_SCALAR_TYPES = (str, bool, int, float) + + +class AzureCosmosSettings(TypedDict, total=False): + """Cosmos connection settings resolved from explicit values, a selected .env file, or the environment.""" + + endpoint: str | None + database_name: str | None + container_name: str | None + key: SecretString | None + + +def _validate_operation_options(options: Mapping[str, Any] | None, allowed: set[str]) -> dict[str, Any]: + result = dict(options or {}) + if unknown := result.keys() - allowed: + raise ValueError(f"Unsupported Azure Cosmos DB option(s): {', '.join(sorted(unknown))}.") + return result + + +def _validate_resource_name(value: str, kind: str) -> str: + if not isinstance(value, str) or not value or len(value) > 255: + raise ValueError(f"Cosmos {kind} name must contain 1-255 characters.") + return value + + +def _validate_credential(credential: str | SecretString | AzureCredentialTypes | None) -> None: + if ( + credential is not None + and not isinstance(credential, (str, SecretString)) + and not callable(getattr(credential, "get_token", None)) + ): + raise TypeError("credential must be a key string, SecretString, TokenCredential, or AsyncTokenCredential.") + + +def _load_connection_settings( + *, + endpoint: str | None, + database_name: str | None, + container_name: str | None, + credential: str | SecretString | AzureCredentialTypes | None, + require_container: bool, + env_file_path: str | None, + env_file_encoding: str | None, +) -> tuple[CosmosClient, str, str | None]: + _validate_credential(credential) + required_fields = ["endpoint", "database_name"] + if require_container: + required_fields.append("container_name") + if credential is None: + required_fields.append("key") + settings = load_settings( + AzureCosmosSettings, + env_prefix="AZURE_COSMOS_", + required_fields=required_fields, + endpoint=endpoint, + database_name=database_name, + container_name=container_name, + key=credential if isinstance(credential, (str, SecretString)) else None, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + resolved_endpoint = settings.get("endpoint") + resolved_database = settings.get("database_name") + resolved_container = settings.get("container_name") + if not isinstance(resolved_endpoint, str): + raise TypeError("endpoint must be a string.") + if not isinstance(resolved_database, str): + raise TypeError("database_name must be a string.") + _validate_resource_name(resolved_database, "database") + if resolved_container is not None: + _validate_resource_name(resolved_container, "container") + resolved_credential: str | AzureCredentialTypes + if isinstance(credential, SecretString): + resolved_credential = credential.get_secret_value() + elif credential is not None: + resolved_credential = credential + else: + key = settings.get("key") + if not isinstance(key, SecretString): + raise TypeError("key must be a string or SecretString.") + resolved_credential = key.get_secret_value() + client = CosmosClient( + url=resolved_endpoint, + credential=resolved_credential, # type: ignore[arg-type] + user_agent_suffix=get_user_agent(), + ) + return client, resolved_database, resolved_container + + +class _CosmosConnection: + """Share one database proxy while closing only a client created by this connector.""" + + def __init__( + self, + *, + cosmos_client: CosmosClient | None, + database_client: DatabaseProxy | None, + database_name: str, + owns_client: bool, + create_database: bool, + ) -> None: + self.cosmos_client = cosmos_client + self.database_client = database_client + self.database_name = database_name + self.owns_client = owns_client + self.create_database = create_database + self.closed = False + self._database_ready = False + + def ensure_open(self) -> None: + if self.closed: + raise RuntimeError("Azure Cosmos DB vector store is closed.") + + async def get_database(self) -> DatabaseProxy: + self.ensure_open() + if self.database_client is not None: + if not self._database_ready: + await self.database_client.read() + self._database_ready = True + return self.database_client + if self.cosmos_client is None: + raise RuntimeError("Cosmos client is not initialized.") + if self.create_database: + try: + self.database_client = await self.cosmos_client.create_database(id=self.database_name) + except CosmosResourceExistsError: + self.database_client = self.cosmos_client.get_database_client(self.database_name) + await self.database_client.read() + else: + self.database_client = self.cosmos_client.get_database_client(self.database_name) + await self.database_client.read() + self._database_ready = True + return self.database_client + + async def close(self) -> None: + if not self.closed: + self.closed = True + if self.owns_client and self.cosmos_client is not None: + await self.cosmos_client.close() + + +def _connection_from_clients( + *, + endpoint: str | None, + database_name: str | None, + credential: str | SecretString | AzureCredentialTypes | None, + cosmos_client: CosmosClient | None, + database_client: DatabaseProxy | None, + create_database: bool, + env_file_path: str | None, + env_file_encoding: str | None, +) -> _CosmosConnection: + if cosmos_client is not None and database_client is not None: + raise ValueError("Provide at most one of cosmos_client or database_client.") + if database_client is not None: + if any(value is not None for value in (endpoint, database_name, credential, env_file_path, env_file_encoding)): + raise ValueError( + "database_client cannot be combined with endpoint, database_name, credential, or env_file options." + ) + if create_database: + raise ValueError("create_database cannot be used with an injected database_client.") + resolved_name = getattr(database_client, "id", None) + if not isinstance(resolved_name, str) or not resolved_name: + raise ValueError("Injected database_client must expose a non-empty string id.") + return _CosmosConnection( + cosmos_client=None, + database_client=database_client, + database_name=resolved_name, + owns_client=False, + create_database=False, + ) + if cosmos_client is not None: + if any(value is not None for value in (endpoint, credential, env_file_path, env_file_encoding)): + raise ValueError("cosmos_client cannot be combined with endpoint, credential, or env_file options.") + if database_name is None: + raise ValueError("database_name is required with an injected cosmos_client.") + return _CosmosConnection( + cosmos_client=cosmos_client, + database_client=None, + database_name=_validate_resource_name(database_name, "database"), + owns_client=False, + create_database=create_database, + ) + client, resolved_database, _ = _load_connection_settings( + endpoint=endpoint, + database_name=database_name, + container_name=None, + credential=credential, + require_container=False, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + return _CosmosConnection( + cosmos_client=client, + database_client=None, + database_name=resolved_database, + owns_client=True, + create_database=create_database, + ) + + +def _property_access(name: str) -> str: + if not isinstance(name, str) or not name or "\x00" in name: + raise ValueError("Cosmos field storage names must be non-empty strings without NUL.") + return f"c[{json.dumps(name, ensure_ascii=True)}]" + + +def _object_projection(names: Sequence[str]) -> str: + return "{" + ", ".join(f"{json.dumps(name, ensure_ascii=True)}: {_property_access(name)}" for name in names) + "}" + + +def _policy_path(name: str, suffix: str = "") -> str: + if _VECTOR_NAME.fullmatch(name): + return f"/{name}{suffix}" + escaped = json.dumps(name, ensure_ascii=True) + return f"/{escaped}{suffix}" + + +def _normalize_policy_path(path: Any) -> str: + if not isinstance(path, str): + raise ValueError("Cosmos policy paths must be strings.") + if not path.startswith('/"'): + return path + escaped = False + for index in range(2, len(path)): + char = path[index] + if char == '"' and not escaped: + segment = json.loads(path[1 : index + 1]) + suffix = path[index + 1 :] + if isinstance(segment, str) and _VECTOR_NAME.fullmatch(segment): + return f"/{segment}{suffix}" + return f"/{json.dumps(segment, ensure_ascii=True)}{suffix}" + escaped = char == "\\" and not escaped + if char != "\\": + escaped = False + raise ValueError(f"Invalid Cosmos policy path '{path}'.") + + +def _field_annotations(field: VectorStoreField) -> dict[str, Any]: + raw = field.provider_annotations.get("azure_cosmos") + if raw is None: + return {} + if not isinstance(raw, Mapping): + raise TypeError("Vector field azure_cosmos annotations must be a mapping.") + options = dict(cast(Mapping[str, Any], raw)) + if any(not isinstance(name, str) for name in options): + raise TypeError("Vector field azure_cosmos annotation names must be strings.") + if unknown := options.keys() - _FIELD_ANNOTATIONS: + raise ValueError(f"Unsupported Azure Cosmos DB field annotation(s): {', '.join(sorted(unknown))}.") + return options + + +def _prepare_vector_config(field: VectorStoreField) -> dict[str, Any]: + storage_name = field.storage_name or field.name + if not _VECTOR_NAME.fullmatch(storage_name): + raise ValueError("Cosmos vector storage names must be top-level ASCII identifiers.") + options = _field_annotations(field) + declared_type = options.pop("data_type", field.type_) + if declared_type is not None and not isinstance(declared_type, str): + raise TypeError("Cosmos vector data_type must be a string.") + if declared_type not in _VECTOR_TYPES: + raise NotImplementedError(f"Cosmos vector field '{field.name}' requires float32, int8, or uint8 elements.") + data_type = _VECTOR_TYPES[declared_type] + try: + distance = _DISTANCES[field.distance_function or "DEFAULT"] + except KeyError: + raise NotImplementedError(f"Unsupported Cosmos vector distance '{field.distance_function}'.") from None + try: + index_kind = _INDEX_KINDS[field.index_kind or "default"] + except KeyError: + raise NotImplementedError(f"Unsupported Cosmos vector index kind '{field.index_kind}'.") from None + dimensions = field.dimensions + maximum = 505 if index_kind == "flat" else 4096 + if dimensions is None or dimensions > maximum: + raise ValueError(f"Cosmos {index_kind} vector field '{field.name}' supports at most {maximum} dimensions.") + index_options: dict[str, Any] = {} + quantizer_type = options.pop("quantizer_type", None) + if quantizer_type is not None: + if index_kind == "flat": + raise ValueError("quantizer_type is only supported by quantizedFlat and diskANN indexes.") + if quantizer_type not in ("product", "spherical"): + raise ValueError("quantizer_type must be 'product' or 'spherical'.") + index_options["quantizerType"] = quantizer_type + quantization_bytes = options.pop("quantization_byte_size", None) + if quantization_bytes is not None: + if index_kind == "flat": + raise ValueError("quantization_byte_size is only supported by quantizedFlat and diskANN indexes.") + maximum_quantization_bytes = min(512, dimensions) + if type(quantization_bytes) is not int or not 4 <= quantization_bytes <= maximum_quantization_bytes: + raise ValueError(f"quantization_byte_size must be an integer between 4 and {maximum_quantization_bytes}.") + index_options["quantizationByteSize"] = quantization_bytes + indexing_list_size = options.pop("indexing_search_list_size", None) + if indexing_list_size is not None: + if index_kind != "diskANN": + raise ValueError("indexing_search_list_size is only supported by diskANN indexes.") + if type(indexing_list_size) is not int or not 25 <= indexing_list_size <= 500: + raise ValueError("indexing_search_list_size must be an integer between 25 and 500.") + index_options["indexingSearchListSize"] = indexing_list_size + return { + "field": field, + "storage_name": storage_name, + "path": f"/{storage_name}", + "data_type": data_type, + "distance": distance, + "index_kind": index_kind, + "index_options": index_options, + } + + +def _prepare_schema( + definition: VectorStoreCollectionDefinition, +) -> tuple[dict[str, Any], dict[str, Any], dict[str, dict[str, Any]]]: + key = definition.key_field + if key.storage_name != "id" and key.name != "id": + raise ValueError("Cosmos vector collections require the key storage name 'id'.") + if definition.key_field_storage_name != "id": + raise ValueError("Cosmos vector collections require the key storage name 'id'.") + if key.type_ != "str": + raise ValueError("Cosmos vector collections require string keys.") + if key.is_auto_generated: + raise NotImplementedError("Cosmos vector collections require application-provided keys.") + if not definition.vector_fields: + raise ValueError("Cosmos vector collections require at least one vector field.") + configs: dict[str, dict[str, Any]] = {} + embeddings: list[dict[str, Any]] = [] + vector_indexes: list[dict[str, Any]] = [] + excluded_paths: list[dict[str, str]] = [{"path": "/_etag/?"}] + for field in definition.fields: + if field.field_type != "vector": + if field.provider_annotations.get("azure_cosmos") is not None: + raise ValueError("azure_cosmos field annotations are supported only on vector fields.") + if field.is_full_text_indexed: + raise NotImplementedError("CosmosCollection does not provide full-text or hybrid search.") + if field.field_type == "data" and field.is_indexed is False: + excluded_paths.append({"path": _policy_path(field.storage_name or field.name, "/*")}) + continue + config = _prepare_vector_config(field) + configs[field.name] = config + embeddings.append({ + "path": config["path"], + "dataType": config["data_type"], + "distanceFunction": config["distance"], + "dimensions": field.dimensions, + }) + vector_indexes.append({ + "path": config["path"], + "type": config["index_kind"], + **config["index_options"], + }) + excluded_paths.append({"path": f"{config['path']}/*"}) + vector_policy = {"vectorEmbeddings": embeddings} + indexing_policy = { + "indexingMode": "consistent", + "automatic": True, + "includedPaths": [{"path": "/*"}], + "excludedPaths": excluded_paths, + "vectorIndexes": vector_indexes, + } + return vector_policy, indexing_policy, configs + + +def _required_indexed_paths(definition: VectorStoreCollectionDefinition) -> set[str]: + paths: set[str] = set() + for field in definition.data_fields: + if field.is_indexed is False: + continue + storage_name = field.storage_name or field.name + paths.add(_normalize_policy_path(_policy_path(storage_name, "/*"))) + paths.add(_normalize_policy_path(_policy_path(storage_name, "/?"))) + return paths + + +def _policy_entries(policy: Mapping[str, Any], name: str) -> list[Mapping[str, Any]]: + entries = policy.get(name) + if not isinstance(entries, list): + raise ValueError(f"Existing Cosmos policy has an invalid '{name}' value.") + typed_entries: list[Any] = entries # pyright: ignore[reportUnknownVariableType] + if not all(isinstance(entry, Mapping) for entry in typed_entries): + raise ValueError(f"Existing Cosmos policy has an invalid '{name}' value.") + return [cast(Mapping[str, Any], entry) for entry in typed_entries] + + +def _validate_existing_policies( + properties: Mapping[str, Any], + *, + vector_policy: Mapping[str, Any], + indexing_policy: Mapping[str, Any], + required_indexed_paths: set[str], +) -> None: + partition = properties.get("partitionKey") + if not isinstance(partition, Mapping): + raise ValueError("Existing Cosmos container is missing its partition-key policy.") + typed_partition = cast(Mapping[str, Any], partition) + paths = typed_partition.get("paths") + kind = typed_partition.get("kind") + if paths != ["/id"] or not isinstance(kind, str) or kind.lower() != "hash": + raise ValueError("Existing Cosmos container must use the single Hash partition key path '/id'.") + + actual_vector = properties.get("vectorEmbeddingPolicy") + if not isinstance(actual_vector, Mapping): + raise ValueError("Existing Cosmos container has no vector embedding policy.") + expected_embeddings = { + ( + _normalize_policy_path(entry.get("path")), + str(entry.get("dataType", "")).lower(), + str(entry.get("distanceFunction", "")).lower(), + entry.get("dimensions"), + ) + for entry in _policy_entries(vector_policy, "vectorEmbeddings") + } + actual_embeddings = { + ( + _normalize_policy_path(entry.get("path")), + str(entry.get("dataType", "")).lower(), + str(entry.get("distanceFunction", "")).lower(), + entry.get("dimensions"), + ) + for entry in _policy_entries(cast(Mapping[str, Any], actual_vector), "vectorEmbeddings") + } + if actual_embeddings != expected_embeddings: + raise ValueError("Existing Cosmos vector embedding policy is incompatible with the collection definition.") + + actual_indexing = properties.get("indexingPolicy") + if not isinstance(actual_indexing, Mapping): + raise ValueError("Existing Cosmos container has no indexing policy.") + typed_indexing = cast(Mapping[str, Any], actual_indexing) + mode = typed_indexing.get("indexingMode", "consistent") + if not isinstance(mode, str) or mode.lower() != "consistent" or typed_indexing.get("automatic", True) is not True: + raise ValueError("Existing Cosmos container must use automatic consistent indexing.") + included = {_normalize_policy_path(entry.get("path")) for entry in _policy_entries(typed_indexing, "includedPaths")} + if "/*" not in included: + raise ValueError("Existing Cosmos indexing policy must include the root path '/*'.") + expected_excluded = { + _normalize_policy_path(entry.get("path")) for entry in _policy_entries(indexing_policy, "excludedPaths") + } + actual_excluded = { + _normalize_policy_path(entry.get("path")) for entry in _policy_entries(typed_indexing, "excludedPaths") + } + if not expected_excluded <= actual_excluded: + raise ValueError("Existing Cosmos indexing policy does not exclude all required vector paths.") + if actual_excluded & required_indexed_paths: + raise ValueError("Existing Cosmos indexing policy excludes a data field that must remain indexed.") + + expected_indexes = { + _normalize_policy_path(entry.get("path")): entry for entry in _policy_entries(indexing_policy, "vectorIndexes") + } + actual_indexes = { + _normalize_policy_path(entry.get("path")): entry for entry in _policy_entries(typed_indexing, "vectorIndexes") + } + if actual_indexes.keys() != expected_indexes.keys(): + raise ValueError("Existing Cosmos vector index paths are incompatible with the collection definition.") + for path, expected in expected_indexes.items(): + actual = actual_indexes[path] + if str(actual.get("type", "")).lower() != str(expected.get("type", "")).lower(): + raise ValueError(f"Existing Cosmos vector index type for '{path}' is incompatible.") + for option in ("quantizerType", "quantizationByteSize", "indexingSearchListSize"): + if option in expected and actual.get(option) != expected[option]: + raise ValueError(f"Existing Cosmos vector index option '{option}' for '{path}' is incompatible.") + actual_quantizer = actual.get("quantizerType") + if "quantizerType" not in expected and actual_quantizer not in (None, "product"): + raise ValueError(f"Existing Cosmos vector index quantizer for '{path}' is incompatible.") + + +def _validate_key(value: Any) -> str: + if not isinstance(value, str): + raise TypeError("Cosmos item keys must be strings.") + size = len(value.encode("utf-8")) + if not value or size > _ID_BYTE_LIMIT or any(char in value for char in "/\\?#"): + raise ValueError("Cosmos item keys must contain 1-1023 UTF-8 bytes and cannot contain '/', '\\', '?', or '#'.") + return value + + +def _validate_json(value: Any, *, path: str, depth: int = 0) -> None: + if depth > _MAX_JSON_DEPTH: + raise ValueError(f"{path} exceeds Cosmos DB's maximum JSON nesting depth of {_MAX_JSON_DEPTH}.") + if value is None or isinstance(value, (str, bool)): + return + if isinstance(value, int): + if not -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER: + raise ValueError(f"{path} must fit exactly in an IEEE 754 binary64 JSON number.") + return + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError(f"{path} must contain only finite JSON numbers.") + return + if isinstance(value, list): + items: list[Any] = value # pyright: ignore[reportUnknownVariableType] + for index, item in enumerate(items): + _validate_json(item, path=f"{path}[{index}]", depth=depth + 1) + return + if isinstance(value, dict): + mapping: dict[Any, Any] = value # pyright: ignore[reportUnknownVariableType] + for key, item in mapping.items(): + if not isinstance(key, str): + raise TypeError(f"{path} object keys must be strings.") + _validate_json(item, path=f"{path}.{key}", depth=depth + 1) + return + raise TypeError(f"{path} must be JSON-compatible, not {type(value).__name__}.") + + +def _validate_data_field(field: VectorStoreField, value: Any) -> None: + if value is None or field.type_ is None: + return + kind = field.type_ + valid = ( + (kind == "str" and isinstance(value, str)) + or (kind == "bool" and type(value) is bool) + or (kind == "int" and type(value) is int) + or (kind == "float" and type(value) in (int, float)) + or (kind in ("list", "tuple", "set", "Sequence") and isinstance(value, list)) + or (kind == "dict" and isinstance(value, dict)) + ) + if not valid: + raise TypeError(f"Cosmos field '{field.name}' requires a value of declared type '{kind}'.") + + +def _validate_vector(value: Any, config: dict[str, Any]) -> None: + field = cast(VectorStoreField, config["field"]) + data_type = cast(str, config["data_type"]) + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + raise TypeError(f"Cosmos vector '{field.name}' must be a dense numeric sequence.") + typed_value: Sequence[Any] = value # pyright: ignore[reportUnknownVariableType] + if len(typed_value) != field.dimensions: + raise ValueError(f"Cosmos vector '{field.name}' requires exactly {field.dimensions} dimensions.") + minimum, maximum, integral = _VECTOR_RANGES[data_type] + for item in typed_value: + if isinstance(item, bool) or not isinstance(item, (int, float)) or (integral and type(item) is not int): + raise TypeError( + f"Cosmos {data_type} vector '{field.name}' requires " + f"{'integer' if integral else 'numeric'} elements without booleans." + ) + if not math.isfinite(item) or not minimum <= item <= maximum: + raise ValueError( + f"Cosmos {data_type} vector '{field.name}' values must be finite and within [{minimum}, {maximum}]." + ) + + +def _filter_value_compatible(field: VectorStoreField, value: Any) -> bool: + if field.field_type == "key": + return isinstance(value, str) + return ( + field.type_ is None + or (field.type_ == "str" and isinstance(value, str)) + or (field.type_ == "bool" and type(value) is bool) + or (field.type_ == "int" and type(value) is int) + or (field.type_ == "float" and type(value) in (int, float)) + ) + + +def _validate_filter_scalar(value: Any, *, allow_none: bool = False) -> None: + if value is None and allow_none: + return + if not isinstance(value, _FILTER_SCALAR_TYPES): + raise NotImplementedError("Cosmos filters support only scalar string, boolean, and numeric literals.") + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("Cosmos numeric filter values must be finite.") + if isinstance(value, int) and not isinstance(value, bool) and not -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER: + raise ValueError("Cosmos integer filter values must fit exactly in IEEE 754 binary64.") + + +def _add_parameter(parameters: list[dict[str, Any]], value: Any, prefix: str = "filter") -> str: + name = f"@{prefix}_{len(parameters)}" + parameters.append({"name": name, "value": value}) + return name + + +def _query_metadata_hook(metadata: dict[str, Any]) -> Callable[[Mapping[str, str], Any], None]: + def response_hook(headers: Mapping[str, str], _: Any) -> None: + charge = headers.get("x-ms-request-charge") + if charge is not None: + try: + metadata["request_charge"] = float(metadata["request_charge"]) + float(charge) + except (TypeError, ValueError) as exc: + raise IntegrationInvalidResponseException( + "Cosmos query returned an invalid request-charge header." + ) from exc + activity_id = headers.get("x-ms-activity-id") + if activity_id: + metadata["activity_id"] = activity_id + metadata["has_more_results"] = bool(headers.get("x-ms-continuation")) + + return response_hook + + +async def _skip_results( + results: AsyncIterable[Mapping[str, Any]], + skip: int, +) -> AsyncIterator[Mapping[str, Any]]: + index = 0 + async for result in results: + if index < skip: + index += 1 + continue + yield result + + +class CosmosCollection(BaseVectorCollection[str, ModelT], BaseVectorSearch[str, ModelT], Generic[ModelT]): + """An Azure Cosmos DB for NoSQL container with vector indexing and search.""" + + supported_key_types: ClassVar[set[str] | None] = {"str"} + supported_vector_types: ClassVar[set[str] | None] = {"float", "float32", "int8", "uint8"} + supported_search_types: ClassVar[set[SearchType]] = {"vector"} + + def __init__( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + endpoint: str | None = None, + database_name: str | None = None, + credential: str | SecretString | AzureCredentialTypes | None = None, + cosmos_client: CosmosClient | None = None, + database_client: DatabaseProxy | None = None, + container_client: ContainerProxy | None = None, + create_database: bool = False, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + _connection: _CosmosConnection | None = None, + ) -> None: + """Configure a Cosmos vector collection without contacting the service. + + Args: + record_type: Registered vector model type, or dict with an explicit definition. + definition: Optional collection definition for dictionary records. + collection_name: Container name, or ``AZURE_COSMOS_CONTAINER_NAME``. + embedding_generator: Optional local embedding client. + endpoint: Cosmos account endpoint, or ``AZURE_COSMOS_ENDPOINT``. + database_name: Database name, or ``AZURE_COSMOS_DATABASE_NAME``. + credential: Caller-owned Azure credential or key, falling back to ``AZURE_COSMOS_KEY``. + cosmos_client: Caller-owned asynchronous Cosmos account client. + database_client: Caller-owned asynchronous Cosmos database proxy. + container_client: Caller-owned asynchronous Cosmos container proxy. + create_database: Allow explicit database creation during the first service operation. + env_file_path: Optional settings file used only when no SDK client is injected. + env_file_encoding: Settings file encoding. + """ + if sum(client is not None for client in (cosmos_client, database_client, container_client)) > 1: + raise ValueError("Provide at most one of cosmos_client, database_client, or container_client.") + if _connection is not None and any( + value is not None + for value in ( + endpoint, + database_name, + credential, + cosmos_client, + database_client, + container_client, + env_file_path, + env_file_encoding, + ) + ): + raise ValueError("A store-provided connection cannot be combined with connection settings or clients.") + if _connection is not None and create_database: + raise ValueError("Store-created collections inherit database ownership from the store.") + + resolved_name = collection_name + owns_connection = False + if container_client is not None: + if ( + any( + value is not None + for value in (endpoint, database_name, credential, env_file_path, env_file_encoding) + ) + or create_database + ): + raise ValueError( + "container_client cannot be combined with connection, database, env_file, or creation options." + ) + client_name = getattr(container_client, "id", None) + if isinstance(client_name, str) and client_name: + if resolved_name is not None and resolved_name != client_name: + raise ValueError("collection_name must match the injected container_client id.") + resolved_name = client_name + self._connection = None + elif _connection is not None: + self._connection = _connection + elif cosmos_client is not None or database_client is not None: + self._connection = _connection_from_clients( + endpoint=endpoint, + database_name=database_name, + credential=credential, + cosmos_client=cosmos_client, + database_client=database_client, + create_database=create_database, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + else: + registered_definition = definition or getattr(record_type, "__vectorstoremodel_definition__", None) + configured_name = resolved_name or getattr(registered_definition, "collection_name", None) + client, resolved_database, settings_name = _load_connection_settings( + endpoint=endpoint, + database_name=database_name, + container_name=configured_name, + credential=credential, + require_container=True, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + resolved_name = settings_name + self._connection = _CosmosConnection( + cosmos_client=client, + database_client=None, + database_name=resolved_database, + owns_client=True, + create_database=create_database, + ) + owns_connection = True + + super().__init__( + record_type, + definition=definition, + collection_name=resolved_name, + embedding_generator=embedding_generator, + managed_client=owns_connection, + ) + _validate_resource_name(self.collection_name, "container") + self._vector_policy, self._indexing_policy, self._vector_configs = _prepare_schema(self.definition) + self._container_client = container_client + self._container_validated = False + self._owns_connection = owns_connection + self._closed = False + self._on_close: Callable[[CosmosCollection[ModelT]], None] | None = None + self._on_delete: Callable[[str], None] | None = None + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError("Cosmos collection is closed.") + if self._connection is not None: + self._connection.ensure_open() + mark_feature_used(FeatureIndex.AZURE_COSMOS) + + async def _get_database(self) -> DatabaseProxy: + self._require_open() + if self._connection is None: + raise NotImplementedError("ContainerProxy injection does not provide database lifecycle operations.") + return await self._connection.get_database() + + async def _read_and_validate_container(self, container: ContainerProxy) -> ContainerProxy: + properties = await container.read() + _validate_existing_policies( + cast(Mapping[str, Any], properties), + vector_policy=self._vector_policy, + indexing_policy=self._indexing_policy, + required_indexed_paths=_required_indexed_paths(self.definition), + ) + self._container_validated = True + return container + + async def _get_container(self) -> ContainerProxy: + self._require_open() + if self._container_client is None: + database = await self._get_database() + self._container_client = database.get_container_client(self.collection_name) + if not self._container_validated: + await self._read_and_validate_container(self._container_client) + return self._container_client + + async def collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> bool: + """Check whether the configured container exists.""" + _validate_operation_options(operation_options, set()) + self._require_open() + container = self._container_client + if container is None: + database = await self._get_database() + container = database.get_container_client(self.collection_name) + try: + await container.read() + except CosmosResourceNotFoundError: + return False + return True + + async def ensure_collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> None: + """Create an absent container and validate an existing container without updating it.""" + _validate_operation_options(operation_options, set()) + self._require_open() + if self._container_client is not None and self._connection is None: + await self._read_and_validate_container(self._container_client) + return + database = await self._get_database() + container = database.get_container_client(self.collection_name) + try: + await container.read() + except CosmosResourceNotFoundError: + try: + container = await database.create_container( + id=self.collection_name, + partition_key=PartitionKey(path="/id"), + indexing_policy=self._indexing_policy, + vector_embedding_policy=self._vector_policy, + ) + except CosmosResourceExistsError: + container = database.get_container_client(self.collection_name) + self._container_client = await self._read_and_validate_container(container) + + async def ensure_collection_deleted(self, *, operation_options: Mapping[str, Any] | None = None) -> None: + """Delete the configured container when a database proxy is available.""" + _validate_operation_options(operation_options, set()) + database = await self._get_database() + with suppress(CosmosResourceNotFoundError): + await database.delete_container(self.collection_name) + self._invalidate_container() + if self._on_delete is not None: + self._on_delete(self.collection_name) + + def _invalidate_container(self) -> None: + self._container_client = None + self._container_validated = False + + def _serialize_dicts_to_store_models( + self, + records: Sequence[dict[str, Any]], + *, + context: Mapping[str, Any] | None = None, + ) -> Sequence[Any]: + del context + storage_fields = {field.storage_name or field.name: field for field in self.definition.fields} + for index, record in enumerate(records): + missing = storage_fields.keys() - record.keys() + if missing: + raise ValueError( + f"Cosmos record at position {index} is missing field(s): {', '.join(sorted(missing))}." + ) + _validate_key(record["id"]) + for storage_name, field in storage_fields.items(): + value = record[storage_name] + if field.field_type == "vector": + if value is not None: + _validate_vector(value, self._vector_configs[field.name]) + else: + _validate_data_field(field, value) + _validate_json(value, path=f"record[{index}].{storage_name}") + encoded = json.dumps( + record, + cls=AzureJSONEncoder, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + if len(encoded) > _ITEM_SIZE_LIMIT: + raise ValueError(f"Cosmos record at position {index} exceeds the 2 MiB item limit.") + return records + + def _project_item(self, item: Mapping[str, Any], include_vectors: bool) -> dict[str, Any]: + names = self.definition.get_storage_names(include_vector_fields=include_vectors) + try: + return {name: item[name] for name in names} + except KeyError as exc: + raise IntegrationInvalidResponseException( + f"Cosmos response is missing required field '{exc.args[0]}'." + ) from exc + + async def _inner_upsert( + self, + records: Sequence[Any], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[str]: + _validate_operation_options(operation_options, set()) + container = await self._get_container() + typed_records = cast(Sequence[dict[str, Any]], records) + keys = [cast(str, record["id"]) for record in typed_records] + for index, record in enumerate(typed_records): + try: + await container.upsert_item(body=record) + except CosmosHttpResponseError as exc: + raise IntegrationException( + f"Cosmos upsert partially completed {index}/{len(records)} records; " + f"the failure corresponds to input index {index}. Retry with the same application keys." + ) from exc + return keys + + def _resolve_filter_field(self, expression: Any) -> VectorStoreField: + if "." in expression.field_name: + raise NotImplementedError("CosmosCollection does not support nested portable filter paths.") + field = self.definition.try_get_field(expression.field_name) + if field is None: + raise ValueError(f"Unknown Cosmos filter field '{expression.field_name}'.") + if field.field_type == "vector": + raise NotImplementedError("Cosmos vector fields cannot be used in portable filters.") + if field.field_type != "key" and field.is_indexed is False: + raise ValueError(f"Cosmos filter field '{field.name}' is excluded from indexing.") + return field + + def _translate_filter( + self, + expression: FilterExpression, + parameters: list[dict[str, Any]], + ) -> str: + if isinstance(expression, FilterGroup): + children = [self._translate_filter(child, parameters) for child in expression.filters] + if expression.operator == "not": + return f"(NOT {children[0]})" + delimiter = " AND " if expression.operator == "and" else " OR " + return "(" + delimiter.join(children) + ")" + + field = self._resolve_filter_field(expression) + access = _property_access(field.storage_name or field.name) + operator = expression.operator + value = expression.value + if operator == "exists": + return f"IS_DEFINED({access})" + if operator == "is_null": + return f"(IS_DEFINED({access}) AND IS_NULL({access}))" + if operator == "is_not_null": + return f"(IS_DEFINED({access}) AND NOT IS_NULL({access}))" + if operator in ("eq", "ne"): + _validate_filter_scalar(value) + if not _filter_value_compatible(field, value): + return "false" if operator == "eq" else f"IS_DEFINED({access})" + parameter = _add_parameter(parameters, value) + if operator == "eq": + return f"(IS_DEFINED({access}) AND {access} = {parameter})" + return f"(IS_DEFINED({access}) AND (IS_NULL({access}) OR {access} != {parameter}))" + if operator in ("gt", "gte", "lt", "lte", "between"): + if field.type_ not in ("str", "int", "float"): + raise NotImplementedError("Cosmos ordered filters require a declared string or numeric field.") + values = list(value) if operator == "between" else [value] + for item in values: + _validate_filter_scalar(item) + if not _filter_value_compatible(field, item): + raise TypeError(f"Cosmos ordered filter value is incompatible with field '{field.name}'.") + if operator == "between": + lower = _add_parameter(parameters, values[0]) + upper = _add_parameter(parameters, values[1]) + return ( + f"(IS_DEFINED({access}) AND NOT IS_NULL({access}) AND {access} >= {lower} AND {access} <= {upper})" + ) + parameter = _add_parameter(parameters, value) + sql_operator = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[operator] + return f"(IS_DEFINED({access}) AND NOT IS_NULL({access}) AND {access} {sql_operator} {parameter})" + if operator in ("in", "not_in"): + values = list(cast(Sequence[Any], value)) + for item in values: + _validate_filter_scalar(item, allow_none=True) + if not values: + return "false" if operator == "in" else f"(IS_DEFINED({access}) AND NOT IS_NULL({access}))" + parameter = _add_parameter(parameters, values) + contained = f"ARRAY_CONTAINS({parameter}, {access})" + if operator == "in": + return f"(IS_DEFINED({access}) AND NOT IS_NULL({access}) AND {contained})" + return f"(IS_DEFINED({access}) AND NOT IS_NULL({access}) AND NOT {contained})" + if operator in ("contains", "contains_any", "contains_all"): + if field.type_ not in ("list", "tuple", "set", "Sequence"): + raise NotImplementedError("Cosmos collection membership filters require a declared collection field.") + values = [value] if operator == "contains" else list(cast(Sequence[Any], value)) + for item in values: + _validate_filter_scalar(item, allow_none=True) + if not values: + return f"IS_ARRAY({access})" if operator == "contains_all" else "false" + clauses = [f"ARRAY_CONTAINS({access}, {_add_parameter(parameters, item)})" for item in values] + delimiter = " AND " if operator == "contains_all" else " OR " + return f"(IS_ARRAY({access}) AND ({delimiter.join(clauses)}))" + if operator in ("starts_with", "ends_with", "contains_text"): + if field.type_ != "str": + raise NotImplementedError("Cosmos string filters require a declared string field.") + if not isinstance(value, str): + raise TypeError("Cosmos string filter values must be strings.") + parameter = _add_parameter(parameters, value) + function = { + "starts_with": "STARTSWITH", + "ends_with": "ENDSWITH", + "contains_text": "CONTAINS", + }[operator] + return f"(IS_STRING({access}) AND {function}({access}, {parameter}))" + raise NotImplementedError(f"CosmosCollection does not support filter operator '{operator}'.") + + def _prepare_filter(self, expression: FilterExpression | None) -> tuple[str | None, list[dict[str, Any]]]: + if expression is None: + return None, [] + parameters: list[dict[str, Any]] = [] + return self._translate_filter(expression, parameters), parameters + + def _prepare_order_by(self, order_by: Mapping[str, bool] | None) -> str | None: + if not order_by: + return None + if len(order_by) > 1: + raise NotImplementedError("CosmosCollection supports one order_by field without a composite index.") + name, ascending = next(iter(order_by.items())) + if not isinstance(ascending, bool): + raise TypeError(f"Order direction for field '{name}' must be a boolean.") + field = self.definition.try_get_field(name) + if field is None: + raise ValueError(f"Unknown Cosmos order_by field '{name}'.") + if field.field_type == "vector" or (field.field_type != "key" and field.is_indexed is False): + raise ValueError(f"Cosmos order_by field '{name}' must be an indexed key or data field.") + return f"{_property_access(field.storage_name or field.name)} {'ASC' if ascending else 'DESC'}" + + async def _inner_get( + self, + *, + keys: Sequence[str] | None = None, + filter: FilterExpression | None = None, + top: int = 10, + skip: int = 0, + order_by: Mapping[str, bool] | None = None, + include_vectors: bool = False, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[Any]: + _validate_operation_options(operation_options, set()) + if keys is not None: + if order_by or skip: + raise ValueError("Cosmos point reads preserve input order and cannot use order_by or skip.") + validated_keys = [_validate_key(key) for key in keys] + container = await self._get_container() + records: list[dict[str, Any]] = [] + for key in validated_keys: + try: + item = await container.read_item(item=key, partition_key=key) + except CosmosResourceNotFoundError: + continue + records.append(self._project_item(cast(Mapping[str, Any], item), include_vectors)) + return records + + where, parameters = self._prepare_filter(filter) + order = self._prepare_order_by(order_by) + names = self.definition.get_storage_names(include_vector_fields=include_vectors) + # Every composed expression comes from the validated collection definition. + query = f"SELECT VALUE {_object_projection(names)} FROM c" # nosec B608 # ruff: ignore[hardcoded-sql-expression] + if where is not None: + query += f" WHERE {where}" + if order is not None: + query += f" ORDER BY {order}" + parameters.extend([ + {"name": "@skip", "value": skip}, + {"name": "@top", "value": top}, + ]) + query += " OFFSET @skip LIMIT @top" + container = await self._get_container() + items = cast( + AsyncIterable[Mapping[str, Any]], + container.query_items(query=query, parameters=parameters), + ) + return [dict(item) async for item in items] + + async def _inner_delete( + self, + keys: Sequence[str], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + _validate_operation_options(operation_options, set()) + validated_keys = [_validate_key(key) for key in keys] + container = await self._get_container() + for index, key in enumerate(validated_keys): + try: + await container.delete_item(item=key, partition_key=key) + except CosmosResourceNotFoundError: + continue + except CosmosHttpResponseError as exc: + raise IntegrationException( + f"Cosmos delete partially completed {index}/{len(keys)} records; " + f"the failure corresponds to input index {index}. Retry with the same application keys." + ) from exc + + def _prepare_search_options( + self, + config: dict[str, Any], + operation_options: Mapping[str, Any] | None, + parameters: list[dict[str, Any]], + ) -> str: + options = _validate_operation_options(operation_options, _SEARCH_OPTIONS) + brute_force = options.pop("brute_force", False) + if not isinstance(brute_force, bool): + raise TypeError("brute_force must be a boolean.") + native_options: dict[str, Any] = {} + for name, native_name in ( + ("search_list_size_multiplier", "searchListSizeMultiplier"), + ("quantized_vector_list_multiplier", "quantizedVectorListMultiplier"), + ): + value = options.pop(name, None) + if value is not None: + if type(value) is not int or value <= 0: + raise ValueError(f"{name} must be a positive integer.") + if name == "search_list_size_multiplier" and config["index_kind"] != "diskANN": + raise ValueError("search_list_size_multiplier requires a diskANN vector index.") + if config["index_kind"] == "flat": + raise ValueError(f"{name} is not supported by a flat vector index.") + native_options[native_name] = value + filter_priority = options.pop("filter_priority", None) + if filter_priority is not None: + if ( + type(filter_priority) not in (int, float) + or not math.isfinite(filter_priority) + or not 0 <= filter_priority <= 1 + ): + raise ValueError("filter_priority must be a finite number between 0 and 1.") + if config["index_kind"] != "diskANN": + raise ValueError("filter_priority requires a diskANN vector index.") + native_options["filterPriority"] = filter_priority + if not native_options and not brute_force: + return "" + brute_force_parameter = _add_parameter(parameters, brute_force, "brute_force") + options_parameter = _add_parameter(parameters, native_options, "vector_options") + return f", {brute_force_parameter}, {options_parameter}" + + async def _inner_search( + self, + *, + search_type: SearchType, + filter: FilterExpression | None = None, + values: Any | None = None, + vector: Vector | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[Any]: + del values + if search_type != "vector": + raise NotImplementedError("CosmosCollection supports vector search only.") + if additional_property_name is not None: + raise ValueError("additional_property_name is only supported for keyword-hybrid search.") + field = self.definition.try_get_vector_field(vector_property_name) + if field is None: + raise ValueError("Cosmos vector search requires a configured vector field.") + if vector is None: + raise NotImplementedError("CosmosCollection does not support server-side embedding generation.") + config = self._vector_configs[field.name] + if score_threshold is not None and config["distance"] == "euclidean": + raise NotImplementedError( + "Euclidean score_threshold is not supported because direct Cosmos VectorDistance predicates " + "do not reliably enforce Euclidean cutoffs. Omit score_threshold to use Euclidean search." + ) + _validate_vector(vector, config) + parameters: list[dict[str, Any]] = [{"name": "@vector", "value": list(vector)}] + option_arguments = self._prepare_search_options(config, operation_options, parameters) + distance = f"VectorDistance({_property_access(config['storage_name'])}, @vector{option_arguments})" + where, filter_parameters = self._prepare_filter(filter) + parameters.extend(filter_parameters) + if score_threshold is not None: + if type(score_threshold) not in (int, float) or not math.isfinite(score_threshold): + raise ValueError("score_threshold must be a finite number.") + threshold_parameter = _add_parameter(parameters, score_threshold, "threshold") + threshold = f"{distance} >= {threshold_parameter}" + where = threshold if where is None else f"({where} AND {threshold})" + names = self.definition.get_storage_names(include_vector_fields=include_vectors) + result_projection = '{"record": ' + _object_projection(names) + f', "score": {distance}' + "}" + parameters.append({"name": "@top", "value": top + skip}) + query = f"SELECT TOP @top VALUE {result_projection} FROM c" # nosec B608 # ruff: ignore[hardcoded-sql-expression] + if where is not None: + query += f" WHERE {where}" + query += f" ORDER BY {distance}" + metadata: dict[str, Any] = { + "score_kind": (f"{config['distance']}_{'distance' if config['distance'] == 'euclidean' else 'similarity'}"), + "score_direction": "lower_is_better" if config["distance"] == "euclidean" else "higher_is_better", + "request_charge": 0.0, + "activity_id": None, + "has_more_results": False, + } + container = await self._get_container() + items = cast( + AsyncIterable[Mapping[str, Any]], + container.query_items( + query=query, + parameters=parameters, + response_hook=_query_metadata_hook(metadata), + ), + ) + return SearchResults(_skip_results(items, skip), metadata=metadata) + + def _get_record_from_result(self, result: Any) -> Any: + if not isinstance(result, Mapping): + raise IntegrationInvalidResponseException("Cosmos vector search returned an invalid record projection.") + typed_result = cast(Mapping[str, Any], result) + record = typed_result.get("record") + if not isinstance(record, Mapping): + raise IntegrationInvalidResponseException("Cosmos vector search returned an invalid record projection.") + return cast(Mapping[str, Any], record) + + def _get_score_from_result(self, result: Any) -> float | None: + if not isinstance(result, Mapping): + raise IntegrationInvalidResponseException("Cosmos vector search returned an invalid result.") + score = cast(Mapping[str, Any], result).get("score") + if not isinstance(score, (int, float)) or isinstance(score, bool) or not math.isfinite(score): + raise IntegrationInvalidResponseException("Cosmos vector search returned a missing or invalid score.") + return float(score) + + async def close(self) -> None: + """Close only a Cosmos client created by this collection.""" + if not self._closed: + self._closed = True + try: + if self._owns_connection and self._connection is not None: + await self._connection.close() + finally: + on_close, self._on_close = self._on_close, None + self._on_delete = None + if on_close is not None: + on_close(self) + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + await self.close() + + +class CosmosStore(BaseVectorStore): + """Factory and database administration for Azure Cosmos DB for NoSQL vector containers.""" + + def __init__( + self, + *, + endpoint: str | None = None, + database_name: str | None = None, + credential: str | SecretString | AzureCredentialTypes | None = None, + cosmos_client: CosmosClient | None = None, + database_client: DatabaseProxy | None = None, + create_database: bool = False, + embedding_generator: EmbeddingClient | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Create a store from settings or a caller-owned asynchronous SDK client. + + Args: + endpoint: Cosmos account endpoint, or ``AZURE_COSMOS_ENDPOINT``. + database_name: Database name, or ``AZURE_COSMOS_DATABASE_NAME``. + credential: Caller-owned Azure credential or key, falling back to ``AZURE_COSMOS_KEY``. + cosmos_client: Caller-owned asynchronous Cosmos account client. + database_client: Caller-owned asynchronous Cosmos database proxy. + create_database: Allow explicit database creation during the first service operation. + embedding_generator: Default local embedding client for child collections. + env_file_path: Optional settings file used only when no SDK client is injected. + env_file_encoding: Settings file encoding. + """ + connection = _connection_from_clients( + endpoint=endpoint, + database_name=database_name, + credential=credential, + cosmos_client=cosmos_client, + database_client=database_client, + create_database=create_database, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + super().__init__(embedding_generator=embedding_generator, managed_client=connection.owns_client) + self._connection = connection + self.database_name = connection.database_name + self._collections: WeakSet[CosmosCollection[Any]] = WeakSet() + self._closed = False + + def _require_open(self) -> None: + if self._closed: + raise RuntimeError("Cosmos store is closed.") + self._connection.ensure_open() + mark_feature_used(FeatureIndex.AZURE_COSMOS) + + def get_collection( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> CosmosCollection[ModelT]: + """Create a child collection that borrows the store's resolved database connection.""" + self._require_open() + collection = CosmosCollection( + record_type, + definition=definition, + collection_name=collection_name, + embedding_generator=embedding_generator or self.embedding_generator, + _connection=self._connection, + ) + self._collections.add(collection) + collection._on_close = self._collections.discard # pyright: ignore[reportPrivateUsage] + collection._on_delete = self._invalidate_collections # pyright: ignore[reportPrivateUsage] + return collection + + def _invalidate_collections(self, collection_name: str) -> None: + for collection in list(self._collections): + if collection.collection_name == collection_name: + collection._invalidate_container() # pyright: ignore[reportPrivateUsage] + + async def list_collection_names( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[str]: + """List every container in the configured database.""" + _validate_operation_options(operation_options, set()) + self._require_open() + database = await self._connection.get_database() + return [ + item["id"] + async for item in database.list_containers() # pyright: ignore[reportUnknownMemberType] + if isinstance(item, Mapping) and isinstance(item.get("id"), str) + ] + + async def collection_exists( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> bool: + """Check one container directly without listing the database.""" + _validate_operation_options(operation_options, set()) + self._require_open() + database = await self._connection.get_database() + try: + await database.get_container_client(collection_name).read() + except CosmosResourceNotFoundError: + return False + return True + + async def _inner_ensure_collection_deleted( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + _validate_operation_options(operation_options, set()) + self._require_open() + database = await self._connection.get_database() + with suppress(CosmosResourceNotFoundError): + await database.delete_container(collection_name) + self._invalidate_collections(collection_name) + + async def close(self) -> None: + """Close child collections and the owned Cosmos client, leaving injected objects open.""" + if not self._closed: + self._closed = True + for collection in list(self._collections): + await collection.close() + self._collections.clear() + await self._connection.close() + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + await self.close() diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/py.typed b/python/packages/azure-cosmos/agent_framework_azure_cosmos/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 21cd2b31ed5..a25e671513e 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-azure-cosmos" -description = "Azure Cosmos DB history provider integration for Microsoft Agent Framework." +description = "Azure Cosmos DB for NoSQL integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.15.0,<2", - "azure-cosmos>=4.3.0,<5", + "azure-cosmos>=4.7.0,<5", "six>=1.17.0,<2", ] @@ -45,10 +45,12 @@ addopts = "-ra -q -r fEX" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ + "ignore::agent_framework._feature_stage.ExperimentalWarning", "ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*" ] timeout = 120 markers = [ + "flaky: marks tests that depend on external services", "integration: marks tests as integration tests that require external services", ] diff --git a/python/packages/azure-cosmos/tests/azure_cosmos/test_vector_store.py b/python/packages/azure-cosmos/tests/azure_cosmos/test_vector_store.py new file mode 100644 index 00000000000..108b12f3788 --- /dev/null +++ b/python/packages/azure-cosmos/tests/azure_cosmos/test_vector_store.py @@ -0,0 +1,1277 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import copy +import gc +import importlib +import math +from collections.abc import AsyncIterator, Callable, Mapping +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch +from weakref import ref + +import pytest +from agent_framework import ( + Filter, + FilterGroup, + SecretString, + VectorStoreCollectionDefinition, + VectorStoreField, + load_settings, +) +from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException, SettingNotFoundError +from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceExistsError, CosmosResourceNotFoundError + +import agent_framework_azure_cosmos as cosmos_package +import agent_framework_azure_cosmos._vector_store as vector_store_module +from agent_framework_azure_cosmos import AzureCosmosSettings, CosmosCollection, CosmosStore +from agent_framework_azure_cosmos._vector_store import ( + _CosmosConnection, + _normalize_policy_path, + _property_access, + _query_metadata_hook, + _required_indexed_paths, + _validate_existing_policies, + _validate_key, +) + +pytestmark = pytest.mark.filterwarnings("ignore::agent_framework._feature_stage.ExperimentalWarning") + + +def test_package_vector_exports_are_lazy() -> None: + with patch.object(importlib, "import_module", wraps=importlib.import_module) as importer: + package = importlib.reload(cosmos_package) + importer.assert_not_called() + assert "CosmosCollection" not in vars(package) + assert package.CosmosCollection is CosmosCollection + assert {"AzureCosmosSettings", "CosmosCollection", "CosmosStore"} <= set(dir(package)) + assert {"AzureCosmosSettings", "CosmosCollection", "CosmosStore"} <= set(package.__all__) + namespace: dict[str, Any] = {} + exec("from agent_framework_azure_cosmos import *", namespace) + assert namespace["CosmosCollection"] is CosmosCollection + assert namespace["CosmosStore"] is CosmosStore + assert namespace["CosmosHistoryProvider"] is package.CosmosHistoryProvider + export_name = "CosmosStore" + with ( + patch.object(importlib, "import_module", side_effect=ImportError("missing vector core")), + pytest.raises(ImportError, match="core with vector-store support"), + ): + getattr(package, export_name) + missing_name = "not_an_export" + with pytest.raises(AttributeError): + getattr(package, missing_name) + + +def _async_items(items: list[Any]) -> AsyncIterator[Any]: + async def iterator() -> AsyncIterator[Any]: + for item in items: + yield item + + return iterator() + + +def _not_found() -> CosmosResourceNotFoundError: + return CosmosResourceNotFoundError(message="missing") + + +def _http_error() -> CosmosHttpResponseError: + return CosmosHttpResponseError(message="failed") + + +def _definition( + *, + index_kind: str = "default", + distance: str = "cosine_similarity", + dimensions: int = 3, + vector_type: str = "float32", + annotations: Mapping[str, Any] | None = None, + second_vector: bool = False, +) -> VectorStoreCollectionDefinition: + fields = [ + VectorStoreField("key", name="key", storage_name="id", type_="str"), + VectorStoreField("data", name="text", storage_name="content", type_="str", is_indexed=True), + VectorStoreField("data", name="count", type_="int", is_indexed=True), + VectorStoreField("data", name="active", type_="bool", is_indexed=True), + VectorStoreField("data", name="tags", type_="list", is_indexed=True), + VectorStoreField("data", name="optional", type_="str", is_indexed=True), + VectorStoreField( + "vector", + name="vector", + storage_name="embedding", + type_=vector_type, + dimensions=dimensions, + index_kind=index_kind, + distance_function=distance, + provider_annotations=annotations, + ), + ] + if second_vector: + fields.append( + VectorStoreField( + "vector", + name="other_vector", + storage_name="otherEmbedding", + type_="uint8", + dimensions=dimensions, + index_kind="disk_ann", + distance_function="euclidean_distance", + ) + ) + return VectorStoreCollectionDefinition(fields, collection_name="items") + + +def _record(key: str = "one", *, include_vectors: bool = True) -> dict[str, Any]: + record: dict[str, Any] = { + "id": key, + "content": "hello", + "count": 2, + "active": True, + "tags": ["a", "b"], + "optional": None, + } + if include_vectors: + record["embedding"] = [1.0, 0.0, 0.0] + return record + + +def _container_properties(collection: CosmosCollection[Any]) -> dict[str, Any]: + return { + "partitionKey": {"paths": ["/id"], "kind": "Hash"}, + "vectorEmbeddingPolicy": copy.deepcopy(collection._vector_policy), + "indexingPolicy": copy.deepcopy(collection._indexing_policy), + } + + +def _collection( + *, + definition: VectorStoreCollectionDefinition | None = None, + query_results: list[Any] | None = None, +) -> tuple[CosmosCollection[dict[str, Any]], MagicMock]: + container = MagicMock() + container.id = "items" + container.read = AsyncMock() + container.upsert_item = AsyncMock(return_value={}) + container.read_item = AsyncMock() + container.delete_item = AsyncMock(return_value=None) + container.query_items = MagicMock(return_value=_async_items(query_results or [])) + collection = CosmosCollection( + dict, + definition=definition or _definition(), + container_client=container, + ) + container.read.return_value = _container_properties(collection) + return collection, container + + +def test_settings_precedence(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> None: + env_file = tmp_path / "cosmos.env" + env_file.write_text( + "AZURE_COSMOS_ENDPOINT=https://file.documents.azure.com/\n" + "AZURE_COSMOS_DATABASE_NAME=file-db\n" + "AZURE_COSMOS_CONTAINER_NAME=file-container\n" + "AZURE_COSMOS_KEY=file-key\n" + ) + monkeypatch.setenv("AZURE_COSMOS_ENDPOINT", "https://process.documents.azure.com/") + monkeypatch.setenv("AZURE_COSMOS_DATABASE_NAME", "process-db") + settings = load_settings( + AzureCosmosSettings, + env_prefix="AZURE_COSMOS_", + endpoint="https://explicit.documents.azure.com/", + env_file_path=str(env_file), + ) + assert settings["endpoint"] == "https://explicit.documents.azure.com/" + assert settings["database_name"] == "file-db" + assert settings["container_name"] == "file-container" + key = settings["key"] + assert isinstance(key, SecretString) + assert key.get_secret_value() == "file-key" + + +def test_owned_store_uses_masked_key_and_closes_only_client() -> None: + client = MagicMock() + client.close = AsyncMock() + with patch.object(vector_store_module, "CosmosClient", return_value=client) as factory: + store = CosmosStore( + endpoint="https://account.documents.azure.com/", + database_name="db", + credential=SecretString("secret"), + ) + assert store.database_name == "db" + factory.assert_called_once() + assert factory.call_args.kwargs["credential"] == "secret" + assert "secret" not in repr(SecretString("secret")) + + +async def test_owned_store_closes_client() -> None: + client = MagicMock() + client.close = AsyncMock() + with patch.object(vector_store_module, "CosmosClient", return_value=client): + store = CosmosStore( + endpoint="https://account.documents.azure.com/", + database_name="db", + credential="key", + ) + await store.close() + client.close.assert_awaited_once() + await store.close() + client.close.assert_awaited_once() + + +async def test_injected_clients_bypass_settings_and_remain_open() -> None: + client = MagicMock() + client.close = AsyncMock() + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + with patch.object(vector_store_module, "load_settings", side_effect=AssertionError("must not load settings")): + client_store = CosmosStore(cosmos_client=client, database_name="db") + database_store = CosmosStore(database_client=database) + collection, container = _collection() + await collection.collection_exists() + await client_store.close() + await database_store.close() + await collection.close() + client.close.assert_not_awaited() + container.close.assert_not_called() + + +@pytest.mark.parametrize( + "kwargs,match", + [ + ({"cosmos_client": MagicMock()}, "database_name"), + ({"cosmos_client": MagicMock(), "database_client": MagicMock()}, "at most one"), + ({"database_client": MagicMock(), "database_name": "db"}, "cannot be combined"), + ({"database_client": MagicMock(), "create_database": True}, "create_database"), + ({"cosmos_client": MagicMock(), "database_name": "db", "endpoint": "https://x"}, "cannot be combined"), + ], +) +def test_store_rejects_conflicting_injection(kwargs: dict[str, Any], match: str) -> None: + if "database_client" in kwargs: + kwargs["database_client"].id = "db" + with pytest.raises(ValueError, match=match): + CosmosStore(**kwargs) + + +def test_container_injection_rejects_settings_and_name_mismatch() -> None: + container = MagicMock() + container.id = "actual" + with pytest.raises(ValueError, match="cannot be combined"): + CosmosCollection( + dict, + definition=_definition(), + container_client=container, + endpoint="https://account.documents.azure.com/", + ) + with pytest.raises(ValueError, match="must match"): + CosmosCollection( + dict, + definition=_definition(), + collection_name="other", + container_client=container, + ) + + +def test_missing_settings_and_invalid_credential(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "AZURE_COSMOS_ENDPOINT", + "AZURE_COSMOS_DATABASE_NAME", + "AZURE_COSMOS_CONTAINER_NAME", + "AZURE_COSMOS_KEY", + ): + monkeypatch.delenv(name, raising=False) + with pytest.raises(SettingNotFoundError): + CosmosStore() + with pytest.raises(TypeError, match="credential"): + CosmosStore( + endpoint="https://account.documents.azure.com/", + database_name="db", + credential=cast(Any, object()), + ) + + +def test_default_and_multi_vector_schema() -> None: + collection, _ = _collection(definition=_definition(second_vector=True)) + embeddings = collection._vector_policy["vectorEmbeddings"] + indexes = collection._indexing_policy["vectorIndexes"] + assert embeddings == [ + { + "path": "/embedding", + "dataType": "float32", + "distanceFunction": "cosine", + "dimensions": 3, + }, + { + "path": "/otherEmbedding", + "dataType": "uint8", + "distanceFunction": "euclidean", + "dimensions": 3, + }, + ] + assert indexes == [ + {"path": "/embedding", "type": "quantizedFlat"}, + {"path": "/otherEmbedding", "type": "diskANN"}, + ] + assert {"path": "/embedding/*"} in collection._indexing_policy["excludedPaths"] + assert {"path": "/otherEmbedding/*"} in collection._indexing_policy["excludedPaths"] + + +def test_vector_schema_tuning_annotations() -> None: + definition = _definition( + index_kind="disk_ann", + vector_type="float", + dimensions=512, + annotations={ + "azure_cosmos": { + "data_type": "int8", + "quantizer_type": "spherical", + "quantization_byte_size": 256, + "indexing_search_list_size": 200, + } + }, + ) + collection, _ = _collection(definition=definition) + assert collection._vector_policy["vectorEmbeddings"][0]["dataType"] == "int8" + assert collection._indexing_policy["vectorIndexes"] == [ + { + "path": "/embedding", + "type": "diskANN", + "quantizerType": "spherical", + "quantizationByteSize": 256, + "indexingSearchListSize": 200, + } + ] + + +def test_declared_data_storage_names_are_escaped_and_unindexed_paths_are_excluded() -> None: + storage_name = 'content"] WHERE true --' + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="key", storage_name="id", type_="str"), + VectorStoreField("data", name="text", storage_name=storage_name, type_="str", is_indexed=False), + VectorStoreField("vector", name="vector", storage_name="embedding", dimensions=3, type_="float"), + ], + collection_name="items", + ) + collection, _ = _collection(definition=definition) + assert _property_access(storage_name) == 'c["content\\"] WHERE true --"]' + assert {"path": '/"content\\"] WHERE true --"/*'} in collection._indexing_policy["excludedPaths"] + with pytest.raises(ValueError, match="excluded from indexing"): + collection._prepare_filter(Filter("text", "eq", "value")) + + +@pytest.mark.parametrize( + "definition_factory,match", + [ + ( + lambda: VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="key", type_="str"), + VectorStoreField("vector", name="vector", dimensions=3, type_="float"), + ], + collection_name="items", + ), + "storage name 'id'", + ), + ( + lambda: VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="key", storage_name="id", type_="int"), + VectorStoreField("vector", name="vector", dimensions=3, type_="float"), + ], + collection_name="items", + ), + "Key field type", + ), + ( + lambda: VectorStoreCollectionDefinition( + [VectorStoreField("key", name="key", storage_name="id", type_="str")], + collection_name="items", + ), + "at least one vector", + ), + ( + lambda: VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="key", storage_name="id", type_="str"), + VectorStoreField("vector", name="nested", storage_name="a.b", dimensions=3, type_="float"), + ], + collection_name="items", + ), + "top-level ASCII", + ), + ], +) +def test_invalid_collection_schema( + definition_factory: Callable[[], VectorStoreCollectionDefinition], + match: str, +) -> None: + container = MagicMock() + container.id = "items" + with pytest.raises(ValueError, match=match): + CosmosCollection(dict, definition=definition_factory(), container_client=container) + + +@pytest.mark.parametrize( + "field_kwargs,exception,match", + [ + ({"index_kind": "flat", "dimensions": 506}, ValueError, "at most 505"), + ({"index_kind": "disk_ann", "dimensions": 4097}, ValueError, "at most 4096"), + ({"index_kind": "hnsw"}, NotImplementedError, "index kind"), + ({"distance": "cosine_distance"}, NotImplementedError, "distance"), + ({"vector_type": "float16"}, ValueError, "Vector field"), + ({"vector_type": "float64"}, ValueError, "Vector field"), + ( + {"vector_type": "float", "annotations": {"azure_cosmos": {"data_type": "float16"}}}, + NotImplementedError, + "float32, int8, or uint8", + ), + ( + {"annotations": {"azure_cosmos": {"unknown": 1}}}, + ValueError, + "annotation", + ), + ( + {"annotations": {"azure_cosmos": {"data_type": 1}}}, + TypeError, + "data_type", + ), + ( + {"index_kind": "flat", "annotations": {"azure_cosmos": {"quantizer_type": "product"}}}, + ValueError, + "quantizer_type", + ), + ( + {"index_kind": "quantized_flat", "annotations": {"azure_cosmos": {"indexing_search_list_size": 100}}}, + ValueError, + "diskANN", + ), + ( + { + "index_kind": "disk_ann", + "dimensions": 1536, + "annotations": {"azure_cosmos": {"quantization_byte_size": 513}}, + }, + ValueError, + "between 4 and 512", + ), + ( + { + "index_kind": "disk_ann", + "dimensions": 1536, + "annotations": {"azure_cosmos": {"quantization_byte_size": 3}}, + }, + ValueError, + "between 4 and 512", + ), + ( + { + "index_kind": "disk_ann", + "dimensions": 8, + "annotations": {"azure_cosmos": {"quantization_byte_size": 9}}, + }, + ValueError, + "between 4 and 8", + ), + ( + { + "index_kind": "disk_ann", + "annotations": {"azure_cosmos": {"indexing_search_list_size": 24}}, + }, + ValueError, + "between 25 and 500", + ), + ], +) +def test_invalid_vector_schema(field_kwargs: dict[str, Any], exception: type[Exception], match: str) -> None: + with pytest.raises(exception, match=match): + _collection(definition=_definition(**field_kwargs)) + + +@pytest.mark.parametrize( + "vector_type,annotations,match", + [ + ("float16", None, "Vector field"), + ("float", {"azure_cosmos": {"data_type": "float16"}}, "float32, int8, or uint8"), + ("float16", {"azure_cosmos": {"data_type": "float32"}}, "Vector field"), + ], +) +def test_float16_schema_rejected_before_container_io( + vector_type: str, + annotations: Mapping[str, Any] | None, + match: str, +) -> None: + container = MagicMock() + container.id = "items" + container.read = AsyncMock() + with pytest.raises((ValueError, NotImplementedError), match=match): + CosmosCollection( + dict, + definition=_definition(vector_type=vector_type, annotations=annotations), + container_client=container, + ) + container.read.assert_not_awaited() + container.query_items.assert_not_called() + + +def test_vector_schema_tuning_boundaries() -> None: + collection, _ = _collection( + definition=_definition( + index_kind="disk_ann", + dimensions=4, + annotations={ + "azure_cosmos": { + "quantization_byte_size": 4, + "indexing_search_list_size": 25, + } + }, + ) + ) + assert collection._indexing_policy["vectorIndexes"] == [ + { + "path": "/embedding", + "type": "diskANN", + "quantizationByteSize": 4, + "indexingSearchListSize": 25, + } + ] + + +def test_policy_path_normalization_and_semantic_defaults() -> None: + collection, _ = _collection( + definition=_definition( + index_kind="disk_ann", + annotations={"azure_cosmos": {"indexing_search_list_size": 100}}, + second_vector=True, + ) + ) + properties = _container_properties(collection) + properties["vectorEmbeddingPolicy"]["vectorEmbeddings"].reverse() + properties["indexingPolicy"]["excludedPaths"] = [ + {"path": '/"_etag"/?'}, + {"path": '/"embedding"/*'}, + {"path": '/"otherEmbedding"/*'}, + ] + properties["indexingPolicy"]["vectorIndexes"][0]["quantizerType"] = "product" + properties["indexingPolicy"]["vectorIndexes"].reverse() + assert _normalize_policy_path('/"embedding"/*') == "/embedding/*" + assert _normalize_policy_path('/"a/b"/*') == '/"a/b"/*' + assert _normalize_policy_path('/"a/b"/*') != "/a/b/*" + _validate_existing_policies( + properties, + vector_policy=collection._vector_policy, + indexing_policy=collection._indexing_policy, + required_indexed_paths=_required_indexed_paths(collection.definition), + ) + + +def test_quoted_policy_path_does_not_match_nested_path() -> None: + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="key", storage_name="id", type_="str"), + VectorStoreField("data", name="value", storage_name="a/b", type_="str", is_indexed=False), + VectorStoreField("vector", name="vector", storage_name="embedding", dimensions=3, type_="float"), + ], + collection_name="items", + ) + collection, _ = _collection(definition=definition) + properties = _container_properties(collection) + excluded_paths = properties["indexingPolicy"]["excludedPaths"] + excluded_paths[1] = {"path": "/a/b/*"} + with pytest.raises(ValueError, match="exclude all required"): + _validate_existing_policies( + properties, + vector_policy=collection._vector_policy, + indexing_policy=collection._indexing_policy, + required_indexed_paths=_required_indexed_paths(collection.definition), + ) + + +@pytest.mark.parametrize( + "mutate,match", + [ + (lambda p: p.update({"partitionKey": {"paths": ["/tenant"], "kind": "Hash"}}), "partition"), + (lambda p: p.pop("vectorEmbeddingPolicy"), "embedding policy"), + ( + lambda p: p["vectorEmbeddingPolicy"]["vectorEmbeddings"][0].update({"dimensions": 99}), + "embedding policy", + ), + (lambda p: p["indexingPolicy"].update({"automatic": False}), "automatic"), + (lambda p: p["indexingPolicy"].update({"includedPaths": []}), "root path"), + (lambda p: p["indexingPolicy"].update({"excludedPaths": [{"path": "/_etag/?"}]}), "exclude"), + ( + lambda p: p["indexingPolicy"]["excludedPaths"].append({"path": "/content/*"}), + "must remain indexed", + ), + (lambda p: p["indexingPolicy"]["vectorIndexes"][0].update({"type": "flat"}), "index type"), + ( + lambda p: p["indexingPolicy"]["vectorIndexes"][0].update({"quantizerType": "spherical"}), + "quantizer", + ), + ], +) +def test_incompatible_existing_policy(mutate: Any, match: str) -> None: + collection, _ = _collection() + properties = _container_properties(collection) + mutate(properties) + with pytest.raises(ValueError, match=match): + _validate_existing_policies( + properties, + vector_policy=collection._vector_policy, + indexing_policy=collection._indexing_policy, + required_indexed_paths=_required_indexed_paths(collection.definition), + ) + + +@pytest.mark.parametrize( + "key,exception", + [ + ("", ValueError), + ("a/b", ValueError), + ("a\\b", ValueError), + ("a?b", ValueError), + ("a#b", ValueError), + ("a" * 1024, ValueError), + (1, TypeError), + ], +) +def test_key_validation(key: Any, exception: type[Exception]) -> None: + with pytest.raises(exception): + _validate_key(key) + assert _validate_key("cafe-\N{SNOWMAN}") == "cafe-\N{SNOWMAN}" + + +@pytest.mark.parametrize( + "vector,vector_type,exception", + [ + ([1.0, 2.0], "float32", ValueError), + ([1.0, math.inf, 0.0], "float32", ValueError), + ([1.0, True, 0.0], "float32", TypeError), + ([1, 2.0, 3], "int8", TypeError), + ([1, 2, 128], "int8", ValueError), + (b"123", "float32", TypeError), + ], +) +async def test_full_batch_vector_preflight(vector: Any, vector_type: str, exception: type[Exception]) -> None: + collection, container = _collection(definition=_definition(vector_type=vector_type)) + records = [_record("valid"), {**_record("late"), "embedding": vector}] + if vector_type in ("int8", "uint8"): + records[0]["embedding"] = [1, 0, 0] + with pytest.raises(exception): + await collection.upsert(records, generate_vectors=False) + container.read.assert_not_awaited() + container.upsert_item.assert_not_awaited() + + +async def test_full_batch_item_size_and_json_preflight() -> None: + collection, container = _collection() + oversized = {**_record("large"), "content": "x" * (2 * 1024 * 1024)} + with pytest.raises(ValueError, match="2 MiB"): + await collection.upsert([_record(), oversized], generate_vectors=False) + with pytest.raises(ValueError, match="IEEE 754"): + await collection.upsert([{**_record(), "count": 2**53}], generate_vectors=False) + with pytest.raises(TypeError, match="declared type"): + await collection.upsert([{**_record(), "active": 1}], generate_vectors=False) + container.upsert_item.assert_not_awaited() + + +@pytest.mark.parametrize( + "expression,clause,values", + [ + (Filter("optional", "exists"), 'IS_DEFINED(c["optional"])', []), + ( + Filter("optional", "is_null"), + '(IS_DEFINED(c["optional"]) AND IS_NULL(c["optional"]))', + [], + ), + ( + Filter("optional", "is_not_null"), + '(IS_DEFINED(c["optional"]) AND NOT IS_NULL(c["optional"]))', + [], + ), + ( + Filter("text", "eq", "O'Reilly"), + '(IS_DEFINED(c["content"]) AND c["content"] = @filter_0)', + ["O'Reilly"], + ), + ( + Filter("text", "ne", "x"), + '(IS_DEFINED(c["content"]) AND (IS_NULL(c["content"]) OR c["content"] != @filter_0))', + ["x"], + ), + (Filter("count", "eq", True), "false", []), + (Filter("count", "ne", True), 'IS_DEFINED(c["count"])', []), + ( + Filter("count", "gt", 1), + '(IS_DEFINED(c["count"]) AND NOT IS_NULL(c["count"]) AND c["count"] > @filter_0)', + [1], + ), + ( + Filter("count", "between", [1, 3]), + ( + '(IS_DEFINED(c["count"]) AND NOT IS_NULL(c["count"]) ' + 'AND c["count"] >= @filter_0 AND c["count"] <= @filter_1)' + ), + [1, 3], + ), + ( + Filter("text", "in", ["a", "b"]), + '(IS_DEFINED(c["content"]) AND NOT IS_NULL(c["content"]) AND ARRAY_CONTAINS(@filter_0, c["content"]))', + [["a", "b"]], + ), + (Filter("text", "in", []), "false", []), + ( + Filter("text", "not_in", []), + '(IS_DEFINED(c["content"]) AND NOT IS_NULL(c["content"]))', + [], + ), + ( + Filter("tags", "contains", "a"), + '(IS_ARRAY(c["tags"]) AND (ARRAY_CONTAINS(c["tags"], @filter_0)))', + ["a"], + ), + (Filter("tags", "contains_any", []), "false", []), + (Filter("tags", "contains_all", []), 'IS_ARRAY(c["tags"])', []), + ( + Filter("tags", "contains_all", ["a", "b"]), + '(IS_ARRAY(c["tags"]) AND (ARRAY_CONTAINS(c["tags"], @filter_0) AND ARRAY_CONTAINS(c["tags"], @filter_1)))', + ["a", "b"], + ), + ( + Filter("tags", "contains_any", [None]), + '(IS_ARRAY(c["tags"]) AND (ARRAY_CONTAINS(c["tags"], @filter_0)))', + [None], + ), + ( + Filter("text", "in", [None, "a"]), + '(IS_DEFINED(c["content"]) AND NOT IS_NULL(c["content"]) AND ARRAY_CONTAINS(@filter_0, c["content"]))', + [[None, "a"]], + ), + ( + Filter("text", "starts_with", "he"), + '(IS_STRING(c["content"]) AND STARTSWITH(c["content"], @filter_0))', + ["he"], + ), + ( + Filter("text", "ends_with", "lo"), + '(IS_STRING(c["content"]) AND ENDSWITH(c["content"], @filter_0))', + ["lo"], + ), + ( + Filter("text", "contains_text", "ell"), + '(IS_STRING(c["content"]) AND CONTAINS(c["content"], @filter_0))', + ["ell"], + ), + ], +) +def test_filter_translation_matches_portable_semantics( + expression: Filter, + clause: str, + values: list[Any], +) -> None: + collection, _ = _collection() + actual, parameters = collection._prepare_filter(expression) + assert actual == clause + assert actual is not None + assert [parameter["value"] for parameter in parameters] == values + if values == ["O'Reilly"]: + assert "O'Reilly" not in actual + + +def test_filter_groups_and_rejections() -> None: + collection, _ = _collection() + group = FilterGroup( + "and", + [ + Filter("count", "gte", 1), + FilterGroup("not", [Filter("active", "eq", False)]), + ], + ) + clause, parameters = collection._prepare_filter(group) + assert clause == ( + '((IS_DEFINED(c["count"]) AND NOT IS_NULL(c["count"]) AND c["count"] >= @filter_0) ' + 'AND (NOT (IS_DEFINED(c["active"]) AND c["active"] = @filter_1)))' + ) + assert [item["value"] for item in parameters] == [1, False] + for expression, exception in ( + (Filter("tags", "eq", {"a": 1}), NotImplementedError), + (Filter("text", "in", [["nested"]]), NotImplementedError), + (Filter("vector", "eq", 1), NotImplementedError), + (Filter("text.child", "eq", "x"), NotImplementedError), + (Filter("text", "provider.unknown", "x"), NotImplementedError), + (Filter("active", "gt", True), NotImplementedError), + ): + with pytest.raises(exception): + collection._prepare_filter(expression) + + +async def test_get_rejects_unsupported_filter_before_io() -> None: + collection, container = _collection() + with pytest.raises(NotImplementedError, match="nested"): + await collection.get(filter=Filter("text.child", "eq", "x")) + container.read.assert_not_awaited() + container.query_items.assert_not_called() + + +async def test_ensure_existing_and_create_absent() -> None: + definition = _definition() + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + existing = MagicMock() + existing.read = AsyncMock() + database.get_container_client.return_value = existing + database.create_container = AsyncMock() + collection = CosmosCollection( + dict, + definition=definition, + database_client=database, + ) + existing.read.return_value = _container_properties(collection) + await collection.ensure_collection_exists() + database.create_container.assert_not_awaited() + + existing.read.side_effect = [_not_found(), _container_properties(collection)] + created = MagicMock() + created.read = AsyncMock(return_value=_container_properties(collection)) + database.create_container.return_value = created + collection._container_client = None + collection._container_validated = False + await collection.ensure_collection_exists() + await_args = database.create_container.await_args + assert await_args is not None + kwargs = await_args.kwargs + assert kwargs["partition_key"].path == "/id" + assert kwargs["vector_embedding_policy"] == collection._vector_policy + assert kwargs["indexing_policy"] == collection._indexing_policy + + +async def test_ensure_handles_create_race_and_revalidates_winner() -> None: + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + winner = MagicMock() + winner.read = AsyncMock() + database.get_container_client.return_value = winner + database.create_container = AsyncMock(side_effect=CosmosResourceExistsError(message="race")) + collection = CosmosCollection(dict, definition=_definition(), database_client=database) + winner.read.side_effect = [_not_found(), _container_properties(collection)] + await collection.ensure_collection_exists() + assert winner.read.await_count == 2 + + +async def test_database_creation_is_explicit() -> None: + client = MagicMock() + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + client.create_database = AsyncMock(return_value=database) + client.get_database_client.return_value = database + connection = _CosmosConnection( + cosmos_client=client, + database_client=None, + database_name="db", + owns_client=False, + create_database=True, + ) + assert await connection.get_database() is database + client.create_database.assert_awaited_once_with(id="db") + assert await connection.get_database() is database + client.create_database.assert_awaited_once() + + +async def test_database_creation_race_uses_existing_database() -> None: + client = MagicMock() + database = MagicMock() + database.read = AsyncMock(return_value={}) + client.create_database = AsyncMock(side_effect=CosmosResourceExistsError(message="race")) + client.get_database_client.return_value = database + connection = _CosmosConnection( + cosmos_client=client, + database_client=None, + database_name="db", + owns_client=False, + create_database=True, + ) + assert await connection.get_database() is database + database.read.assert_awaited_once() + + +async def test_collection_exists_and_delete_lifecycle() -> None: + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + container = MagicMock() + container.read = AsyncMock(return_value={}) + database.get_container_client.return_value = container + database.delete_container = AsyncMock(side_effect=_not_found()) + collection = CosmosCollection(dict, definition=_definition(), database_client=database) + assert await collection.collection_exists() + container.read.side_effect = _not_found() + assert not await collection.collection_exists() + await collection.ensure_collection_deleted() + database.delete_container.assert_awaited_once_with("items") + + +async def test_upsert_point_get_and_delete() -> None: + collection, container = _collection() + records = [_record("one"), _record("two")] + assert await collection.upsert(records, generate_vectors=False) == ["one", "two"] + assert [call.kwargs["body"]["id"] for call in container.upsert_item.await_args_list] == ["one", "two"] + + container.read_item.side_effect = [_record("one"), _not_found(), _record("two")] + found = await collection.get(["one", "missing", "two"]) + assert [item["key"] for item in found] == ["one", "two"] + assert all("vector" not in item for item in found) + calls = container.read_item.await_args_list + assert [(call.kwargs["item"], call.kwargs["partition_key"]) for call in calls] == [ + ("one", "one"), + ("missing", "missing"), + ("two", "two"), + ] + await collection.delete(["one", "missing"]) + assert [(call.kwargs["item"], call.kwargs["partition_key"]) for call in container.delete_item.await_args_list] == [ + ("one", "one"), + ("missing", "missing"), + ] + + +async def test_partial_write_and_delete_failures_are_aligned() -> None: + collection, container = _collection() + container.upsert_item.side_effect = [{}, _http_error()] + with pytest.raises(IntegrationException, match="1/2.*input index 1"): + await collection.upsert([_record("one"), _record("two")], generate_vectors=False) + container.delete_item.side_effect = [None, _http_error()] + with pytest.raises(IntegrationException, match="1/2.*input index 1"): + await collection.delete(["one", "two"]) + + +async def test_filtered_get_query_is_parameterized_and_bounded() -> None: + collection, container = _collection(query_results=[_record("one", include_vectors=False)]) + found = await collection.get( + filter=Filter("text", "eq", "hello"), + top=4, + skip=2, + order_by={"count": False}, + ) + assert [item["key"] for item in found] == ["one"] + kwargs = container.query_items.call_args.kwargs + query = kwargs["query"] + assert "hello" not in query + assert 'WHERE (IS_DEFINED(c["content"]) AND c["content"] = @filter_0)' in query + assert 'ORDER BY c["count"] DESC OFFSET @skip LIMIT @top' in query + assert kwargs["parameters"] == [ + {"name": "@filter_0", "value": "hello"}, + {"name": "@skip", "value": 2}, + {"name": "@top", "value": 4}, + ] + + +def test_order_by_validation() -> None: + collection, _ = _collection() + with pytest.raises(NotImplementedError, match="one order_by"): + collection._prepare_order_by({"text": True, "count": False}) + with pytest.raises(TypeError, match="boolean"): + collection._prepare_order_by(cast(Mapping[str, bool], {"text": 1})) + with pytest.raises(ValueError, match="Unknown"): + collection._prepare_order_by({"missing": True}) + with pytest.raises(ValueError, match="indexed"): + collection._prepare_order_by({"vector": True}) + + +async def test_cosine_search_query_threshold_filter_order_and_metadata() -> None: + collection, container = _collection( + query_results=[{"record": _record("one", include_vectors=False), "score": 0.75}] + ) + + def query_items(**kwargs: Any) -> AsyncIterator[Any]: + kwargs["response_hook"]( + { + "x-ms-request-charge": "3.5", + "x-ms-activity-id": "activity", + "x-ms-continuation": "", + }, + None, + ) + return _async_items([{"record": _record("one", include_vectors=False), "score": 0.75}]) + + container.query_items.side_effect = query_items + results = await collection.search( + vector=[1.0, 0.0, 0.0], + filter=Filter("count", "gte", 1), + score_threshold=0.7, + top=2, + ) + rows = [row async for row in results] + assert rows == [ + { + "record": { + "key": "one", + "text": "hello", + "count": 2, + "active": True, + "tags": ["a", "b"], + "optional": None, + }, + "score": 0.75, + } + ] + query_kwargs = container.query_items.call_args.kwargs + query = query_kwargs["query"] + assert query.startswith("SELECT TOP @top VALUE") + assert ( + 'WHERE ((IS_DEFINED(c["count"]) AND NOT IS_NULL(c["count"]) AND c["count"] >= @filter_0) AND VectorDistance' + ) in query + assert " >= @threshold_" in query + assert query.index("WHERE") < query.index("ORDER BY") + assert " OFFSET " not in query + assert [item["value"] for item in query_kwargs["parameters"]] == [ + [1.0, 0.0, 0.0], + 1, + 0.7, + 2, + ] + assert results.metadata == { + "score_kind": "cosine_similarity", + "score_direction": "higher_is_better", + "request_charge": 3.5, + "activity_id": "activity", + "has_more_results": False, + } + + +async def test_euclidean_search_uses_bounded_top_and_lazily_skips() -> None: + collection, container = _collection( + definition=_definition(distance="euclidean_distance"), + query_results=[ + {"record": _record("skipped", include_vectors=False), "score": 0.0}, + {"record": _record("kept", include_vectors=False), "score": 0.25}, + ], + ) + results = await collection.search( + vector=[1.0, 0.0, 0.0], + top=2, + skip=1, + ) + rows = [row async for row in results] + assert rows == [ + { + "record": { + "key": "kept", + "text": "hello", + "count": 2, + "active": True, + "tags": ["a", "b"], + "optional": None, + }, + "score": 0.25, + } + ] + kwargs = container.query_items.call_args.kwargs + query = kwargs["query"] + assert query.startswith("SELECT TOP @top VALUE") + assert "@threshold_" not in query + assert "OFFSET" not in query + assert {"name": "@top", "value": 3} in kwargs["parameters"] + assert results.metadata is not None + assert results.metadata["score_kind"] == "euclidean_distance" + assert results.metadata["score_direction"] == "lower_is_better" + + +async def test_euclidean_threshold_rejected_before_io() -> None: + collection, container = _collection(definition=_definition(distance="euclidean_distance")) + with pytest.raises(NotImplementedError, match="Omit score_threshold"): + await collection.search( + vector=[1.0, 0.0, 0.0], + score_threshold=0.5, + ) + container.read.assert_not_awaited() + container.query_items.assert_not_called() + + +@pytest.mark.parametrize("distance", ["cosine_similarity", "dot_prod"]) +async def test_similarity_threshold_is_inclusive_and_parameterized(distance: str) -> None: + collection, container = _collection( + definition=_definition(distance=distance), + query_results=[{"record": _record("one", include_vectors=False), "score": 0.5}], + ) + results = await collection.search( + vector=[1.0, 0.0, 0.0], + score_threshold=0.5, + ) + assert [row async for row in results][0]["score"] == 0.5 + kwargs = container.query_items.call_args.kwargs + assert " >= @threshold_" in kwargs["query"] + assert {"name": "@threshold_1", "value": 0.5} in kwargs["parameters"] + + +async def test_search_options_are_validated_and_parameterized() -> None: + collection, container = _collection(definition=_definition(index_kind="disk_ann")) + results = await collection.search( + vector=[1.0, 0.0, 0.0], + operation_options={ + "search_list_size_multiplier": 10, + "quantized_vector_list_multiplier": 5, + "filter_priority": 0.5, + "brute_force": True, + }, + ) + assert [row async for row in results] == [] + kwargs = container.query_items.call_args.kwargs + query = kwargs["query"] + assert "searchListSizeMultiplier" not in query + assert "@brute_force_" in query + options = next(item["value"] for item in kwargs["parameters"] if item["name"].startswith("@vector_options_")) + assert options == { + "searchListSizeMultiplier": 10, + "quantizedVectorListMultiplier": 5, + "filterPriority": 0.5, + } + + +@pytest.mark.parametrize( + "options,match", + [ + ({"unknown": 1}, "Unsupported"), + ({"brute_force": 1}, "boolean"), + ({"search_list_size_multiplier": 0}, "positive"), + ({"filter_priority": 2}, "between 0 and 1"), + ], +) +async def test_invalid_search_options(options: dict[str, Any], match: str) -> None: + collection, container = _collection(definition=_definition(index_kind="disk_ann")) + with pytest.raises((TypeError, ValueError), match=match): + await collection.search(vector=[1.0, 0.0, 0.0], operation_options=options) + container.query_items.assert_not_called() + + +async def test_flat_index_rejects_approximate_search_options() -> None: + collection, _ = _collection(definition=_definition(index_kind="flat")) + with pytest.raises(ValueError, match="flat"): + await collection.search( + vector=[1.0, 0.0, 0.0], + operation_options={"quantized_vector_list_multiplier": 2}, + ) + + +async def test_search_rejects_server_vectorization_and_invalid_score() -> None: + collection, _ = _collection() + with pytest.raises(NotImplementedError, match="server-side embedding"): + await collection.search("text") + with pytest.raises(ValueError, match="finite"): + await collection.search(vector=[1.0, 0.0, 0.0], score_threshold=math.inf) + with pytest.raises(ValueError, match="keyword-hybrid"): + await collection.search( + vector=[1.0, 0.0, 0.0], + additional_property_name="text", + ) + with pytest.raises(IntegrationInvalidResponseException, match="invalid score"): + collection._get_score_from_result({"record": {}, "score": True}) + with pytest.raises(IntegrationInvalidResponseException, match="record projection"): + collection._get_record_from_result({"record": 1}) + + +def test_metadata_hook_is_bounded_and_rejects_bad_charge() -> None: + metadata: dict[str, Any] = { + "request_charge": 0.0, + "activity_id": None, + "has_more_results": False, + } + hook = _query_metadata_hook(metadata) + with pytest.raises(IntegrationInvalidResponseException, match="request-charge"): + hook( + { + "x-ms-request-charge": "bad", + "x-ms-activity-id": "activity", + "x-ms-continuation": "opaque", + }, + None, + ) + assert "opaque" not in repr(metadata) + + +async def test_store_children_share_database_and_do_not_reload_settings() -> None: + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + database.list_containers = MagicMock(return_value=_async_items([{"id": "one"}, {"id": 2}, {"id": "two"}])) + existing = MagicMock() + existing.read = AsyncMock(return_value={}) + database.get_container_client.return_value = existing + database.delete_container = AsyncMock(side_effect=_not_found()) + with patch.object(vector_store_module, "load_settings", side_effect=AssertionError("must not reload settings")): + store = CosmosStore(database_client=database) + collection = store.get_collection(dict, definition=_definition()) + assert await store.list_collection_names() == ["one", "two"] + assert await store.collection_exists("one") + existing.read.side_effect = _not_found() + assert not await store.collection_exists("missing") + await store.ensure_collection_deleted("missing") + assert collection._connection is store._connection + await store.close() + assert collection._closed + database.close.assert_not_called() + + +async def test_store_does_not_retain_abandoned_collection_handles() -> None: + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + store = CosmosStore(database_client=database) + collection = store.get_collection(dict, definition=_definition()) + collection_ref = ref(collection) + assert len(store._collections) == 1 + del collection + gc.collect() + assert collection_ref() is None + assert len(store._collections) == 0 + await store.close() + + +async def test_store_delete_invalidates_same_name_children_only() -> None: + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + lifecycle_container = MagicMock() + lifecycle_container.read = AsyncMock(return_value={}) + database.get_container_client.return_value = lifecycle_container + database.delete_container = AsyncMock(return_value=None) + store = CosmosStore(database_client=database) + first = store.get_collection(dict, definition=_definition()) + sibling = store.get_collection(dict, definition=_definition()) + other = store.get_collection(dict, definition=_definition(), collection_name="other") + for collection in (first, sibling, other): + collection._container_client = MagicMock() + collection._container_validated = True + + await store.ensure_collection_deleted("items") + + assert first._container_client is None + assert not first._container_validated + assert sibling._container_client is None + assert not sibling._container_validated + assert other._container_client is not None + assert other._container_validated + await store.close() + + +async def test_child_delete_invalidates_same_name_sibling() -> None: + database = MagicMock() + database.id = "db" + database.read = AsyncMock(return_value={}) + database.delete_container = AsyncMock(return_value=None) + store = CosmosStore(database_client=database) + first = store.get_collection(dict, definition=_definition()) + sibling = store.get_collection(dict, definition=_definition()) + first._container_client = MagicMock() + first._container_validated = True + sibling._container_client = MagicMock() + sibling._container_validated = True + + await first.ensure_collection_deleted() + + assert first._container_client is None + assert not first._container_validated + assert sibling._container_client is None + assert not sibling._container_validated + await store.close() diff --git a/python/packages/azure-cosmos/tests/azure_cosmos/test_vector_store_integration.py b/python/packages/azure-cosmos/tests/azure_cosmos/test_vector_store_integration.py new file mode 100644 index 00000000000..b51ffe849d8 --- /dev/null +++ b/python/packages/azure-cosmos/tests/azure_cosmos/test_vector_store_integration.py @@ -0,0 +1,322 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Explicitly authorized cloud test for a unique, disposable Cosmos database.""" + +from __future__ import annotations + +import os +from contextlib import suppress +from typing import cast +from uuid import uuid4 + +import pytest +from agent_framework import Filter, FilterGroup, VectorStoreCollectionDefinition, VectorStoreField +from azure.cosmos.aio import CosmosClient +from azure.cosmos.exceptions import CosmosResourceNotFoundError +from azure.identity.aio import AzureCliCredential + +from agent_framework_azure_cosmos import CosmosCollection, CosmosStore + +_USE_AZURE_CLI = os.getenv("AZURE_COSMOS_VECTOR_TEST_USE_AZURE_CLI") == "1" +_CREATE_DATABASE = os.getenv("AZURE_COSMOS_VECTOR_TEST_CREATE_DATABASE", "1") == "1" + +pytestmark = [ + pytest.mark.flaky, + pytest.mark.integration, + pytest.mark.timeout(240), + pytest.mark.filterwarnings("ignore::agent_framework._feature_stage.ExperimentalWarning"), + pytest.mark.skipif( + os.getenv("AZURE_COSMOS_VECTOR_TESTS") != "1" + or not os.getenv("AZURE_COSMOS_ENDPOINT") + or (not _USE_AZURE_CLI and not os.getenv("AZURE_COSMOS_KEY")), + reason="Requires explicit disposable-database authorization and a vector-enabled Cosmos NoSQL account.", + ), +] + + +async def test_disposable_vector_database_crud_filter_and_search() -> None: + """Use a bounded exact index because no high-volume live-test cost is authorized by default.""" + database_name = os.getenv("AZURE_COSMOS_VECTOR_TEST_DATABASE_NAME", f"af-vector-{uuid4().hex}") + container_name = os.getenv("AZURE_COSMOS_VECTOR_TEST_CONTAINER_NAME", f"items-{uuid4().hex}") + default_container_name = os.getenv( + "AZURE_COSMOS_VECTOR_TEST_DEFAULT_CONTAINER_NAME", + f"items-default-{uuid4().hex}", + ) + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="key", storage_name="id", type_="str"), + VectorStoreField("data", name="label", type_="str", is_indexed=True), + VectorStoreField( + "vector", + name="vector", + storage_name="embedding", + type_="float32", + dimensions=3, + index_kind="flat", + distance_function="cosine_similarity", + ), + ], + collection_name=container_name, + ) + credential: str | AzureCliCredential + cli_credential: AzureCliCredential | None = None + if _USE_AZURE_CLI: + cli_credential = AzureCliCredential( + tenant_id=os.getenv("AZURE_COSMOS_VECTOR_TEST_TENANT_ID", ""), + ) + credential = cli_credential + else: + credential = os.environ["AZURE_COSMOS_KEY"] + client = CosmosClient( + os.environ["AZURE_COSMOS_ENDPOINT"], + credential=credential, # pyrefly: ignore[bad-argument-type] + ) + store = CosmosStore( + cosmos_client=client, + database_name=database_name, + create_database=_CREATE_DATABASE, + ) + collection: CosmosCollection[dict] | None = None + default_collection: CosmosCollection[dict] | None = None + try: + collection = store.get_collection(dict, definition=definition) + await collection.ensure_collection_exists() + records = [ + {"id": "one", "label": "first", "embedding": [1.0, 0.0, 0.0]}, + {"id": "two", "label": "second", "embedding": [0.0, 1.0, 0.0]}, + {"id": "three", "label": "third", "embedding": [0.0, 0.0, 1.0]}, + ] + assert await collection.upsert(records, generate_vectors=False) == ["one", "two", "three"] + assert [item["key"] for item in await collection.get(["one", "three"])] == ["one", "three"] + assert [item["key"] for item in await collection.get(filter=Filter("label", "eq", "second"))] == ["two"] + results = await collection.search(vector=[1.0, 0.0, 0.0], top=1) + rows = [row async for row in results] + assert rows[0]["record"]["key"] == "one" + await collection.delete(["one", "two", "three"]) + assert await collection.get(["one", "two", "three"]) == [] + await collection.ensure_collection_deleted() + + default_definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="key", storage_name="id", type_="str"), + VectorStoreField("data", name="category", type_="str", is_indexed=True), + VectorStoreField("data", name="count", type_="int", is_indexed=True), + VectorStoreField("data", name="active", type_="bool", is_indexed=True), + VectorStoreField("data", name="tags", type_="list", is_indexed=True), + VectorStoreField("data", name="optional", type_="str", is_indexed=True), + VectorStoreField( + "vector", + name="cosine", + type_="float32", + dimensions=3, + distance_function="cosine_similarity", + ), + VectorStoreField( + "vector", + name="dot", + type_="float32", + dimensions=3, + index_kind="flat", + distance_function="dot_prod", + ), + VectorStoreField( + "vector", + name="euclidean", + type_="int8", + dimensions=3, + index_kind="flat", + distance_function="euclidean_distance", + ), + VectorStoreField( + "vector", + name="byte_vector", + type_="uint8", + dimensions=3, + index_kind="flat", + distance_function="cosine_similarity", + ), + ], + collection_name=default_container_name, + ) + default_collection = store.get_collection(dict, definition=default_definition) + await default_collection.ensure_collection_exists() + await default_collection.ensure_collection_exists() + + multi_records = [ + { + "id": "one", + "category": "a", + "count": 1, + "active": True, + "tags": ["x", None], + "optional": None, + "cosine": [1.0, 0.0, 0.0], + "dot": [2.0, 0.0, 0.0], + "euclidean": [1, 0, 0], + "byte_vector": [255, 0, 0], + }, + { + "id": "two", + "category": "b", + "count": 2, + "active": False, + "tags": [], + "optional": "value", + "cosine": [0.0, 1.0, 0.0], + "dot": [1.0, 0.0, 0.0], + "euclidean": [2, 0, 0], + "byte_vector": [0, 255, 0], + }, + { + "id": "three", + "category": "c", + "count": 3, + "active": True, + "tags": ["y"], + "optional": "other", + "cosine": [-1.0, 0.0, 0.0], + "dot": [0.0, 1.0, 0.0], + "euclidean": [3, 0, 0], + "byte_vector": [0, 0, 255], + }, + ] + assert await default_collection.upsert(multi_records, generate_vectors=False) == ["one", "two", "three"] + + sdk_container = client.get_database_client(database_name).get_container_client(default_container_name) + properties = await sdk_container.read() + assert properties["partitionKey"]["paths"] == ["/id"] + assert {item["dataType"] for item in properties["vectorEmbeddingPolicy"]["vectorEmbeddings"]} == { + "float32", + "int8", + "uint8", + } + assert {item["type"] for item in properties["indexingPolicy"]["vectorIndexes"]} == { + "quantizedFlat", + "flat", + } + + cosine_results = [ + item + async for item in await default_collection.search( + vector=[1.0, 0.0, 0.0], + vector_property_name="cosine", + top=3, + operation_options={"quantized_vector_list_multiplier": 5}, + ) + ] + assert [item["record"]["key"] for item in cosine_results] == ["one", "two", "three"] + assert [item["score"] for item in cosine_results] == pytest.approx([1.0, 0.0, -1.0]) + + dot_results = [ + item + async for item in await default_collection.search( + vector=[1.0, 0.0, 0.0], + vector_property_name="dot", + top=3, + ) + ] + assert [item["record"]["key"] for item in dot_results] == ["one", "two", "three"] + assert [item["score"] for item in dot_results] == pytest.approx([2.0, 1.0, 0.0]) + + euclidean_results = [ + item + async for item in await default_collection.search( + vector=[1, 0, 0], + vector_property_name="euclidean", + top=3, + ) + ] + assert [item["record"]["key"] for item in euclidean_results] == ["one", "two", "three"] + assert [item["score"] for item in euclidean_results] == pytest.approx([0.0, 1.0, 2.0]) + + cosine_threshold = [ + item + async for item in await default_collection.search( + vector=[1.0, 0.0, 0.0], + vector_property_name="cosine", + filter=Filter("active", "eq", True), + score_threshold=0.5, + top=3, + ) + ] + assert [item["record"]["key"] for item in cosine_threshold] == ["one"] + dot_threshold = [ + item + async for item in await default_collection.search( + vector=[1.0, 0.0, 0.0], + vector_property_name="dot", + score_threshold=1.5, + top=3, + ) + ] + assert [item["record"]["key"] for item in dot_threshold] == ["one"] + with pytest.raises(NotImplementedError, match="Omit score_threshold"): + await default_collection.search( + vector=[1, 0, 0], + vector_property_name="euclidean", + score_threshold=1.0, + top=3, + ) + + assert [item["key"] for item in await default_collection.get(filter=Filter("optional", "is_null"))] == ["one"] + assert [ + item["key"] for item in await default_collection.get(filter=Filter("tags", "contains_any", [None])) + ] == ["one"] + assert {item["key"] for item in await default_collection.get(filter=Filter("tags", "contains_all", []))} == { + "one", + "two", + "three", + } + assert await default_collection.get(filter=Filter("count", "eq", True)) == [] + assert await default_collection.get(filter=Filter("active", "eq", 1)) == [] + + missing_optional = {**multi_records[0], "id": "missing"} + missing_optional.pop("optional") + await sdk_container.upsert_item(missing_optional) + exists_clause, exists_parameters = default_collection._prepare_filter(Filter("optional", "exists")) + missing_clause, missing_parameters = default_collection._prepare_filter( + FilterGroup("not", [Filter("optional", "exists")]) + ) + assert exists_clause is not None + assert missing_clause is not None + defined_ids = { + cast(str, item) + async for item in sdk_container.query_items( + query=f"SELECT VALUE c.id FROM c WHERE {exists_clause}", # noqa: S608 + parameters=exists_parameters, + ) + } + missing_ids = [ + cast(str, item) + async for item in sdk_container.query_items( + query=f"SELECT VALUE c.id FROM c WHERE {missing_clause}", # noqa: S608 + parameters=missing_parameters, + ) + ] + assert defined_ids == {"one", "two", "three"} + assert missing_ids == ["missing"] + + late_valid = {**multi_records[0], "id": "late-valid"} + late_invalid = {**multi_records[1], "id": "late-invalid", "euclidean": [1, 2.5, 3]} + with pytest.raises(TypeError, match="integer elements"): + await default_collection.upsert([late_valid, late_invalid], generate_vectors=False) + assert await default_collection.get(["late-valid"]) == [] + + assert cosine_results[0]["record"].keys().isdisjoint({"cosine", "dot", "euclidean", "byte_vector"}) + assert cosine_results[0]["score"] == pytest.approx(1.0) + await default_collection.delete(["one", "two", "three", "missing"]) + assert await default_collection.get(["one", "two", "three", "missing"]) == [] + finally: + try: + if _CREATE_DATABASE: + with suppress(CosmosResourceNotFoundError): + await client.delete_database(database_name) + else: + for candidate in (default_collection, collection): + if candidate is not None: + await candidate.ensure_collection_deleted() + finally: + await store.close() + await client.close() + if cli_credential is not None: + await cli_credential.close() diff --git a/python/packages/core/agent_framework/azure/__init__.py b/python/packages/core/agent_framework/azure/__init__.py index 50e72094935..d6e198492bd 100644 --- a/python/packages/core/agent_framework/azure/__init__.py +++ b/python/packages/core/agent_framework/azure/__init__.py @@ -16,7 +16,10 @@ "AzureAISearchContextProvider": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), "AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), "AzureAISearchStore": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"), + "AzureCosmosSettings": ("agent_framework_azure_cosmos", "agent-framework-azure-cosmos"), + "CosmosCollection": ("agent_framework_azure_cosmos", "agent-framework-azure-cosmos"), "CosmosHistoryProvider": ("agent_framework_azure_cosmos", "agent-framework-azure-cosmos"), + "CosmosStore": ("agent_framework_azure_cosmos", "agent-framework-azure-cosmos"), "DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"), "DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"), diff --git a/python/packages/core/agent_framework/azure/__init__.pyi b/python/packages/core/agent_framework/azure/__init__.pyi index 577a4ee86b5..fa03370db5b 100644 --- a/python/packages/core/agent_framework/azure/__init__.pyi +++ b/python/packages/core/agent_framework/azure/__init__.pyi @@ -10,6 +10,7 @@ from agent_framework_azure_ai_search import ( AzureAISearchStore, ) from agent_framework_azure_cosmos import CosmosHistoryProvider +from agent_framework_azure_cosmos._vector_store import AzureCosmosSettings, CosmosCollection, CosmosStore from agent_framework_azurefunctions import AgentFunctionApp, WorkflowHitlContext from agent_framework_durabletask import ( AgentCallbackContext, @@ -29,7 +30,10 @@ __all__ = [ "AzureAISearchContextProvider", "AzureAISearchSettings", "AzureAISearchStore", + "AzureCosmosSettings", + "CosmosCollection", "CosmosHistoryProvider", + "CosmosStore", "DurableAIAgent", "DurableAIAgentClient", "DurableAIAgentOrchestrationContext", diff --git a/python/packages/core/tests/core/test_azure_namespace.py b/python/packages/core/tests/core/test_azure_namespace.py index d4e4b8317b9..50e0a1ba604 100644 --- a/python/packages/core/tests/core/test_azure_namespace.py +++ b/python/packages/core/tests/core/test_azure_namespace.py @@ -4,9 +4,17 @@ import agent_framework.azure as azure -CosmosHistoryProvider = pytest.importorskip("agent_framework_azure_cosmos").CosmosHistoryProvider +azure_cosmos = pytest.importorskip("agent_framework_azure_cosmos") def test_azure_namespace_exposes_cosmos_history_provider() -> None: - assert azure.CosmosHistoryProvider is CosmosHistoryProvider - assert "CosmosHistoryProvider" in dir(azure) + assert azure.CosmosHistoryProvider is azure_cosmos.CosmosHistoryProvider + assert azure.CosmosCollection is azure_cosmos.CosmosCollection + assert azure.CosmosStore is azure_cosmos.CosmosStore + assert azure.AzureCosmosSettings is azure_cosmos.AzureCosmosSettings + assert { + "AzureCosmosSettings", + "CosmosCollection", + "CosmosHistoryProvider", + "CosmosStore", + } <= set(dir(azure)) diff --git a/python/uv.lock b/python/uv.lock index 4c64db6109a..fa7c3cc4e68 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -305,7 +305,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "azure-cosmos", specifier = ">=4.3.0,<5" }, + { name = "azure-cosmos", specifier = ">=4.7.0,<5" }, { name = "six", specifier = ">=1.17.0,<2" }, ] From 7abe2ff92b2f418bbcb01e8a0ad21052f575dc98 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 14 Sep 2026 08:02:06 +0000 Subject: [PATCH 046/108] Python: Add Azure DocumentDB vector store connector (#8185) * Python: Add Azure DocumentDB vector store connector Add an alpha Azure DocumentDB vector-store package using PyMongo async APIs and the DocumentDB cosmosSearch dialect. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Address Azure DocumentDB review feedback Run the package-local sample through the workspace package and add managed negative-filter coverage while retaining the standard Mongo $ne wire operator. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Python: Fix DocumentDB L2 score thresholds Apply maximum score thresholds for raw Euclidean distance while retaining minimum thresholds for cosine and inner product scores. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --- python/PACKAGE_STATUS.md | 1 + python/packages/azure-documentdb/LICENSE | 21 + python/packages/azure-documentdb/README.md | 114 ++ .../__init__.py | 21 + .../_vector_store.py | 1205 +++++++++++++++++ .../agent_framework_azure_documentdb/py.typed | 0 .../packages/azure-documentdb/pyproject.toml | 66 + .../azure-documentdb/samples/README.md | 14 + .../samples/azure_documentdb_vectors.py | 74 + .../tests/azure_documentdb/conftest.py | 148 ++ .../azure_documentdb/test_integration.py | 106 ++ .../tests/azure_documentdb/test_settings.py | 166 +++ .../azure_documentdb/test_vector_store.py | 675 +++++++++ python/pyproject.toml | 1 + python/uv.lock | 86 ++ 15 files changed, 2698 insertions(+) create mode 100644 python/packages/azure-documentdb/LICENSE create mode 100644 python/packages/azure-documentdb/README.md create mode 100644 python/packages/azure-documentdb/agent_framework_azure_documentdb/__init__.py create mode 100644 python/packages/azure-documentdb/agent_framework_azure_documentdb/_vector_store.py create mode 100644 python/packages/azure-documentdb/agent_framework_azure_documentdb/py.typed create mode 100644 python/packages/azure-documentdb/pyproject.toml create mode 100644 python/packages/azure-documentdb/samples/README.md create mode 100644 python/packages/azure-documentdb/samples/azure_documentdb_vectors.py create mode 100644 python/packages/azure-documentdb/tests/azure_documentdb/conftest.py create mode 100644 python/packages/azure-documentdb/tests/azure_documentdb/test_integration.py create mode 100644 python/packages/azure-documentdb/tests/azure_documentdb/test_settings.py create mode 100644 python/packages/azure-documentdb/tests/azure_documentdb/test_vector_store.py diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index 1ff011114e7..d232cab0565 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -20,6 +20,7 @@ Status is grouped into these buckets: | `agent-framework-anthropic` | `python/packages/anthropic` | `beta` | | `agent-framework-azure-contentunderstanding` | `python/packages/azure-contentunderstanding` | `beta` | | `agent-framework-azure-ai-search` | `python/packages/azure-ai-search` | `beta` | +| `agent-framework-azure-documentdb` | `python/packages/azure-documentdb` | `alpha` | | `agent-framework-azure-cosmos` | `python/packages/azure-cosmos` | `beta` | | `agent-framework-azure-cosmos-memory` | `python/packages/azure-cosmos-memory` | `alpha` | | `agent-framework-bedrock` | `python/packages/bedrock` | `beta` | diff --git a/python/packages/azure-documentdb/LICENSE b/python/packages/azure-documentdb/LICENSE new file mode 100644 index 00000000000..96a394c43c3 --- /dev/null +++ b/python/packages/azure-documentdb/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/python/packages/azure-documentdb/README.md b/python/packages/azure-documentdb/README.md new file mode 100644 index 00000000000..76dcf86116b --- /dev/null +++ b/python/packages/azure-documentdb/README.md @@ -0,0 +1,114 @@ +# Agent Framework Azure DocumentDB + +Store and search vector records in +[Azure DocumentDB](https://learn.microsoft.com/azure/documentdb/) with this +alpha integration for +[Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/). +The package uses PyMongo's stable asynchronous API and Azure DocumentDB's +Mongo-compatible `$search.cosmosSearch` dialect. + +- **`AzureDocumentDBCollection`** provides batch CRUD, metadata filters, index reconciliation, and vector search. +- **`AzureDocumentDBStore`** creates collection clients sharing one resolved database. +- **`AzureDocumentDBSettings`** defines the two Agent Framework-managed connection settings. + +## Installation + +```bash +pip install agent-framework-azure-documentdb --pre +``` + +Requires Python 3.10+, PyMongo 4.13+, and an Azure DocumentDB cluster. IVF +indexes are intended for smaller datasets and M10/M20 tiers. HNSW and DiskANN +require M30 or higher; consult the current service documentation before choosing +a production tier. + +## Authentication and setup + +Set `AZURE_DOCUMENTDB_CONNECTION_STRING` to the connection string from the +Azure portal and `AZURE_DOCUMENTDB_DATABASE_NAME` to an existing or intended +database. Settings precedence is explicit constructor value, selected `.env` +file, then process environment. Connection strings are held in Agent Framework +`SecretString` values. + +Connector-created clients enforce TLS, disable retryable writes, and set an +application name. They are closed by `aclose()` or an async context manager. +An injected PyMongo `AsyncMongoClient`, `AsyncDatabase`, or `AsyncCollection` +remains caller-owned and bypasses settings loading. + +The identity running `ensure_collection_exists()` needs permission to create +collections and indexes. Each filtered data field must declare +`is_indexed=True`; the connector creates its ordinary ascending index alongside +separate vector indexes. + +## Example + +```python +import asyncio +from dataclasses import dataclass +from typing import Annotated + +from agent_framework import Filter, VectorStoreField, vectorstoremodel +from agent_framework_azure_documentdb import AzureDocumentDBStore + + +@vectorstoremodel(collection_name="articles") +@dataclass +class Article: + id: Annotated[str, VectorStoreField("key")] + category: Annotated[str, VectorStoreField("data", is_indexed=True)] + embedding: Annotated[ + list[float] | None, + VectorStoreField("vector", dimensions=3, index_kind="ivf_flat"), + ] = None + + +async def main() -> None: + async with AzureDocumentDBStore() as store: + collection = store.get_collection(Article) + await collection.ensure_collection_exists() + await collection.upsert( + [Article("one", "database", [1.0, 0.0, 0.0])], + generate_vectors=False, + ) + results = await collection.search( + vector=[1.0, 0.0, 0.0], + filter=Filter("category", "eq", "database"), + ) + async for result in results: + print(result["record"], result["score"]) + + +asyncio.run(main()) +``` + +## Limits + +The connector supports explicit string and signed 64-bit integer `_id` keys, +multiple top-level dense vector fields, storage aliases, IVF/HNSW/DiskANN +indexes, native metadata filters, server-side paging, and native `searchScore` +values. Generated ObjectIds, ordered retrieval, hybrid/full-text search, +sparse/binary vectors, nested field paths, compressed vectors, and automatic +schema/index migration are not supported. + +The core `default` index kind maps to IVF so it does not silently require an +M30+ tier. Select `hnsw` or `disk_ann` explicitly when those service contracts +and cluster requirements are appropriate. + +Vector dimensions are conservatively limited to the service's 2,000-dimension +standard-vector contract. Azure DocumentDB also offers higher limits with +half-precision or product quantization; those distinct index/storage options are +outside this connector. + +`score_threshold` uses native metric units after `$search` selects its `k` +candidates and before `$skip`/`$limit`. It is a minimum for cosine and inner +product scores, where larger is better, and a maximum for Euclidean distance, +where smaller is better. Set a larger `operation_options={"k": ...}` candidate +window when needed. Thresholded ANN search can return fewer than `top`; the +connector does not fetch or filter a client-side prefix. Algorithm tuning uses +`n_probes`, `ef_search`, or `l_search` for IVF, HNSW, or DiskANN respectively. + +See the +[Azure DocumentDB vector search guide](https://learn.microsoft.com/azure/documentdb/vector-search), +[Azure DocumentDB limits](https://learn.microsoft.com/azure/documentdb/limitations), +[PyMongo async API](https://pymongo.readthedocs.io/en/stable/api/pymongo/asynchronous/), +and [Agent Framework documentation](https://learn.microsoft.com/agent-framework/). diff --git a/python/packages/azure-documentdb/agent_framework_azure_documentdb/__init__.py b/python/packages/azure-documentdb/agent_framework_azure_documentdb/__init__.py new file mode 100644 index 00000000000..50c19d003a3 --- /dev/null +++ b/python/packages/azure-documentdb/agent_framework_azure_documentdb/__init__.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure DocumentDB vector collections and stores.""" + +from __future__ import annotations + +import importlib.metadata + +from ._vector_store import AzureDocumentDBCollection, AzureDocumentDBSettings, AzureDocumentDBStore + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "AzureDocumentDBCollection", + "AzureDocumentDBSettings", + "AzureDocumentDBStore", + "__version__", +] diff --git a/python/packages/azure-documentdb/agent_framework_azure_documentdb/_vector_store.py b/python/packages/azure-documentdb/agent_framework_azure_documentdb/_vector_store.py new file mode 100644 index 00000000000..3ad4d2520f2 --- /dev/null +++ b/python/packages/azure-documentdb/agent_framework_azure_documentdb/_vector_store.py @@ -0,0 +1,1205 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Async Azure DocumentDB collection lifecycle, CRUD, filters, and vector search.""" + +from __future__ import annotations + +import hashlib +import math +from collections.abc import Mapping, Sequence +from dataclasses import replace +from typing import Any, ClassVar, Generic, cast + +from agent_framework import ( + BaseVectorCollection, + BaseVectorSearch, + BaseVectorStore, + FilterGroup, + SearchResults, + SecretString, + VectorStoreCollectionDefinition, + VectorStoreField, + load_settings, +) +from agent_framework._telemetry import FeatureIndex, mark_feature_used +from agent_framework._vector_filters import FilterExpression +from agent_framework._vectors import EmbeddingClient, SearchType, Vector +from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException +from bson import BSON +from bson.errors import InvalidDocument +from pymongo import AsyncMongoClient, ReplaceOne +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.asynchronous.database import AsyncDatabase +from pymongo.errors import CollectionInvalid, OperationFailure, PyMongoError +from typing_extensions import Self, TypedDict, TypeVar + +KeyT = TypeVar("KeyT", default=Any) +ModelT = TypeVar("ModelT", default=Any) + +_Document = dict[str, Any] +_ClientType = AsyncMongoClient[_Document] +_DatabaseType = AsyncDatabase[_Document] +_CollectionType = AsyncCollection[_Document] + +_MAX_DOCUMENT_BYTES = 16 * 1024 * 1024 +_MAX_ID_BYTES = 2 * 1024 +_MAX_INDEX_PATH_BYTES = 256 +_MAX_WRITES_PER_BATCH = 25_000 +_KEY_BATCH_SIZE = 1_000 +_INDEX_RACE_CODES = {68, 85, 86} +_INDEX_RACE_NAMES = {"IndexAlreadyExists", "IndexKeySpecsConflict", "IndexOptionsConflict"} +_NATIVE_SCORE_FIELD = "_af_documentdb_score" +_CLIENT_OPTION_NAMES = {"appname", "retryWrites", "tls"} +_INDEX_KINDS = { + "default": "vector-ivf", + "ivf_flat": "vector-ivf", + "hnsw": "vector-hnsw", + "disk_ann": "vector-diskann", +} +_METRICS = { + "DEFAULT": "COS", + "cosine_similarity": "COS", + "dot_prod": "IP", + "euclidean_distance": "L2", +} +_DATA_TYPES = {"str", "int", "float", "bool", "bytes", "list", "dict"} + + +class AzureDocumentDBSettings(TypedDict, total=False): + """Connection settings resolved from explicit values, a selected file, or the process environment.""" + + connection_string: SecretString | None + """Azure DocumentDB MongoDB connection string from ``AZURE_DOCUMENTDB_CONNECTION_STRING``.""" + + database_name: str | None + """Database name from ``AZURE_DOCUMENTDB_DATABASE_NAME``.""" + + +def _validate_operation_options( + options: Mapping[str, Any] | None, + allowed: set[str] | None = None, +) -> dict[str, Any]: + result = dict(options or {}) + if unknown := result.keys() - (allowed or set()): + raise NotImplementedError(f"Unsupported Azure DocumentDB operation option(s): {', '.join(sorted(unknown))}.") + return result + + +def _validate_integer(value: Any, name: str, minimum: int, maximum: int | None = None) -> int: + if type(value) is not int or value < minimum or (maximum is not None and value > maximum): + requirement = f"between {minimum} and {maximum}" if maximum is not None else f"at least {minimum}" + raise ValueError(f"{name} must be an integer {requirement}.") + return value + + +def _validate_database_name(name: Any) -> str: + if not isinstance(name, str) or not name or len(name.encode("utf-8")) > 63: + raise ValueError("database_name must contain 1-63 UTF-8 bytes.") + if any(character in name for character in '/\\."$*<>:|?\0'): + raise ValueError("database_name contains a character unsupported by Azure DocumentDB.") + return name + + +def _validate_collection_name(name: Any) -> str: + if not isinstance(name, str) or not name or len(name.encode("utf-8")) > 255: + raise ValueError("collection_name must contain 1-255 UTF-8 bytes.") + if "\0" in name or "$" in name or name.startswith("system."): + raise ValueError("collection_name contains a reserved Azure DocumentDB name or character.") + return name + + +def _validate_storage_name(name: str, *, indexed: bool) -> str: + encoded = name.encode("utf-8") + if not name or "\0" in name or "." in name or name.startswith("$"): + raise ValueError("Azure DocumentDB fields must be non-empty top-level names without '.', '$' prefixes, or NUL.") + if indexed and len(encoded) > _MAX_INDEX_PATH_BYTES: + raise ValueError(f"Azure DocumentDB indexed field paths cannot exceed {_MAX_INDEX_PATH_BYTES} UTF-8 bytes.") + return name + + +def _validate_client_options(options: Mapping[str, Any] | None) -> dict[str, Any]: + result = dict(options or {}) + if unknown := result.keys() - _CLIENT_OPTION_NAMES: + raise NotImplementedError(f"Unsupported Azure DocumentDB client option(s): {', '.join(sorted(unknown))}.") + if "tls" in result and result["tls"] is not True: + raise ValueError("Azure DocumentDB requires client_options['tls'] to be True.") + if "retryWrites" in result and result["retryWrites"] is not False: + raise ValueError("Azure DocumentDB requires client_options['retryWrites'] to be False.") + if "appname" in result: + app_name = result["appname"] + if not isinstance(app_name, str) or not app_name or "\0" in app_name or len(app_name.encode()) > 128: + raise ValueError("client_options['appname'] must contain 1-128 UTF-8 bytes and no NUL.") + return {"tls": True, "retryWrites": False, "appname": "agent-framework-azure-documentdb", **result} + + +class _Client: + """Own one connector-created client or borrow a caller's resolved database.""" + + def __init__(self, client: _ClientType, database: _DatabaseType, *, owned: bool) -> None: + self.client = client + self.database = database + self.owned = owned + self.closed = False + + def ensure_open(self) -> None: + if self.closed: + raise RuntimeError("The Azure DocumentDB client is closed.") + + async def close(self) -> None: + if not self.closed: + if self.owned: + await self.client.close() + self.closed = True + + +def _create_client( + connection_string: str | SecretString | None, + database_name: str | None, + *, + client: _ClientType | None, + database: _DatabaseType | None, + client_options: Mapping[str, Any] | None, + env_file_path: str | None, + env_file_encoding: str | None, +) -> _Client: + if database is not None: + if any( + value is not None + for value in ( + connection_string, + database_name, + client, + client_options, + env_file_path, + env_file_encoding, + ) + ): + raise ValueError("database cannot be combined with a client or connection settings.") + if not isinstance(database, AsyncDatabase): + raise TypeError("database must be a PyMongo AsyncDatabase.") + return _Client(database.client, database, owned=False) + if client is not None: + if any(value is not None for value in (connection_string, client_options, env_file_path, env_file_encoding)): + raise ValueError("client cannot be combined with connection settings or client_options.") + if not isinstance(client, AsyncMongoClient): + raise TypeError("client must be a PyMongo AsyncMongoClient.") + if database_name is None: + raise ValueError("database_name is required with an injected client.") + resolved_database_name = _validate_database_name(database_name) + return _Client(client, client[resolved_database_name], owned=False) + + settings = load_settings( + AzureDocumentDBSettings, + env_prefix="AZURE_DOCUMENTDB_", + connection_string=connection_string, + database_name=database_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + resolved_connection_string = settings.get("connection_string") + if ( + not isinstance(resolved_connection_string, SecretString) + or not resolved_connection_string.get_secret_value().strip() + ): + raise ValueError("A non-empty Azure DocumentDB connection_string is required.") + resolved_database_name = _validate_database_name(settings.get("database_name")) + created_client: _ClientType = AsyncMongoClient( + resolved_connection_string.get_secret_value(), + **_validate_client_options(client_options), + ) + return _Client(created_client, created_client[resolved_database_name], owned=True) + + +def _prepare_key(value: Any, expected_type: str | None) -> str | int: + if isinstance(value, bool) or not isinstance(value, (str, int)): + raise TypeError("Azure DocumentDB keys must be strings or signed 64-bit integers, not booleans.") + if expected_type == "str" and not isinstance(value, str): + raise TypeError("Azure DocumentDB key must match its declared string type.") + if expected_type == "int" and type(value) is not int: + raise TypeError("Azure DocumentDB key must match its declared integer type.") + if isinstance(value, str): + if len(value.encode("utf-8")) > _MAX_ID_BYTES: + raise ValueError(f"Azure DocumentDB string keys cannot exceed {_MAX_ID_BYTES} UTF-8 bytes.") + elif not -(2**63) <= value < 2**63: + raise ValueError("Azure DocumentDB integer keys must fit a signed 64-bit BSON integer.") + return value + + +def _prepare_untyped_bson_value(value: Any, *, path: str) -> Any: + if value is None or isinstance(value, (str, bytes, bool)): + return value + if isinstance(value, int): + if not -(2**63) <= value < 2**63: + raise ValueError(f"Field '{path}' contains an integer outside the signed 64-bit BSON range.") + return value + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError(f"Field '{path}' contains a non-finite number.") + return value + if isinstance(value, list): + return [_prepare_untyped_bson_value(item, path=f"{path}[]") for item in cast(list[Any], value)] + if isinstance(value, dict): + result: dict[str, Any] = {} + for key, item in cast(dict[Any, Any], value).items(): + if not isinstance(key, str) or not key or "\0" in key or "." in key or key.startswith("$"): + raise TypeError( + f"Field '{path}' requires non-empty string object keys without '.', '$' prefixes, or NUL." + ) + result[key] = _prepare_untyped_bson_value(item, path=f"{path}.{key}") + return result + raise TypeError(f"Field '{path}' contains unsupported BSON value type '{type(value).__name__}'.") + + +def _prepare_field_value(field: VectorStoreField, value: Any) -> Any: + if value is None: + return None + kind = field.type_ + if kind == "str" and isinstance(value, str): + return value + if kind == "int" and type(value) is int: + return _prepare_untyped_bson_value(value, path=field.name) + if kind == "float" and type(value) in (int, float): + number = float(value) + if not math.isfinite(number): + raise ValueError(f"Field '{field.name}' requires a finite number.") + return number + if kind == "bool" and type(value) is bool: + return value + if kind == "bytes" and isinstance(value, bytes): + return value + if kind == "list" and isinstance(value, list): + return _prepare_untyped_bson_value(value, path=field.name) + if kind == "dict" and isinstance(value, dict): + return _prepare_untyped_bson_value(value, path=field.name) + raise TypeError(f"Azure DocumentDB field '{field.name}' requires declared type '{kind}'.") + + +def _prepare_dense_vector(value: Any, field: VectorStoreField) -> list[float | int] | None: + if value is None: + return None + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + raise TypeError(f"Vector field '{field.name}' requires a dense numeric sequence.") + result: list[float | int] = [] + for item in cast(Sequence[Any], value): + if isinstance(item, bool) or not isinstance(item, (int, float)): + raise TypeError(f"Vector field '{field.name}' must contain numbers, not booleans.") + if isinstance(item, int): + if not -(2**63) <= item < 2**63: + raise ValueError(f"Vector field '{field.name}' contains an integer outside the BSON range.") + result.append(item) + elif not math.isfinite(item): + raise ValueError(f"Vector field '{field.name}' must contain finite numbers.") + else: + result.append(item) + return result + + +def _validate_bson_document(document: _Document) -> None: + try: + encoded = BSON.encode(document) + except (InvalidDocument, OverflowError) as exc: + raise TypeError("Azure DocumentDB records must be BSON encodable.") from exc + if len(encoded) > _MAX_DOCUMENT_BYTES: + raise ValueError(f"Azure DocumentDB records cannot exceed {_MAX_DOCUMENT_BYTES} encoded BSON bytes.") + + +def _prepare_filter_field( + fields: Sequence[VectorStoreField], + definition: VectorStoreCollectionDefinition, + name: str, +) -> tuple[VectorStoreField, str]: + if "." in name: + raise NotImplementedError("Azure DocumentDB filters support declared top-level logical fields only.") + logical_field = definition.try_get_field(name) + if logical_field is None: + raise ValueError(f"Unknown Azure DocumentDB field '{name}'.") + field = next(field for field in fields if field.name == logical_field.name) + if field.field_type == "vector": + raise NotImplementedError("Azure DocumentDB portable filters do not operate on vector fields.") + if field.field_type == "key": + return field, "_id" + if field.type_ == "dict": + raise NotImplementedError( + "Azure DocumentDB filters do not support dictionary fields because " + "BSON document equality is order-sensitive." + ) + if field.is_indexed is not True: + raise NotImplementedError(f"Azure DocumentDB filter field '{field.name}' must declare is_indexed=True.") + return field, field.storage_name or field.name + + +def _prepare_filter_operand(field: VectorStoreField, value: Any, *, ordered: bool = False) -> Any: + if value is None: + if ordered: + raise TypeError("Ordered Azure DocumentDB filter operands cannot be null.") + return None + kind = field.type_ + if kind == "float" and type(value) in (int, float): + if not math.isfinite(value): + raise ValueError("Azure DocumentDB numeric filter operands must be finite.") + return value + if kind == "int" and type(value) in (int, float): + if isinstance(value, int) and not -(2**63) <= value < 2**63: + raise ValueError("Azure DocumentDB integer filter operands must fit signed 64-bit BSON integers.") + if isinstance(value, float) and not math.isfinite(value): + raise ValueError("Azure DocumentDB numeric filter operands must be finite.") + return value + if ordered and kind not in {"str", "int", "float"}: + raise NotImplementedError("Ordered Azure DocumentDB filters require a string, int, or float field.") + if kind == "str" and isinstance(value, str): + return value + if kind == "bool" and type(value) is bool: + return value + if kind == "bytes" and isinstance(value, bytes): + return value + if kind == "list" and isinstance(value, list): + return _prepare_untyped_bson_value(value, path=field.name) + if kind == "dict" and isinstance(value, dict): + return _prepare_untyped_bson_value(value, path=field.name) + return _TYPE_MISMATCH + + +class _TypeMismatch: + pass + + +_TYPE_MISMATCH = _TypeMismatch() + + +class _FilterCompiler: + """Translate a core filter snapshot to a BSON query without string interpolation.""" + + def __init__( + self, + fields: Sequence[VectorStoreField], + definition: VectorStoreCollectionDefinition, + ) -> None: + self.fields = fields + self.definition = definition + + def compile(self, expression: FilterExpression) -> _Document: + return self._prepare_filter_condition(expression) + + @staticmethod + def _false_filter() -> _Document: + return {"_id": {"$exists": False}} + + def _prepare_filter_group(self, expression: FilterGroup) -> _Document: + children = [self._prepare_filter_condition(child) for child in expression.filters] + if expression.operator == "and": + return {"$and": children} + if expression.operator == "or": + return {"$or": children} + return {"$nor": children} + + def _prepare_filter_condition(self, expression: FilterExpression) -> _Document: + if isinstance(expression, FilterGroup): + return self._prepare_filter_group(expression) + field, name = _prepare_filter_field(self.fields, self.definition, expression.field_name) + operator, value = expression.operator, expression.value + if operator == "exists": + return {name: {"$exists": True}} + if operator == "is_null": + return {"$and": [{name: {"$exists": True}}, {name: {"$eq": None}}]} + if operator == "is_not_null": + return {"$and": [{name: {"$exists": True}}, {name: {"$ne": None}}]} + if operator in {"eq", "ne"}: + operand = _prepare_filter_operand(field, value) + if operand is _TYPE_MISMATCH: + if operator == "eq": + return self._false_filter() + return {name: {"$exists": True}} + condition = {name: {f"${operator}": operand}} + if operator == "ne" or operand is None: + return {"$and": [{name: {"$exists": True}}, condition]} + return condition + if operator in {"gt", "gte", "lt", "lte"}: + operand = _prepare_filter_operand(field, value, ordered=True) + if operand is _TYPE_MISMATCH: + raise TypeError(f"Ordered filter operand does not match field '{field.name}'.") + return {name: {f"${operator}": operand}} + if operator == "between": + lower = _prepare_filter_operand(field, value[0], ordered=True) + upper = _prepare_filter_operand(field, value[1], ordered=True) + if lower is _TYPE_MISMATCH or upper is _TYPE_MISMATCH: + raise TypeError(f"Between operands do not match field '{field.name}'.") + return {name: {"$gte": lower, "$lte": upper}} + if operator in {"in", "not_in"}: + if field.type_ == "list": + raise NotImplementedError( + "Use contains, contains_any, or contains_all for Azure DocumentDB list fields." + ) + operands = [_prepare_filter_operand(field, item) for item in value] + operands = [item for item in operands if item is not _TYPE_MISMATCH] + if not operands: + if operator == "in": + return self._false_filter() + return {"$and": [{name: {"$exists": True}}, {name: {"$ne": None}}]} + condition = {name: {"$in" if operator == "in" else "$nin": operands}} + if operator == "not_in" or None in operands: + guards: list[_Document] = [{name: {"$exists": True}}] + if operator == "not_in": + guards.append({name: {"$ne": None}}) + guards.append(condition) + return {"$and": guards} + return condition + if operator in {"contains", "contains_any", "contains_all"}: + if field.type_ != "list": + raise TypeError("Azure DocumentDB collection membership requires a declared list field.") + operands = [value] if operator == "contains" else list(value) + if any(isinstance(item, (list, dict)) for item in operands): + raise NotImplementedError("Nested collection membership has ambiguous MongoDB array semantics.") + prepared = [_prepare_untyped_bson_value(item, path=f"{field.name}[]") for item in operands] + if not prepared: + return ( + {"$and": [{name: {"$exists": True}}, {name: {"$ne": None}}]} + if operator == "contains_all" + else self._false_filter() + ) + native_operator = "$all" if operator == "contains_all" else "$in" + return { + "$and": [ + {name: {"$exists": True}}, + {name: {"$ne": None}}, + {name: {native_operator: prepared}}, + ] + } + raise NotImplementedError( + f"Azure DocumentDB does not support portable filter operator '{operator}' in this connector. " + "Literal text operations are not equivalent to regular-expression or analyzed search." + ) + + +def _index_name(collection_name: str, field_name: str, kind: str) -> str: + digest = hashlib.sha256(f"{collection_name}\0{field_name}\0{kind}".encode()).hexdigest()[:32] + return f"af_{kind}_{digest}" + + +def _vector_index_options(field: VectorStoreField) -> _Document: + kind = _INDEX_KINDS[cast(str, field.index_kind)] + annotations = field.provider_annotations + options: _Document = { + "kind": kind, + "dimensions": field.dimensions, + "similarity": _METRICS[cast(str, field.distance_function)], + } + if kind == "vector-ivf": + options["numLists"] = annotations.get("azure_documentdb.num_lists", 1) + elif kind == "vector-hnsw": + options["m"] = annotations.get("azure_documentdb.m", 16) + options["efConstruction"] = annotations.get("azure_documentdb.ef_construction", 64) + else: + options["maxDegree"] = annotations.get("azure_documentdb.max_degree", 32) + options["lBuild"] = annotations.get("azure_documentdb.l_build", 50) + return options + + +def _vector_index_spec(collection_name: str, field: VectorStoreField) -> _Document: + path = field.storage_name or field.name + return { + "name": _index_name(collection_name, path, "vector"), + "key": {path: "cosmosSearch"}, + "cosmosSearchOptions": _vector_index_options(field), + } + + +def _data_index_spec(collection_name: str, field: VectorStoreField) -> _Document: + path = field.storage_name or field.name + return {"name": _index_name(collection_name, path, "filter"), "key": {path: 1}} + + +def _canonical_vector_options(value: Mapping[str, Any]) -> _Document: + raw_kind = value.get("kind") + kind = raw_kind if isinstance(raw_kind, str) else None + common: set[str] = {"kind", "dimensions", "similarity"} + options_by_kind: Mapping[str, set[str]] = { + "vector-ivf": {"numLists"}, + "vector-hnsw": {"m", "efConstruction"}, + "vector-diskann": {"maxDegree", "lBuild"}, + } + algorithm_options = options_by_kind.get(kind, set[str]()) if kind is not None else set[str]() + if unknown := value.keys() - common - algorithm_options: + raise ValueError( + f"Existing Azure DocumentDB vector index has unsupported option(s): {', '.join(sorted(unknown))}." + ) + result: _Document = { + "kind": kind, + "dimensions": value.get("dimensions"), + "similarity": str(value.get("similarity", "")).upper(), + } + if kind == "vector-ivf": + result["numLists"] = value.get("numLists") + elif kind == "vector-hnsw": + result["m"] = value.get("m", 16) + result["efConstruction"] = value.get("efConstruction", 64) + elif kind == "vector-diskann": + result["maxDegree"] = value.get("maxDegree", 32) + result["lBuild"] = value.get("lBuild", 50) + return result + + +def _matching_index(indexes: Sequence[Mapping[str, Any]], expected: Mapping[str, Any]) -> bool: + expected_name = expected["name"] + expected_key = expected["key"] + is_vector = "cosmosSearchOptions" in expected + for index in indexes: + same_name = index.get("name") == expected_name + same_key = index.get("key") == expected_key + if not same_name and not same_key: + continue + if not same_key: + raise ValueError(f"Existing Azure DocumentDB index '{expected_name}' has an incompatible key.") + if is_vector: + raw_options = index.get("cosmosSearch") or index.get("cosmosSearchOptions") + if not isinstance(raw_options, Mapping): + raise ValueError("Existing Azure DocumentDB vector index is missing cosmosSearch options.") + if _canonical_vector_options(cast(Mapping[str, Any], raw_options)) != _canonical_vector_options( + cast(Mapping[str, Any], expected["cosmosSearchOptions"]) + ): + raise ValueError( + f"Existing Azure DocumentDB vector index on '{next(iter(cast(Mapping[str, Any], expected_key)))}' " + "is incompatible with the collection definition." + ) + elif ( + index.get("unique", False) is not False + or index.get("sparse", False) is not False + or "partialFilterExpression" in index + or "expireAfterSeconds" in index + or "collation" in index + ): + raise ValueError( + f"Existing Azure DocumentDB filter index on " + f"'{next(iter(cast(Mapping[str, Any], expected_key)))}' has incompatible options." + ) + return True + return False + + +class AzureDocumentDBCollection( + BaseVectorCollection[KeyT, ModelT], + BaseVectorSearch[KeyT, ModelT], + Generic[KeyT, ModelT], +): + """Store typed BSON documents and search vectors through ``$search.cosmosSearch``. + + Keys are explicit string or signed 64-bit integer ``_id`` values. Generated + ObjectIds are intentionally unsupported because converting them to a core key + type would not preserve identity. Scores retain Azure DocumentDB's native + ``searchScore`` units, where larger values rank first. + """ + + supported_key_types: ClassVar[set[str] | None] = {"str", "int"} + supported_vector_types: ClassVar[set[str] | None] = {"float", "float32", "float64"} + supported_search_types: ClassVar[set[SearchType]] = {"vector"} + + def __init__( + self, + record_type: type[ModelT], + *, + connection_string: str | SecretString | None = None, + database_name: str | None = None, + client: _ClientType | None = None, + database: _DatabaseType | None = None, + collection: _CollectionType | None = None, + client_options: Mapping[str, Any] | None = None, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Azure DocumentDB collection without performing network I/O. + + Args: + record_type: Registered model type, or ``dict`` with an explicit definition. + connection_string: MongoDB connection string for a connector-owned client. + database_name: Database selected for a created or injected client. + client: Caller-owned PyMongo async client. Requires ``database_name``. + database: Caller-owned resolved PyMongo async database. + collection: Caller-owned resolved PyMongo async collection. + client_options: Created-client options limited to ``tls``, ``retryWrites``, and ``appname``. + definition: Explicit dictionary schema. + collection_name: Collection name overriding the model definition. + embedding_generator: Default local embedding generator. + env_file_path: Optional selected settings file. + env_file_encoding: Encoding of the selected settings file. + """ + if collection is not None: + if not isinstance(collection, AsyncCollection): + raise TypeError("collection must be a PyMongo AsyncCollection.") + if collection_name is not None and collection_name != collection.name: + raise ValueError("collection_name must match the injected collection.") + if any( + value is not None + for value in ( + connection_string, + database_name, + client, + database, + client_options, + env_file_path, + env_file_encoding, + ) + ): + raise ValueError("collection cannot be combined with a client, database, or connection settings.") + resolved_collection_name = collection.name + else: + resolved_collection_name = collection_name + super().__init__( + record_type, + definition=definition, + collection_name=resolved_collection_name, + embedding_generator=embedding_generator, + managed_client=collection is None and client is None and database is None, + ) + _validate_collection_name(self.collection_name) + self._fields = tuple( + replace(field, provider_annotations=field.provider_annotations) for field in self.definition.fields + ) + if collection is not None: + self._client = _Client(collection.database.client, collection.database, owned=False) + self.collection = collection + else: + self._client = _create_client( + connection_string, + database_name, + client=client, + database=database, + client_options=client_options, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + self.collection = self._client.database[self.collection_name] + self._shared_client = False + + def _validate_data_model(self) -> None: + super()._validate_data_model() + if self.definition.key_field.is_auto_generated: + raise NotImplementedError( + "Azure DocumentDB generated ObjectId keys are unsupported; provide an explicit string or integer key." + ) + for field in self.definition.fields: + name = "_id" if field.field_type == "key" else field.storage_name or field.name + _validate_storage_name(name, indexed=field.field_type == "vector" or field.is_indexed is True) + if field.field_type != "key" and name == "_id": + raise ValueError("Only the key field can map to Azure DocumentDB '_id'.") + if field.field_type != "key" and name == _NATIVE_SCORE_FIELD: + raise ValueError(f"Azure DocumentDB field name '{_NATIVE_SCORE_FIELD}' is reserved.") + if field.field_type == "data": + if field.type_ not in _DATA_TYPES: + raise NotImplementedError( + f"Azure DocumentDB data field '{field.name}' needs a supported explicit type; " + f"got '{field.type_}'." + ) + if field.is_full_text_indexed: + raise NotImplementedError("Azure DocumentDB full-text and hybrid search are not supported.") + if field.provider_annotations: + raise NotImplementedError("Azure DocumentDB provider annotations apply only to vector fields.") + continue + if field.field_type == "key": + if field.provider_annotations: + raise NotImplementedError("Azure DocumentDB key provider annotations are unsupported.") + continue + if type(field.dimensions) is not int or not 1 <= field.dimensions <= 2000: + raise ValueError("Azure DocumentDB standard vector dimensions must be an integer from 1 to 2000.") + if field.index_kind not in _INDEX_KINDS: + raise NotImplementedError(f"Unsupported Azure DocumentDB index kind '{field.index_kind}'.") + if field.distance_function not in _METRICS: + raise NotImplementedError( + f"Unsupported Azure DocumentDB distance function '{field.distance_function}'." + ) + annotations = field.provider_annotations + kind = _INDEX_KINDS[field.index_kind] + allowed: set[str] + if kind == "vector-ivf": + allowed = {"azure_documentdb.num_lists"} + _validate_integer( + annotations.get("azure_documentdb.num_lists", 1), + "azure_documentdb.num_lists", + 1, + ) + elif kind == "vector-hnsw": + allowed = {"azure_documentdb.m", "azure_documentdb.ef_construction"} + m = _validate_integer(annotations.get("azure_documentdb.m", 16), "azure_documentdb.m", 2, 100) + _validate_integer( + annotations.get("azure_documentdb.ef_construction", 64), + "azure_documentdb.ef_construction", + 2 * m, + 1000, + ) + else: + allowed = {"azure_documentdb.max_degree", "azure_documentdb.l_build"} + _validate_integer( + annotations.get("azure_documentdb.max_degree", 32), + "azure_documentdb.max_degree", + 20, + 2048, + ) + _validate_integer( + annotations.get("azure_documentdb.l_build", 50), + "azure_documentdb.l_build", + 10, + 500, + ) + if unknown := annotations.keys() - allowed: + raise NotImplementedError( + f"Unsupported Azure DocumentDB vector annotation(s): {', '.join(sorted(unknown))}." + ) + + async def __aenter__(self) -> Self: + """Enter the collection context manager.""" + return self + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Close resources owned directly by this collection.""" + await self.aclose() + + async def aclose(self) -> None: + """Close a directly owned client, never an injected or store-shared client.""" + if not self._shared_client: + await self._client.close() + + async def _list_indexes(self) -> list[_Document]: + cursor = await self.collection.list_indexes() + return [dict(index) async for index in cursor] + + async def _ensure_index(self, expected: _Document, *, max_time_ms: int | None) -> None: + indexes = await self._list_indexes() + if _matching_index(indexes, expected): + return + command: _Document = {"createIndexes": self.collection_name, "indexes": [expected]} + if max_time_ms is not None: + command["maxTimeMS"] = max_time_ms + try: + await self._client.database.command(command) + except OperationFailure as exc: + details: Mapping[str, Any] = exc.details if isinstance(exc.details, Mapping) else {} + if exc.code not in _INDEX_RACE_CODES and details.get("codeName") not in _INDEX_RACE_NAMES: + raise + indexes = await self._list_indexes() + if not _matching_index(indexes, expected): + raise IntegrationInvalidResponseException( + f"Azure DocumentDB did not expose the requested index '{expected['name']}'." + ) + + async def ensure_collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> None: + """Create the collection and reconcile compatible vector/filter indexes.""" + options = _validate_operation_options(operation_options, {"create_indexes", "max_time_ms"}) + self._client.ensure_open() + create_indexes = options.get("create_indexes", True) + if not isinstance(create_indexes, bool): + raise TypeError("create_indexes must be a boolean.") + max_time_ms = options.get("max_time_ms") + if max_time_ms is not None: + max_time_ms = _validate_integer(max_time_ms, "max_time_ms", 1) + if not await self.collection_exists(): + try: + await self._client.database.create_collection(self.collection_name) + except CollectionInvalid: + if not await self.collection_exists(): + raise + except OperationFailure as exc: + if exc.code != 48 or not await self.collection_exists(): + raise + if not create_indexes: + return + for field in self._fields: + if field.field_type == "vector": + await self._ensure_index(_vector_index_spec(self.collection_name, field), max_time_ms=max_time_ms) + elif field.field_type == "data" and field.is_indexed: + await self._ensure_index(_data_index_spec(self.collection_name, field), max_time_ms=max_time_ms) + + async def collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> bool: + """Return whether the configured collection exists.""" + _validate_operation_options(operation_options) + self._client.ensure_open() + return self.collection_name in await self._client.database.list_collection_names() + + async def ensure_collection_deleted(self, *, operation_options: Mapping[str, Any] | None = None) -> None: + """Drop only the configured collection when it exists.""" + _validate_operation_options(operation_options) + self._client.ensure_open() + if await self.collection_exists(): + await self._client.database.drop_collection(self.collection_name) + + def _serialize_dicts_to_store_models( + self, + records: Sequence[dict[str, Any]], + *, + context: Mapping[str, Any] | None = None, + ) -> Sequence[_Document]: + result: list[_Document] = [] + for record in records: + document: _Document = {} + for field in self._fields: + source_name = field.storage_name or field.name + if source_name not in record: + raise ValueError(f"Record is missing Azure DocumentDB field '{field.name}'.") + if field.field_type == "key": + document["_id"] = _prepare_key(record[source_name], field.type_) + elif field.field_type == "vector": + document[source_name] = _prepare_dense_vector(record[source_name], field) + else: + document[source_name] = _prepare_field_value(field, record[source_name]) + _validate_bson_document(document) + result.append(document) + return result + + def _deserialize_store_models_to_dicts( + self, + records: Sequence[Any], + *, + context: Mapping[str, Any] | None = None, + ) -> Sequence[dict[str, Any]]: + result: list[dict[str, Any]] = [] + key_name = self.definition.key_field_storage_name + for item in records: + if not isinstance(item, Mapping): + raise IntegrationInvalidResponseException("Azure DocumentDB returned a non-document record.") + document = dict(cast(Mapping[str, Any], item)) + if "_id" not in document: + raise IntegrationInvalidResponseException("Azure DocumentDB returned a document without '_id'.") + document[key_name] = _prepare_key(document.pop("_id"), self.definition.key_field.type_) + result.append(document) + return result + + def _prepare_filter(self, filter: FilterExpression | None) -> _Document: + if filter is None: + return {} + result = _FilterCompiler(self._fields, self.definition).compile(filter) + try: + encoded = BSON.encode(result) + except (InvalidDocument, OverflowError) as exc: + raise TypeError("Azure DocumentDB filters must be BSON encodable.") from exc + if len(encoded) > _MAX_DOCUMENT_BYTES: + raise ValueError(f"Azure DocumentDB filters cannot exceed {_MAX_DOCUMENT_BYTES} encoded BSON bytes.") + return result + + def _projection(self, *, include_vectors: bool, score: bool = False) -> _Document: + projection: _Document = {"_id": 1} + for field in self._fields: + if field.field_type != "key" and (include_vectors or field.field_type != "vector"): + projection[field.storage_name or field.name] = 1 + if score: + projection[_NATIVE_SCORE_FIELD] = {"$meta": "searchScore"} + return projection + + async def _inner_upsert( + self, + records: Sequence[Any], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[KeyT]: + _validate_operation_options(operation_options) + self._client.ensure_open() + documents = [dict(cast(Mapping[str, Any], record)) for record in records] + for document in documents: + _validate_bson_document(document) + _prepare_key(document.get("_id"), self.definition.key_field.type_) + if not documents: + return [] + completed = 0 + try: + for start in range(0, len(documents), _MAX_WRITES_PER_BATCH): + batch = documents[start : start + _MAX_WRITES_PER_BATCH] + await self.collection.bulk_write( + [ReplaceOne({"_id": document["_id"]}, document, upsert=True) for document in batch], + ordered=True, + ) + completed += len(batch) + except PyMongoError as exc: + raise IntegrationException( + "Azure DocumentDB upsert failed. " + f"{completed} records in earlier batches were applied; the failing batch may be partially applied." + ) from exc + return [cast(KeyT, document["_id"]) for document in documents] + + async def _inner_get( + self, + *, + keys: Sequence[KeyT] | None = None, + filter: FilterExpression | None = None, + top: int = 10, + skip: int = 0, + order_by: Mapping[str, bool] | None = None, + include_vectors: bool = False, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[_Document]: + _validate_operation_options(operation_options) + self._client.ensure_open() + if order_by: + raise NotImplementedError("Azure DocumentDB ordered retrieval is not supported.") + projection = self._projection(include_vectors=include_vectors) + if keys is None: + native_filter = self._prepare_filter(filter) + cursor = self.collection.find(native_filter, projection=projection).skip(skip).limit(top) + return [document async for document in cursor] + if skip: + raise ValueError("Azure DocumentDB key retrieval cannot be combined with skip.") + prepared_keys = [_prepare_key(key, self.definition.key_field.type_) for key in keys] + if not prepared_keys: + return [] + documents: list[_Document] = [] + for start in range(0, len(prepared_keys), _KEY_BATCH_SIZE): + cursor = self.collection.find( + {"_id": {"$in": prepared_keys[start : start + _KEY_BATCH_SIZE]}}, + projection=projection, + ) + documents.extend([document async for document in cursor]) + by_key = {document["_id"]: document for document in documents} + return [by_key[key] for key in prepared_keys if key in by_key] + + async def _inner_delete( + self, + keys: Sequence[KeyT], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + _validate_operation_options(operation_options) + self._client.ensure_open() + prepared_keys = [_prepare_key(key, self.definition.key_field.type_) for key in keys] + completed = 0 + try: + for start in range(0, len(prepared_keys), _KEY_BATCH_SIZE): + batch = prepared_keys[start : start + _KEY_BATCH_SIZE] + await self.collection.delete_many({"_id": {"$in": batch}}) + completed += len(batch) + except PyMongoError as exc: + raise IntegrationException( + "Azure DocumentDB delete failed. " + f"{completed} keys in earlier batches were attempted; the failing batch may be partially applied." + ) from exc + + def _search_options( + self, + field: VectorStoreField, + *, + top: int, + skip: int, + operation_options: Mapping[str, Any] | None, + ) -> tuple[_Document, int | None]: + options = _validate_operation_options( + operation_options, + {"ef_search", "k", "l_search", "max_time_ms", "n_probes"}, + ) + minimum_k = top + skip + k = _validate_integer(options.get("k", minimum_k), "k", minimum_k) + query: _Document = {"k": k} + kind = _INDEX_KINDS[cast(str, field.index_kind)] + if kind == "vector-ivf": + if "ef_search" in options or "l_search" in options: + raise ValueError("IVF search supports n_probes, not ef_search or l_search.") + if "n_probes" in options: + num_lists = cast(int, field.provider_annotations.get("azure_documentdb.num_lists", 1)) + query["nProbes"] = _validate_integer(options["n_probes"], "n_probes", 1, num_lists) + elif kind == "vector-hnsw": + if "n_probes" in options or "l_search" in options: + raise ValueError("HNSW search supports ef_search, not n_probes or l_search.") + if "ef_search" in options: + query["efSearch"] = _validate_integer(options["ef_search"], "ef_search", 1) + else: + if "n_probes" in options or "ef_search" in options: + raise ValueError("DiskANN search supports l_search, not n_probes or ef_search.") + l_search = _validate_integer(options.get("l_search", max(40, k)), "l_search", 10, 1000) + if k > l_search: + raise ValueError("DiskANN search requires k to be less than or equal to l_search.") + query["lSearch"] = l_search + max_time_ms = options.get("max_time_ms") + if max_time_ms is not None: + max_time_ms = _validate_integer(max_time_ms, "max_time_ms", 1) + return query, max_time_ms + + async def _inner_search( + self, + *, + search_type: SearchType, + filter: FilterExpression | None = None, + values: Any | None = None, + vector: Vector | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[Any]: + if search_type != "vector" or additional_property_name is not None: + raise NotImplementedError("Azure DocumentDB supports dense vector search only.") + if vector is None: + raise ValueError("Azure DocumentDB search requires a vector or configured embedding generator.") + self._client.ensure_open() + resolved = self.definition.try_get_vector_field(vector_property_name) + if resolved is None: + raise ValueError("Select a vector_property_name from the Azure DocumentDB collection definition.") + field = next(field for field in self._fields if field.name == resolved.name) + query_vector = _prepare_dense_vector(vector, field) + if query_vector is None: + raise ValueError("Azure DocumentDB query vectors cannot be null.") + native_options, max_time_ms = self._search_options( + field, + top=top, + skip=skip, + operation_options=operation_options, + ) + cosmos_search: _Document = { + "path": field.storage_name or field.name, + "vector": query_vector, + **native_options, + } + native_filter = self._prepare_filter(filter) + if native_filter: + cosmos_search["filter"] = native_filter + metric = _METRICS[cast(str, field.distance_function)] + threshold_operator = "$lte" if metric == "L2" else "$gte" + threshold_direction = "maximum" if metric == "L2" else "minimum" + pipeline: list[_Document] = [ + {"$search": {"cosmosSearch": cosmos_search}}, + {"$project": self._projection(include_vectors=include_vectors, score=True)}, + ] + if score_threshold is not None: + if isinstance(score_threshold, bool) or not isinstance(score_threshold, (int, float)): + raise TypeError("Azure DocumentDB score_threshold must be a finite number.") + if not math.isfinite(score_threshold): + raise ValueError("Azure DocumentDB score_threshold must be a finite number.") + pipeline.append({"$match": {_NATIVE_SCORE_FIELD: {threshold_operator: score_threshold}}}) + if skip: + pipeline.append({"$skip": skip}) + pipeline.append({"$limit": top}) + cursor = ( + await self.collection.aggregate(pipeline, maxTimeMS=max_time_ms) + if max_time_ms is not None + else await self.collection.aggregate(pipeline) + ) + return SearchResults( + cursor, + metadata={ + "candidate_window": cosmos_search["k"], + "distance_function": field.distance_function or "DEFAULT", + "index_kind": field.index_kind, + "metric": metric, + "score_threshold_direction": threshold_direction, + }, + ) + + def _get_record_from_result(self, result: Any) -> _Document: + if not isinstance(result, Mapping): + raise IntegrationInvalidResponseException("Azure DocumentDB returned a non-document search result.") + document = dict(cast(Mapping[str, Any], result)) + if _NATIVE_SCORE_FIELD not in document: + raise IntegrationInvalidResponseException("Azure DocumentDB search result is missing searchScore.") + document.pop(_NATIVE_SCORE_FIELD) + return document + + def _get_score_from_result(self, result: Any) -> float: + if not isinstance(result, Mapping): + raise IntegrationInvalidResponseException("Azure DocumentDB returned a non-document search result.") + score = cast(Mapping[str, Any], result).get(_NATIVE_SCORE_FIELD) + if isinstance(score, bool) or not isinstance(score, (int, float)) or not math.isfinite(score): + raise IntegrationInvalidResponseException("Azure DocumentDB returned an invalid searchScore.") + return float(score) + + +class AzureDocumentDBStore(BaseVectorStore): + """Create collection clients that share one resolved Azure DocumentDB database.""" + + def __init__( + self, + *, + connection_string: str | SecretString | None = None, + database_name: str | None = None, + client: _ClientType | None = None, + database: _DatabaseType | None = None, + client_options: Mapping[str, Any] | None = None, + embedding_generator: EmbeddingClient | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a store without performing network I/O. + + Args: + connection_string: MongoDB connection string for a connector-owned client. + database_name: Database selected for a created or injected client. + client: Caller-owned PyMongo async client. Requires ``database_name``. + database: Caller-owned resolved PyMongo async database. + client_options: Created-client options limited to ``tls``, ``retryWrites``, and ``appname``. + embedding_generator: Default embedding generator for child collections. + env_file_path: Optional selected settings file. + env_file_encoding: Encoding of the selected settings file. + """ + super().__init__( + embedding_generator=embedding_generator, + managed_client=client is None and database is None, + ) + self._client = _create_client( + connection_string, + database_name, + client=client, + database=database, + client_options=client_options, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + + def get_collection( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> AzureDocumentDBCollection[Any, ModelT]: + """Create a collection that borrows this store's resolved database.""" + collection = AzureDocumentDBCollection( + record_type, + database=self._client.database, + definition=definition, + collection_name=collection_name, + embedding_generator=embedding_generator if embedding_generator is not None else self.embedding_generator, + ) + collection._client = self._client # pyright: ignore[reportPrivateUsage] + collection._shared_client = True # pyright: ignore[reportPrivateUsage] + return collection + + async def list_collection_names( + self, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[str]: + """List collection names in the resolved database.""" + _validate_operation_options(operation_options) + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + self._client.ensure_open() + return await self._client.database.list_collection_names() + + async def collection_exists( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> bool: + """Return whether one collection exists.""" + _validate_operation_options(operation_options) + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + self._client.ensure_open() + _validate_collection_name(collection_name) + return collection_name in await self._client.database.list_collection_names() + + async def _inner_ensure_collection_deleted( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + _validate_operation_options(operation_options) + self._client.ensure_open() + _validate_collection_name(collection_name) + await self._client.database.drop_collection(collection_name) + + async def aclose(self) -> None: + """Close the connector-owned client once, leaving injected clients open.""" + await self._client.close() + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Close the connector-owned client on context exit.""" + await self.aclose() diff --git a/python/packages/azure-documentdb/agent_framework_azure_documentdb/py.typed b/python/packages/azure-documentdb/agent_framework_azure_documentdb/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/azure-documentdb/pyproject.toml b/python/packages/azure-documentdb/pyproject.toml new file mode 100644 index 00000000000..6e515f78398 --- /dev/null +++ b/python/packages/azure-documentdb/pyproject.toml @@ -0,0 +1,66 @@ +[project] +name = "agent-framework-azure-documentdb" +description = "Azure DocumentDB vector stores for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260909" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.17.0,<2", + "pymongo>=4.13,<5", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +timeout = 120 +markers = [ + "flaky: marks tests that depend on external services", + "integration: requires an explicitly authorized Azure DocumentDB test database", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.ruff.lint.per-file-ignores] +"samples/**" = ["D", "INP", "commented-out-code", "RUF", "S", "print", "CPY"] +"tests/**" = ["D", "INP", "TD", "commented-out-code", "RUF", "S"] + +[tool.coverage.run] +omit = ["**/__init__.py"] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_azure_documentdb"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.test] +cmd = 'pytest -m "not integration" --cov=agent_framework_azure_documentdb --cov-report=term-missing:skip-covered tests' + +[tool.poe.tasks.test-integration] +cmd = "pytest -m integration tests" + +[build-system] +requires = ["flit-core>=3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/azure-documentdb/samples/README.md b/python/packages/azure-documentdb/samples/README.md new file mode 100644 index 00000000000..398c946dfff --- /dev/null +++ b/python/packages/azure-documentdb/samples/README.md @@ -0,0 +1,14 @@ +# Azure DocumentDB vector store sample + +`azure_documentdb_vectors.py` creates one uniquely named collection, writes +deterministic vectors, performs filtered searches across two vector fields, and +deletes the collection. Set `AZURE_DOCUMENTDB_CONNECTION_STRING` and +`AZURE_DOCUMENTDB_DATABASE_NAME` for a development database where collection +and index creation is authorized. + +Run it from `python/`: + +```bash +uv run --package agent-framework-azure-documentdb \ + python packages/azure-documentdb/samples/azure_documentdb_vectors.py +``` diff --git a/python/packages/azure-documentdb/samples/azure_documentdb_vectors.py b/python/packages/azure-documentdb/samples/azure_documentdb_vectors.py new file mode 100644 index 00000000000..64279810d9e --- /dev/null +++ b/python/packages/azure-documentdb/samples/azure_documentdb_vectors.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Annotated +from uuid import uuid4 + +from agent_framework import Filter, VectorStoreField, vectorstoremodel + +from agent_framework_azure_documentdb import AzureDocumentDBStore + +""" +Use Azure DocumentDB with two top-level vector fields and an indexed metadata filter. + +This sample uses deterministic vectors, not an embedding API. It creates and +deletes a uniquely named collection in the configured development database. +""" + + +@vectorstoremodel +@dataclass +class Note: + id: Annotated[str, VectorStoreField("key")] + text: Annotated[str, VectorStoreField("data")] + category: Annotated[str, VectorStoreField("data", is_indexed=True)] + title_vector: Annotated[ + list[float] | None, + VectorStoreField("vector", dimensions=3, index_kind="ivf_flat"), + ] = None + body_vector: Annotated[ + list[float] | None, + VectorStoreField("vector", dimensions=3, index_kind="ivf_flat"), + ] = None + + +async def main() -> None: + # 1. The store resolves AF settings and owns the PyMongo async client. + async with AzureDocumentDBStore() as store: + collection = store.get_collection(Note, collection_name=f"af_demo_{uuid4().hex}") + await collection.ensure_collection_exists() + try: + # 2. Preserve the supplied vectors rather than calling an embedding API. + await collection.upsert( + [ + Note("documentdb", "Azure DocumentDB stores vectors", "database", [1, 0, 0], [0, 1, 0]), + Note("travel", "A travel journal", "travel", [0, 1, 0], [1, 0, 0]), + ], + generate_vectors=False, + ) + + # 3. Select one vector path and apply the filter in cosmosSearch. + results = await collection.search( + vector=[1, 0, 0], + vector_property_name="title_vector", + filter=Filter("category", "eq", "database"), + ) + async for result in results: + print(result["record"].text, result["score"]) + + # 4. Ordinary retrieval omits vectors unless explicitly requested. + notes = await collection.get(["documentdb"], include_vectors=True) + print(notes[0].body_vector) + finally: + await collection.ensure_collection_deleted() + + +if __name__ == "__main__": + asyncio.run(main()) + +# Expected output resembles: +# Azure DocumentDB stores vectors 1.0 +# [0.0, 1.0, 0.0] diff --git a/python/packages/azure-documentdb/tests/azure_documentdb/conftest.py b/python/packages/azure-documentdb/tests/azure_documentdb/conftest.py new file mode 100644 index 00000000000..fc92323e637 --- /dev/null +++ b/python/packages/azure-documentdb/tests/azure_documentdb/conftest.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import AsyncIterator, Sequence +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agent_framework import VectorStoreCollectionDefinition, VectorStoreField +from pymongo import AsyncMongoClient +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.asynchronous.database import AsyncDatabase + +from agent_framework_azure_documentdb import AzureDocumentDBCollection + + +class AsyncCursor: + def __init__(self, values: Sequence[dict[str, Any]]) -> None: + self.values = list(values) + self.skip_value = 0 + self.limit_value: int | None = None + + def skip(self, value: int) -> AsyncCursor: + self.skip_value = value + return self + + def limit(self, value: int) -> AsyncCursor: + self.limit_value = value + return self + + def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + async def iterate() -> AsyncIterator[dict[str, Any]]: + end = None if self.limit_value is None else self.skip_value + self.limit_value + for value in self.values[self.skip_value : end]: + yield value + + return iterate() + + +@pytest.fixture +def definition_factory(): + def make( + *, + key_type: str = "str", + generated: bool = False, + dimensions: int = 3, + index_kind: str = "ivf_flat", + distance_function: str = "DEFAULT", + annotations: dict[str, Any] | None = None, + second_vector: bool = False, + category_indexed: bool = True, + ) -> VectorStoreCollectionDefinition: + fields = [ + VectorStoreField("key", name="id", type_=key_type, storage_name="record_id", is_auto_generated=generated), + VectorStoreField("data", name="text", type_="str", storage_name="body"), + VectorStoreField("data", name="category", type_="str", is_indexed=category_indexed), + VectorStoreField("data", name="number", type_="int", is_indexed=True), + VectorStoreField("data", name="ratio", type_="float", is_indexed=True), + VectorStoreField("data", name="flag", type_="bool", is_indexed=True), + VectorStoreField("data", name="tags", type_="list", is_indexed=True), + VectorStoreField( + "vector", + name="embedding", + type_="float", + storage_name="contentVector", + dimensions=dimensions, + index_kind=index_kind, + distance_function=distance_function, + provider_annotations=annotations, + ), + ] + if second_vector: + fields.append( + VectorStoreField( + "vector", + name="secondary", + type_="float", + storage_name="titleVector", + dimensions=dimensions, + index_kind="ivf_flat", + ) + ) + return VectorStoreCollectionDefinition(fields, collection_name="documents") + + return make + + +@pytest.fixture +def record_factory(): + def make( + id: str | int = "one", + *, + text: str = "Azure DocumentDB", + category: str | None = "database", + number: Any = 1, + ratio: Any = 1.5, + flag: Any = True, + tags: Any = None, + embedding: Any = None, + **extra: Any, + ) -> dict[str, Any]: + return { + "id": id, + "text": text, + "category": category, + "number": number, + "ratio": ratio, + "flag": flag, + "tags": ["azure", 1, True, None] if tags is None else tags, + "embedding": [1.0, 0.0, 0.0] if embedding is None else embedding, + **extra, + } + + return make + + +@pytest.fixture +def pymongo_objects(): + client = MagicMock(spec=AsyncMongoClient) + client.close = AsyncMock() + database = MagicMock(spec=AsyncDatabase) + database.name = "test_database" + database.client = client + database.list_collection_names = AsyncMock(return_value=["documents"]) + database.create_collection = AsyncMock() + database.drop_collection = AsyncMock() + database.command = AsyncMock(return_value={"ok": 1}) + collection = MagicMock(spec=AsyncCollection) + collection.name = "documents" + collection.database = database + collection.bulk_write = AsyncMock() + collection.delete_many = AsyncMock() + collection.list_indexes = AsyncMock(return_value=AsyncCursor([{"name": "_id_", "key": {"_id": 1}}])) + collection.aggregate = AsyncMock(return_value=AsyncCursor([])) + collection.find = MagicMock(return_value=AsyncCursor([])) + database.__getitem__.return_value = collection + client.__getitem__.return_value = database + return client, database, collection + + +@pytest.fixture +def collection(definition_factory, pymongo_objects): + return AzureDocumentDBCollection( + dict, + definition=definition_factory(), + collection=pymongo_objects[2], + ) diff --git a/python/packages/azure-documentdb/tests/azure_documentdb/test_integration.py b/python/packages/azure-documentdb/tests/azure_documentdb/test_integration.py new file mode 100644 index 00000000000..02cae90e886 --- /dev/null +++ b/python/packages/azure-documentdb/tests/azure_documentdb/test_integration.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +from uuid import uuid4 + +import pytest +from agent_framework import Filter, VectorStoreCollectionDefinition, VectorStoreField + +from agent_framework_azure_documentdb import AzureDocumentDBCollection + +pytestmark = [pytest.mark.flaky, pytest.mark.integration] + +_CONNECTION_STRING = os.getenv("AZURE_DOCUMENTDB_TEST_CONNECTION_STRING") +_DATABASE_NAME = os.getenv("AZURE_DOCUMENTDB_TEST_DATABASE_NAME") +_WRITES_AUTHORIZED = os.getenv("AZURE_DOCUMENTDB_TEST_ALLOW_WRITES") == "true" +skip_if_managed_tests_disabled = pytest.mark.skipif( + not _CONNECTION_STRING or not _DATABASE_NAME or not _WRITES_AUTHORIZED, + reason=( + "Set AZURE_DOCUMENTDB_TEST_CONNECTION_STRING, AZURE_DOCUMENTDB_TEST_DATABASE_NAME, and " + "AZURE_DOCUMENTDB_TEST_ALLOW_WRITES=true for an authorized disposable managed test database." + ), +) + + +def _vector(seed: int, *, dimensions: int = 1536) -> list[float]: + return [((seed * 17 + index * 31) % 997) / 997 for index in range(dimensions)] + + +@skip_if_managed_tests_disabled +async def test_managed_documentdb_two_vector_fields_with_1000_records() -> None: + assert _DATABASE_NAME is not None and "test" in _DATABASE_NAME.lower() + collection_name = os.getenv("AZURE_DOCUMENTDB_TEST_COLLECTION_NAME") or f"af_vector_test_{uuid4().hex}" + definition = VectorStoreCollectionDefinition( + [ + VectorStoreField("key", name="id", type_="str"), + VectorStoreField("data", name="category", type_="str", is_indexed=True), + VectorStoreField( + "vector", + name="content_vector", + type_="float", + dimensions=1536, + index_kind="ivf_flat", + provider_annotations={"azure_documentdb.num_lists": 1}, + ), + VectorStoreField( + "vector", + name="title_vector", + type_="float", + dimensions=1536, + index_kind="ivf_flat", + provider_annotations={"azure_documentdb.num_lists": 1}, + ), + ], + collection_name=collection_name, + ) + connector = AzureDocumentDBCollection( + dict, + definition=definition, + connection_string=_CONNECTION_STRING, + database_name=_DATABASE_NAME, + ) + await connector.ensure_collection_exists() + try: + records = [ + { + "id": f"record-{index:04d}", + "category": "even" if index % 2 == 0 else "odd", + "content_vector": _vector(index), + "title_vector": _vector(1000 - index), + } + for index in range(1000) + ] + keys = await connector.upsert(records, generate_vectors=False) + assert len(keys) == 1000 + + content_results = await connector.search( + vector=_vector(0), + vector_property_name="content_vector", + filter=Filter("category", "eq", "even"), + top=5, + ) + content_page = [result async for result in content_results] + assert content_page and content_page[0]["record"]["id"] == "record-0000" + + negative_filter_results = await connector.search( + vector=_vector(0), + vector_property_name="content_vector", + filter=Filter("category", "ne", "odd"), + top=5, + ) + negative_filter_page = [result async for result in negative_filter_results] + assert negative_filter_page and all(result["record"]["category"] == "even" for result in negative_filter_page) + + title_results = await connector.search( + vector=_vector(1), + vector_property_name="title_vector", + top=5, + include_vectors=True, + ) + title_page = [result async for result in title_results] + assert title_page and len(title_page[0]["record"]["title_vector"]) == 1536 + finally: + await connector.ensure_collection_deleted() + await connector.aclose() diff --git a/python/packages/azure-documentdb/tests/azure_documentdb/test_settings.py b/python/packages/azure-documentdb/tests/azure_documentdb/test_settings.py new file mode 100644 index 00000000000..56323e42cbf --- /dev/null +++ b/python/packages/azure-documentdb/tests/azure_documentdb/test_settings.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from typing import get_type_hints +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework import SecretString, load_settings +from pymongo import AsyncMongoClient +from pymongo.asynchronous.database import AsyncDatabase + +from agent_framework_azure_documentdb import ( + AzureDocumentDBCollection, + AzureDocumentDBSettings, + AzureDocumentDBStore, +) +from agent_framework_azure_documentdb import _vector_store as module + + +def test_public_settings_use_af_secret_string(monkeypatch): + monkeypatch.setenv("AZURE_DOCUMENTDB_CONNECTION_STRING", "mongodb://user:secret@example") + monkeypatch.setenv("AZURE_DOCUMENTDB_DATABASE_NAME", "environment_db") + assert get_type_hints(AzureDocumentDBSettings) == { + "connection_string": SecretString | None, + "database_name": str | None, + } + settings = load_settings(AzureDocumentDBSettings, env_prefix="AZURE_DOCUMENTDB_") + secret = settings["connection_string"] + assert isinstance(secret, SecretString) + assert secret.get_secret_value() == "mongodb://user:secret@example" + assert "secret" not in str(secret) + assert settings["database_name"] == "environment_db" + + +def test_explicit_settings_precede_file_and_environment(monkeypatch, tmp_path): + monkeypatch.setenv("AZURE_DOCUMENTDB_CONNECTION_STRING", "mongodb://environment") + monkeypatch.setenv("AZURE_DOCUMENTDB_DATABASE_NAME", "environment_db") + env_file = tmp_path / "selected.env" + env_file.write_text( + "AZURE_DOCUMENTDB_CONNECTION_STRING=mongodb://file\nAZURE_DOCUMENTDB_DATABASE_NAME=file_db\n", + encoding="utf-8", + ) + created = MagicMock(spec=AsyncMongoClient) + created.close = AsyncMock() + with patch.object(module, "AsyncMongoClient", return_value=created) as client_type: + store = AzureDocumentDBStore( + connection_string=SecretString("mongodb://explicit"), + database_name="explicit_db", + env_file_path=str(env_file), + ) + assert store._client.client is created + assert client_type.call_args.args == ("mongodb://explicit",) + assert client_type.call_args.kwargs == { + "appname": "agent-framework-azure-documentdb", + "retryWrites": False, + "tls": True, + } + created.__getitem__.assert_called_once_with("explicit_db") + + +def test_selected_file_precedes_environment_and_uses_encoding(monkeypatch, tmp_path): + monkeypatch.setenv("AZURE_DOCUMENTDB_CONNECTION_STRING", "mongodb://environment") + monkeypatch.setenv("AZURE_DOCUMENTDB_DATABASE_NAME", "environment_db") + env_file = tmp_path / "selected.env" + env_file.write_text( + "AZURE_DOCUMENTDB_CONNECTION_STRING=mongodb://file\nAZURE_DOCUMENTDB_DATABASE_NAME=file_db\n", + encoding="utf-16", + ) + with patch.object(module, "AsyncMongoClient") as client_type: + AzureDocumentDBStore(env_file_path=str(env_file), env_file_encoding="utf-16") + assert client_type.call_args.args == ("mongodb://file",) + client_type.return_value.__getitem__.assert_called_once_with("file_db") + + +def test_missing_or_empty_settings_rejected_before_client(monkeypatch): + monkeypatch.delenv("AZURE_DOCUMENTDB_CONNECTION_STRING", raising=False) + monkeypatch.delenv("AZURE_DOCUMENTDB_DATABASE_NAME", raising=False) + with patch.object(module, "AsyncMongoClient") as client_type: + with pytest.raises(ValueError, match="connection_string"): + AzureDocumentDBStore() + with pytest.raises(ValueError, match="connection_string"): + AzureDocumentDBStore(connection_string="", database_name="db") + client_type.assert_not_called() + + +@pytest.mark.parametrize( + "options", + [ + {"tls": False}, + {"retryWrites": True}, + {"appname": ""}, + {"appname": "x\0y"}, + {"password": "must-not-be-forwarded"}, + ], +) +def test_client_options_rejected(options): + with patch.object(module, "AsyncMongoClient") as client_type, pytest.raises((ValueError, NotImplementedError)): + AzureDocumentDBStore( + connection_string="mongodb://explicit", + database_name="db", + client_options=options, + ) + client_type.assert_not_called() + + +async def test_injected_client_bypasses_settings_and_remains_caller_owned(monkeypatch): + monkeypatch.setenv("AZURE_DOCUMENTDB_CONNECTION_STRING", "must-not-be-read") + client = MagicMock(spec=AsyncMongoClient) + client.close = AsyncMock() + database = MagicMock(spec=AsyncDatabase) + client.__getitem__.return_value = database + with patch.object(module, "load_settings", side_effect=AssertionError("settings must be bypassed")): + store = AzureDocumentDBStore(client=client, database_name="borrowed") + await store.aclose() + assert store._client.database is database + assert not store.managed_client + client.close.assert_not_awaited() + + +async def test_injected_database_and_collection_bypass_settings(definition_factory, pymongo_objects): + client, database, collection = pymongo_objects + with patch.object(module, "load_settings", side_effect=AssertionError("settings must be bypassed")): + store = AzureDocumentDBStore(database=database) + direct = AzureDocumentDBCollection(dict, definition=definition_factory(), collection=collection) + await store.aclose() + await direct.aclose() + client.close.assert_not_awaited() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"connection_string": "mongodb://explicit"}, + {"client_options": {"tls": True}}, + {"env_file_path": "selected.env"}, + {"env_file_encoding": "utf-16"}, + ], +) +def test_injected_client_rejects_connection_settings(kwargs, pymongo_objects): + with ( + patch.object(module, "load_settings", side_effect=AssertionError("settings must be bypassed")), + pytest.raises(ValueError, match="cannot be combined"), + ): + AzureDocumentDBStore(client=pymongo_objects[0], database_name="db", **kwargs) + + +async def test_store_children_reuse_resolved_client(definition_factory, pymongo_objects): + store = AzureDocumentDBStore(database=pymongo_objects[1]) + with patch.object(module, "load_settings", side_effect=AssertionError("children must reuse the database")): + child = store.get_collection(dict, definition=definition_factory()) + assert child._client is store._client + await child.aclose() + pymongo_objects[0].close.assert_not_awaited() + + +async def test_created_client_closes_once(monkeypatch): + monkeypatch.setenv("AZURE_DOCUMENTDB_CONNECTION_STRING", "mongodb://environment") + monkeypatch.setenv("AZURE_DOCUMENTDB_DATABASE_NAME", "environment_db") + created = MagicMock(spec=AsyncMongoClient) + created.close = AsyncMock() + with patch.object(module, "AsyncMongoClient", return_value=created): + store = AzureDocumentDBStore() + await store.aclose() + await store.aclose() + created.close.assert_awaited_once() diff --git a/python/packages/azure-documentdb/tests/azure_documentdb/test_vector_store.py b/python/packages/azure-documentdb/tests/azure_documentdb/test_vector_store.py new file mode 100644 index 00000000000..9d30ac8a0f1 --- /dev/null +++ b/python/packages/azure-documentdb/tests/azure_documentdb/test_vector_store.py @@ -0,0 +1,675 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import math +from collections.abc import AsyncIterator, Sequence +from dataclasses import replace +from typing import Any, cast +from unittest.mock import AsyncMock, patch + +import pytest +from agent_framework import Filter, FilterGroup, VectorStoreCollectionDefinition +from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException +from bson import ObjectId +from pymongo.errors import OperationFailure, PyMongoError + +from agent_framework_azure_documentdb import AzureDocumentDBCollection, AzureDocumentDBStore +from agent_framework_azure_documentdb._vector_store import ( + _data_index_spec, + _FilterCompiler, + _matching_index, + _vector_index_spec, +) + + +class AsyncCursor: + def __init__(self, values: Sequence[dict[str, Any]]) -> None: + self.values = list(values) + self.skip_value = 0 + self.limit_value: int | None = None + + def skip(self, value: int) -> AsyncCursor: + self.skip_value = value + return self + + def limit(self, value: int) -> AsyncCursor: + self.limit_value = value + return self + + def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + async def iterate() -> AsyncIterator[dict[str, Any]]: + end = None if self.limit_value is None else self.skip_value + self.limit_value + for value in self.values[self.skip_value : end]: + yield value + + return iterate() + + +def test_model_supports_multiple_vectors_and_aliases(definition_factory, pymongo_objects): + connector = AzureDocumentDBCollection( + dict, + definition=definition_factory(second_vector=True), + collection=pymongo_objects[2], + ) + assert [field.storage_name for field in connector._fields[-2:]] == ["contentVector", "titleVector"] + + +def test_reserved_score_alias_rejected(definition_factory, pymongo_objects): + definition = definition_factory() + fields = [ + replace(field, storage_name="_af_documentdb_score" if field.name == "text" else field.storage_name) + for field in definition.fields + ] + with pytest.raises(ValueError, match="reserved"): + AzureDocumentDBCollection( + dict, + definition=VectorStoreCollectionDefinition(fields, collection_name="documents"), + collection=pymongo_objects[2], + ) + + +@pytest.mark.parametrize( + "options", + [ + {"generated": True}, + {"key_type": "UUID"}, + {"dimensions": 0}, + {"dimensions": 2001}, + {"dimensions": True}, + {"index_kind": "flat"}, + {"distance_function": "cosine_distance"}, + {"distance_function": "hamming"}, + {"annotations": {"azure_documentdb.unknown": 1}}, + {"index_kind": "ivf_flat", "annotations": {"azure_documentdb.num_lists": 0}}, + {"index_kind": "hnsw", "annotations": {"azure_documentdb.m": 101}}, + { + "index_kind": "hnsw", + "annotations": {"azure_documentdb.m": 40, "azure_documentdb.ef_construction": 64}, + }, + {"index_kind": "disk_ann", "annotations": {"azure_documentdb.max_degree": 19}}, + {"index_kind": "disk_ann", "annotations": {"azure_documentdb.l_build": 501}}, + ], +) +def test_model_capabilities_rejected_before_io(definition_factory, pymongo_objects, options): + with pytest.raises((ValueError, NotImplementedError)): + AzureDocumentDBCollection( + dict, + definition=definition_factory(**options), + collection=pymongo_objects[2], + ) + + +def test_key_and_data_storage_names_are_mapped(definition_factory, pymongo_objects, record_factory): + connector = AzureDocumentDBCollection( + dict, + definition=definition_factory(), + collection=pymongo_objects[2], + ) + stored = connector._serialize_dicts_to_store_models([connector._serialize_record_to_dict(record_factory())])[0] + assert stored["_id"] == "one" + assert "record_id" not in stored + assert stored["body"] == "Azure DocumentDB" + restored = connector._deserialize_store_models_to_dicts([stored])[0] + assert restored["record_id"] == "one" + + +@pytest.mark.parametrize("key", [True, 1.5, object(), 2**63, -(2**63) - 1, "x" * 2049]) +async def test_keys_are_strict_before_io(collection, pymongo_objects, record_factory, key): + with pytest.raises((TypeError, ValueError, NotImplementedError)): + await collection.upsert([record_factory(cast(Any, key))], generate_vectors=False) + with pytest.raises((TypeError, ValueError)): + await collection.get([cast(Any, key)]) + pymongo_objects[2].bulk_write.assert_not_awaited() + pymongo_objects[2].find.assert_not_called() + + +def test_object_id_response_is_not_implicitly_encoded(collection): + with pytest.raises(TypeError, match="strings or signed"): + collection._deserialize_store_models_to_dicts([{"_id": ObjectId()}]) + + +@pytest.mark.parametrize( + "field,value", + [ + ("number", True), + ("number", 2**63), + ("ratio", float("nan")), + ("flag", 1), + ("tags", ("tuple",)), + ("text", b"bytes"), + ], +) +async def test_record_values_are_type_checked_before_io( + collection, + pymongo_objects, + record_factory, + field, + value, +): + with pytest.raises((TypeError, ValueError)): + await collection.upsert([record_factory(**{field: value})], generate_vectors=False) + pymongo_objects[2].bulk_write.assert_not_awaited() + + +@pytest.mark.parametrize( + "vector", + [ + "not-a-vector", + b"bytes", + [1, True, 0], + [1, "0", 0], + [1, float("nan"), 0], + [1, 2**63, 0], + ], +) +async def test_vectors_are_validated_before_io(collection, pymongo_objects, record_factory, vector): + with pytest.raises((TypeError, ValueError)): + await collection.upsert([record_factory(embedding=vector)], generate_vectors=False) + pymongo_objects[2].bulk_write.assert_not_awaited() + + +async def test_late_invalid_batch_performs_no_io(collection, pymongo_objects, record_factory): + with pytest.raises(TypeError): + await collection.upsert( + [record_factory("valid"), record_factory("invalid", embedding=[1, "invalid", 0])], + generate_vectors=False, + ) + pymongo_objects[2].bulk_write.assert_not_awaited() + + +async def test_document_size_preflight_performs_no_io(collection, pymongo_objects, record_factory): + with ( + patch("agent_framework_azure_documentdb._vector_store._MAX_DOCUMENT_BYTES", 100), + pytest.raises(ValueError, match="encoded BSON"), + ): + await collection.upsert([record_factory(text="x" * 1_000)], generate_vectors=False) + pymongo_objects[2].bulk_write.assert_not_awaited() + + +async def test_upsert_chunks_and_reports_partial_writes(collection, pymongo_objects, record_factory): + pymongo_objects[2].bulk_write.side_effect = [None, PyMongoError("failed")] + with ( + patch("agent_framework_azure_documentdb._vector_store._MAX_WRITES_PER_BATCH", 2), + pytest.raises(IntegrationException, match="2 records in earlier batches"), + ): + await collection.upsert( + [record_factory(str(index)) for index in range(3)], + generate_vectors=False, + ) + assert pymongo_objects[2].bulk_write.await_count == 2 + + +async def test_upsert_preserves_order_and_duplicate_keys(collection, pymongo_objects, record_factory): + keys = await collection.upsert( + [record_factory("same"), record_factory("same")], + generate_vectors=False, + ) + assert keys == ["same", "same"] + requests = pymongo_objects[2].bulk_write.call_args.args[0] + assert len(requests) == 2 + + +@pytest.mark.parametrize( + "expression,expected", + [ + (Filter("category", "eq", "database"), {"category": {"$eq": "database"}}), + ( + Filter("category", "ne", "database"), + {"$and": [{"category": {"$exists": True}}, {"category": {"$ne": "database"}}]}, + ), + ( + Filter("category", "is_null"), + {"$and": [{"category": {"$exists": True}}, {"category": {"$eq": None}}]}, + ), + ( + Filter("category", "is_not_null"), + {"$and": [{"category": {"$exists": True}}, {"category": {"$ne": None}}]}, + ), + (Filter("category", "exists"), {"category": {"$exists": True}}), + (Filter("number", "between", [1, 3]), {"number": {"$gte": 1, "$lte": 3}}), + ( + Filter("number", "not_in", [1, 2]), + { + "$and": [ + {"number": {"$exists": True}}, + {"number": {"$ne": None}}, + {"number": {"$nin": [1, 2]}}, + ] + }, + ), + ( + Filter("tags", "contains", "azure"), + { + "$and": [ + {"tags": {"$exists": True}}, + {"tags": {"$ne": None}}, + {"tags": {"$in": ["azure"]}}, + ] + }, + ), + ( + Filter("tags", "contains_all", ["azure", 1]), + { + "$and": [ + {"tags": {"$exists": True}}, + {"tags": {"$ne": None}}, + {"tags": {"$all": ["azure", 1]}}, + ] + }, + ), + ], +) +def test_filter_compilation_uses_mongo_wire_operators(collection, expression, expected): + assert collection._prepare_filter(expression) == expected + assert "$neq" not in repr(expected) + + +def test_filter_groups_and_values_remain_bson_documents(collection): + payload = "'; drop collection documents; --" + expression = FilterGroup( + "and", + [ + Filter("category", "eq", payload), + FilterGroup("not", [Filter("flag", "eq", False)]), + ], + ) + assert collection._prepare_filter(expression) == { + "$and": [ + {"category": {"$eq": payload}}, + {"$nor": [{"flag": {"$eq": False}}]}, + ] + } + + +def test_filter_bson_size_is_checked_before_io(collection): + with ( + patch("agent_framework_azure_documentdb._vector_store._MAX_DOCUMENT_BYTES", 100), + pytest.raises(ValueError, match="filters cannot exceed"), + ): + collection._prepare_filter(Filter("category", "eq", "x" * 1_000)) + + +@pytest.mark.parametrize( + "expression,error", + [ + (Filter("text", "eq", "not-indexed"), NotImplementedError), + (Filter("embedding", "is_null"), NotImplementedError), + (Filter("category.nested", "eq", "x"), NotImplementedError), + (Filter("category", "contains_text", "x"), NotImplementedError), + (Filter("category", "starts_with", "x"), NotImplementedError), + (Filter("category", "azure_documentdb.regex", "^x"), NotImplementedError), + (Filter("tags", "contains", ["nested"]), NotImplementedError), + (Filter("tags", "in", [["azure"]]), NotImplementedError), + (Filter("flag", "gt", True), NotImplementedError), + ], +) +def test_unsupported_filters_fail_before_io(collection, expression, error): + with pytest.raises(error): + collection._prepare_filter(expression) + + +def test_empty_membership_semantics(collection): + assert collection._prepare_filter(Filter("number", "in", [])) == {"_id": {"$exists": False}} + assert collection._prepare_filter(Filter("number", "not_in", [])) == { + "$and": [{"number": {"$exists": True}}, {"number": {"$ne": None}}] + } + assert collection._prepare_filter(Filter("tags", "contains_any", [])) == {"_id": {"$exists": False}} + assert collection._prepare_filter(Filter("tags", "contains_all", [])) == { + "$and": [{"tags": {"$exists": True}}, {"tags": {"$ne": None}}] + } + + +def test_filter_type_mismatches_preserve_bool_number_distinction(collection): + assert collection._prepare_filter(Filter("number", "eq", True)) == {"_id": {"$exists": False}} + assert collection._prepare_filter(Filter("flag", "eq", 1)) == {"_id": {"$exists": False}} + assert collection._prepare_filter(Filter("number", "ne", True)) == {"number": {"$exists": True}} + + +async def test_filtered_get_uses_prepared_filter_and_server_paging(collection, pymongo_objects): + cursor = AsyncCursor([ + {"_id": "zero", "body": "0", "category": "database", "number": 0, "ratio": 0.0, "flag": True, "tags": []}, + {"_id": "one", "body": "1", "category": "database", "number": 1, "ratio": 1.0, "flag": True, "tags": []}, + {"_id": "two", "body": "2", "category": "database", "number": 2, "ratio": 2.0, "flag": True, "tags": []}, + ]) + pymongo_objects[2].find.return_value = cursor + records = await collection.get(filter=Filter("category", "eq", "database"), skip=1, top=1) + assert [record["id"] for record in records] == ["one"] + assert pymongo_objects[2].find.call_args.args[0] == {"category": {"$eq": "database"}} + assert cursor.skip_value == 1 and cursor.limit_value == 1 + + +async def test_key_get_preserves_input_order_and_omits_missing(collection, pymongo_objects): + pymongo_objects[2].find.return_value = AsyncCursor([ + {"_id": "two", "body": "2", "category": "db", "number": 2, "ratio": 2.0, "flag": True, "tags": []}, + {"_id": "one", "body": "1", "category": "db", "number": 1, "ratio": 1.0, "flag": True, "tags": []}, + ]) + records = await collection.get(["one", "missing", "two", "one"]) + assert [record["id"] for record in records] == ["one", "two", "one"] + + +async def test_empty_key_batches_perform_no_io(collection, pymongo_objects): + assert await collection.get([]) == [] + await collection.delete([]) + pymongo_objects[2].find.assert_not_called() + pymongo_objects[2].delete_many.assert_not_awaited() + + +async def test_delete_preflights_all_keys_before_io(collection, pymongo_objects): + with pytest.raises(IntegrationException): + await collection.delete(["valid", cast(Any, True)]) + pymongo_objects[2].delete_many.assert_not_awaited() + + +async def test_delete_reports_partial_batches(collection, pymongo_objects): + pymongo_objects[2].delete_many.side_effect = [None, PyMongoError("failed")] + with ( + patch("agent_framework_azure_documentdb._vector_store._KEY_BATCH_SIZE", 2), + pytest.raises(IntegrationException, match="2 keys in earlier batches"), + ): + await collection.delete(["one", "two", "three"]) + + +async def test_search_pipeline_threshold_before_paging_and_vector_projection( + collection, + pymongo_objects, +): + pymongo_objects[2].aggregate.return_value = AsyncCursor([ + { + "_id": "one", + "body": "Azure", + "category": "database", + "number": 1, + "ratio": 1.0, + "flag": True, + "tags": [], + "_af_documentdb_score": 0.9, + } + ]) + results = await collection.search( + vector=[1, 0, 0], + filter=Filter("category", "eq", "database"), + score_threshold=0.8, + top=2, + skip=1, + operation_options={"k": 10, "n_probes": 1, "max_time_ms": 5000}, + ) + pipeline = pymongo_objects[2].aggregate.call_args.args[0] + assert pipeline[0] == { + "$search": { + "cosmosSearch": { + "path": "contentVector", + "vector": [1, 0, 0], + "k": 10, + "nProbes": 1, + "filter": {"category": {"$eq": "database"}}, + } + } + } + assert pipeline[1]["$project"]["_af_documentdb_score"] == {"$meta": "searchScore"} + assert "contentVector" not in pipeline[1]["$project"] + assert pipeline[2] == {"$match": {"_af_documentdb_score": {"$gte": 0.8}}} + assert pipeline[3:] == [{"$skip": 1}, {"$limit": 2}] + assert pymongo_objects[2].aggregate.call_args.kwargs == {"maxTimeMS": 5000} + assert results.metadata is not None and results.metadata["candidate_window"] == 10 + assert results.metadata["metric"] == "COS" + assert results.metadata["score_threshold_direction"] == "minimum" + responses = [response async for response in results] + assert len(responses) == 1 and responses[0]["score"] == 0.9 + assert responses[0]["record"]["id"] == "one" + + +async def test_euclidean_threshold_is_maximum_before_paging(definition_factory, pymongo_objects): + connector = AzureDocumentDBCollection( + dict, + definition=definition_factory(distance_function="euclidean_distance"), + collection=pymongo_objects[2], + ) + results = await connector.search( + vector=[1, 0, 0], + score_threshold=0.5, + top=2, + skip=1, + ) + pipeline = pymongo_objects[2].aggregate.call_args.args[0] + assert pipeline[2] == {"$match": {"_af_documentdb_score": {"$lte": 0.5}}} + assert pipeline[3:] == [{"$skip": 1}, {"$limit": 2}] + assert results.metadata is not None + assert results.metadata["metric"] == "L2" + assert results.metadata["score_threshold_direction"] == "maximum" + + +async def test_empty_search_results_are_supported(collection, pymongo_objects): + results = await collection.search(vector=[1, 0, 0]) + assert [result async for result in results] == [] + + +@pytest.mark.parametrize( + "definition_options,operation_options", + [ + ({"index_kind": "ivf_flat"}, {"ef_search": 10}), + ({"index_kind": "ivf_flat"}, {"n_probes": 2}), + ({"index_kind": "hnsw"}, {"n_probes": 1}), + ({"index_kind": "disk_ann"}, {"ef_search": 40}), + ({"index_kind": "disk_ann"}, {"k": 1001}), + ({"index_kind": "disk_ann"}, {"k": 50, "l_search": 40}), + ({"index_kind": "disk_ann"}, {"l_search": 9}), + ], +) +async def test_search_option_bounds_fail_before_io( + definition_factory, + pymongo_objects, + definition_options, + operation_options, +): + connector = AzureDocumentDBCollection( + dict, + definition=definition_factory(**definition_options), + collection=pymongo_objects[2], + ) + with pytest.raises(ValueError): + await connector.search(vector=[1, 0, 0], operation_options=operation_options) + pymongo_objects[2].aggregate.assert_not_awaited() + + +async def test_hnsw_and_diskann_search_options_use_service_names(definition_factory, pymongo_objects): + hnsw = AzureDocumentDBCollection( + dict, + definition=definition_factory(index_kind="hnsw"), + collection=pymongo_objects[2], + ) + await hnsw.search(vector=[1, 0, 0], operation_options={"ef_search": 75}) + assert pymongo_objects[2].aggregate.call_args.args[0][0]["$search"]["cosmosSearch"]["efSearch"] == 75 + + diskann = AzureDocumentDBCollection( + dict, + definition=definition_factory(index_kind="disk_ann"), + collection=pymongo_objects[2], + ) + await diskann.search(vector=[1, 0, 0], operation_options={"l_search": 80}) + assert pymongo_objects[2].aggregate.call_args.args[0][0]["$search"]["cosmosSearch"]["lSearch"] == 80 + + +def test_index_specs_use_documentdb_commands(definition_factory): + definition = definition_factory( + index_kind="hnsw", + distance_function="dot_prod", + annotations={"azure_documentdb.m": 12, "azure_documentdb.ef_construction": 48}, + ) + vector_spec = _vector_index_spec("documents", definition.vector_fields[0]) + assert vector_spec["key"] == {"contentVector": "cosmosSearch"} + assert vector_spec["cosmosSearchOptions"] == { + "kind": "vector-hnsw", + "dimensions": 3, + "similarity": "IP", + "m": 12, + "efConstruction": 48, + } + assert _data_index_spec("documents", definition.data_fields[1])["key"] == {"category": 1} + + +def test_index_compatibility_canonicalizes_documented_defaults(definition_factory): + expected = _vector_index_spec( + "documents", + definition_factory(index_kind="hnsw").vector_fields[0], + ) + existing: list[dict[str, Any]] = [ + { + "name": "administrator_name", + "key": {"contentVector": "cosmosSearch"}, + "cosmosSearch": { + "kind": "vector-hnsw", + "dimensions": 3, + "similarity": "cos", + }, + } + ] + assert _matching_index(existing, expected) + cast(dict[str, Any], existing[0]["cosmosSearch"])["m"] = 32 + with pytest.raises(ValueError, match="incompatible"): + _matching_index(existing, expected) + + +def test_filter_index_compatibility_rejects_semantic_options(definition_factory): + expected = _data_index_spec("documents", definition_factory().data_fields[1]) + assert _matching_index([{"name": "existing", "key": {"category": 1}}], expected) + for option in ( + {"unique": True}, + {"sparse": True}, + {"partialFilterExpression": {"category": {"$exists": True}}}, + {"expireAfterSeconds": 60}, + {"collation": {"locale": "en"}}, + ): + with pytest.raises(ValueError, match="incompatible options"): + _matching_index([{"name": "existing", "key": {"category": 1}, **option}], expected) + + +async def test_ensure_index_command_and_narrow_race_revalidation( + collection, + pymongo_objects, + definition_factory, +): + expected = _vector_index_spec("documents", definition_factory().vector_fields[0]) + existing = { + "name": expected["name"], + "key": expected["key"], + "cosmosSearch": expected["cosmosSearchOptions"], + } + pymongo_objects[2].list_indexes.side_effect = [ + AsyncCursor([{"name": "_id_", "key": {"_id": 1}}]), + AsyncCursor([{"name": "_id_", "key": {"_id": 1}}, existing]), + ] + pymongo_objects[1].command.side_effect = OperationFailure( + "index raced", + code=85, + details={"codeName": "IndexOptionsConflict"}, + ) + await collection._ensure_index(expected, max_time_ms=5000) + assert pymongo_objects[1].command.call_args.args[0] == { + "createIndexes": "documents", + "indexes": [expected], + "maxTimeMS": 5000, + } + + +async def test_unrelated_index_failure_is_not_suppressed( + collection, + pymongo_objects, + definition_factory, +): + expected = _vector_index_spec("documents", definition_factory().vector_fields[0]) + pymongo_objects[1].command.side_effect = OperationFailure("forbidden", code=13) + with pytest.raises(OperationFailure): + await collection._ensure_index(expected, max_time_ms=None) + + +async def test_ensure_collection_reconciles_each_vector_and_filter_index( + definition_factory, + pymongo_objects, +): + connector = AzureDocumentDBCollection( + dict, + definition=definition_factory(second_vector=True), + collection=pymongo_objects[2], + ) + with patch.object(connector, "_ensure_index", new_callable=AsyncMock) as ensure: + await connector.ensure_collection_exists() + specs = [call.args[0] for call in ensure.call_args_list] + assert [spec["key"] for spec in specs if "cosmosSearchOptions" in spec] == [ + {"contentVector": "cosmosSearch"}, + {"titleVector": "cosmosSearch"}, + ] + assert {"category": 1} in [spec["key"] for spec in specs] + + +async def test_incompatible_existing_index_is_not_replaced( + collection, + pymongo_objects, + definition_factory, +): + expected = _vector_index_spec("documents", definition_factory().vector_fields[0]) + pymongo_objects[2].list_indexes.return_value = AsyncCursor([ + { + "name": "existing", + "key": expected["key"], + "cosmosSearch": { + **expected["cosmosSearchOptions"], + "dimensions": 4, + }, + } + ]) + with pytest.raises(ValueError, match="incompatible"): + await collection._ensure_index(expected, max_time_ms=None) + pymongo_objects[1].command.assert_not_awaited() + + +async def test_store_lifecycle_is_database_scoped(definition_factory, pymongo_objects): + store = AzureDocumentDBStore(database=pymongo_objects[1]) + assert await store.list_collection_names() == ["documents"] + assert await store.collection_exists("documents") + await store.ensure_collection_deleted("documents") + pymongo_objects[1].drop_collection.assert_awaited_once_with("documents") + + +async def test_invalid_search_result_is_reported_during_iteration(collection, pymongo_objects): + pymongo_objects[2].aggregate.return_value = AsyncCursor([ + {"_id": "one", "body": "text", "category": "db", "number": 1, "ratio": 1.0, "flag": True, "tags": []} + ]) + results = await collection.search(vector=[1, 0, 0]) + with pytest.raises(IntegrationInvalidResponseException, match="searchScore"): + _ = [result async for result in results] + + +def test_filter_compiler_calls_are_isolated(definition_factory): + definition = definition_factory() + compiler = _FilterCompiler(definition.fields, definition) + first = compiler.compile(Filter("number", "eq", 1)) + second = compiler.compile(Filter("number", "eq", 2)) + assert first == {"number": {"$eq": 1}} + assert second == {"number": {"$eq": 2}} + + +@pytest.mark.parametrize("score", [True, float("nan"), float("inf"), "0.5"]) +async def test_invalid_native_scores_rejected(collection, pymongo_objects, score): + pymongo_objects[2].aggregate.return_value = AsyncCursor([ + { + "_id": "one", + "body": "text", + "category": "db", + "number": 1, + "ratio": 1.0, + "flag": True, + "tags": [], + "_af_documentdb_score": score, + } + ]) + results = await collection.search(vector=[1, 0, 0]) + with pytest.raises(IntegrationInvalidResponseException): + _ = [result async for result in results] + + +def test_score_threshold_requires_finite_number(collection): + for threshold in (True, float("nan"), float("inf"), "0.5"): + with pytest.raises(IntegrationInvalidResponseException): + collection._get_score_from_result({"_af_documentdb_score": threshold}) + assert math.isclose(collection._get_score_from_result({"_af_documentdb_score": 0.5}), 0.5) diff --git a/python/pyproject.toml b/python/pyproject.toml index ef943c47930..b7d34ef6105 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -78,6 +78,7 @@ agent-framework-core = { workspace = true } agent-framework-a2a = { workspace = true } agent-framework-ag-ui = { workspace = true } agent-framework-azure-ai-search = { workspace = true } +agent-framework-azure-documentdb = { workspace = true } agent-framework-azure-cosmos = { workspace = true } agent-framework-anthropic = { workspace = true } agent-framework-bedrock = { workspace = true } diff --git a/python/uv.lock b/python/uv.lock index fa7c3cc4e68..d51ac06d7a7 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -37,6 +37,7 @@ members = [ "agent-framework-azure-contentunderstanding", "agent-framework-azure-cosmos", "agent-framework-azure-cosmos-memory", + "agent-framework-azure-documentdb", "agent-framework-bedrock", "agent-framework-chatkit", "agent-framework-claude", @@ -340,6 +341,21 @@ dev = [ { name = "pytest-cov", specifier = ">=4.0.0" }, ] +[[package]] +name = "agent-framework-azure-documentdb" +version = "1.0.0a260909" +source = { editable = "packages/azure-documentdb" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pymongo", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "pymongo", specifier = ">=4.13,<5" }, +] + [[package]] name = "agent-framework-azurefunctions" version = "1.0.0b260730" @@ -2003,6 +2019,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docstring-parser" version = "0.18.0" @@ -4855,6 +4880,67 @@ crypto = [ { name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] +[[package]] +name = "pymongo" +version = "4.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/0a/d4daac76f66c466c20f00215064d43f4eb4b0aba8f2d8ecabdcc13102bc5/pymongo-4.18.0.tar.gz", hash = "sha256:6f33cd2033e8ea216d3069b5ebea79ded74ef2a93f69a93b26cf310082f9b5b9", size = 2741743, upload-time = "2026-09-03T16:01:06.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/a8/2dd2627a1bb8e9b7cc137464b8b494b3b857c6a4c668d544892feeb66b02/pymongo-4.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:232629980d2286644fcfdab89d6f16990a4b34f9c11f8e94184491895c84f652", size = 818176, upload-time = "2026-09-03T15:59:06.646Z" }, + { url = "https://files.pythonhosted.org/packages/56/f8/7da63e0961009dab27a74424c054dc802dee526de2660a41443323bdb320/pymongo-4.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d478d036b15a7b421bd663290fbdcf2ba6a6072943cdd6cb90274832c40534b", size = 818709, upload-time = "2026-09-03T15:59:08.412Z" }, + { url = "https://files.pythonhosted.org/packages/c8/1c/1af393729126c9999daa4703d9b52c699878a73a4e6c4636061069655fae/pymongo-4.18.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:065f8c5d5a8c677cfefc2dd59fe55b649827255375675f9061b459163735e123", size = 1022755, upload-time = "2026-09-03T15:59:10.274Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/9eac4d41ea1cfcad75db29e89942cd2eea21339647e71805979805e85d8b/pymongo-4.18.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a376caecb6cf32d0b27190a304e884e6db68b05d3bdf3ee3a7e780081745efbe", size = 1031177, upload-time = "2026-09-03T15:59:12.23Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/79749c0c936845539b0acede9ced62c2720d41d6a438a32e448907437620/pymongo-4.18.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5277ec3804d6c174953aab6e960f6590d7ed43ee932c4c29183a4123881762e5", size = 1053410, upload-time = "2026-09-03T15:59:13.997Z" }, + { url = "https://files.pythonhosted.org/packages/04/ca/814fe3f7bac4c84c6400dc34b7b73bcca8f5eff60e52ea9e0e7e9ad8b723/pymongo-4.18.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ee6af1a764a563bf82ac49b54b38cf7e34615ddb709c974aa7a9105a9b9d24e", size = 1046128, upload-time = "2026-09-03T15:59:15.898Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fa/c21e7fe8f480538c58f718b49bd1370e81f2ebaae91c6b4844378d17c24e/pymongo-4.18.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3ef22f72013934c1a688f42c3dc629b2c8215d03e313ec45f00e4f9a80ef2c8", size = 1032499, upload-time = "2026-09-03T15:59:17.96Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0b/b2883d8cc6ea4dbe089d613bb3f480d37c2c60d5b6fc5811bbf359709b3e/pymongo-4.18.0-cp311-cp311-win32.whl", hash = "sha256:fb0ad64ed30b8ad268ebcafebefe00750868c23d4d62b137da3316f26f3361b1", size = 813895, upload-time = "2026-09-03T15:59:19.601Z" }, + { url = "https://files.pythonhosted.org/packages/2e/93/588e44ab0db8a016d052f9e225e987c2709fe36111b975baf47d31ae1efd/pymongo-4.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac4029c949f8141ea5600e34e12cc400507d664e2746cda4fa4f2895959bebed", size = 819706, upload-time = "2026-09-03T15:59:21.401Z" }, + { url = "https://files.pythonhosted.org/packages/41/b4/03a75f77a129a3f8ec33c28d6abac23198a4c18a455ecc02a5bbf336c07e/pymongo-4.18.0-cp311-cp311-win_arm64.whl", hash = "sha256:d22465d4798d4fbfc6992de089d07ca33821fcaaf876a15942a9cc2e99441ad4", size = 815044, upload-time = "2026-09-03T15:59:23.137Z" }, + { url = "https://files.pythonhosted.org/packages/71/45/b0d2f2c463d4c74eec652bb4bfaa7107c0ccc0880ad06d3d384f6c393aab/pymongo-4.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:209aa073b8939bfbdae0e74d76c85db4ad8681aa4ec157d6db3aa5e0ada6ec8d", size = 818678, upload-time = "2026-09-03T15:59:24.918Z" }, + { url = "https://files.pythonhosted.org/packages/00/6a/c08d9c0131ea86fc13837d8b4c90264ace36c2c6823a65e2af07819c24e3/pymongo-4.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd37241ae704da14b71961da2b6a862a6100bfa83419782de3c3e2336a5d330e", size = 819107, upload-time = "2026-09-03T15:59:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/37/50/f5204dc860cf219b744e70f8c69b8ae7b08093acb200d56c3fd7b10b59b5/pymongo-4.18.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:47f443322528b03106c7bcc4784288ae35fe5d7bf4cf99a9d79cb4b582a3ecdc", size = 1039079, upload-time = "2026-09-03T15:59:28.477Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3f/7a4a9d3f8d0dfed987914dcd77bf87e2091889b1b1b5076b73deddf2ccf4/pymongo-4.18.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:751c2cc268e0a43e53ed6d41d3c36ca23f048b35714a9e1c83a3b3216bda37af", size = 1050403, upload-time = "2026-09-03T15:59:30.501Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/9916952952bebc37b0cc1b7544e494da33ebe779581dc7d5ceca020eac8e/pymongo-4.18.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b48d37852d45673a6ef38cc91fdee6ad59eef74d1f24691c6f19069a5de936ac", size = 1075134, upload-time = "2026-09-03T15:59:32.509Z" }, + { url = "https://files.pythonhosted.org/packages/ba/57/54b5f915b3daa31fe4a93f04700bd0f17570fabc90a7d7c107aa3da14248/pymongo-4.18.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e84103297ac0d1f3079ba6891b1f5f3f9459cbe1a61ad5465643c311021bec6a", size = 1068019, upload-time = "2026-09-03T15:59:34.859Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a5/9987c5b2f909f9b7796de7e3c5ea3f9d7ebe3e3454862904e80bd6c37654/pymongo-4.18.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21a0b97c0fcb9ce63895e37642e64dbb119b27053f7b5292ca9dfb597c1bca5f", size = 1049555, upload-time = "2026-09-03T15:59:36.573Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/ce8c73aaf26461232efe31659b448699f46f29dfa7fd2726e582bf9b6ec8/pymongo-4.18.0-cp312-cp312-win32.whl", hash = "sha256:7ab8e8f9d636a199a60c01427eff854b52d3bec5023e7ed516b3b0e26ee088f6", size = 814802, upload-time = "2026-09-03T15:59:38.308Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ae/ab46e8a875675b9b0ab76f04e30267f8d56988b776977c29584a2ae3f0be/pymongo-4.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:96bd084e388afa10eda7ea7e2e2c7b070589ff180ae7a61b7af9039747c2848f", size = 820395, upload-time = "2026-09-03T15:59:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a3/81e90bba3e5e011ba818854f84efc5480386349aa8039f85b5ed196f7264/pymongo-4.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:9a50d8ad7310b87e09d34ddf398c3d347d1ab28599b7f181d432a59ce89cb6ac", size = 815303, upload-time = "2026-09-03T15:59:41.997Z" }, + { url = "https://files.pythonhosted.org/packages/df/b1/6602487d94f6aef7677b18fe149bb0693fa209acf3b339a44ad8bc4060b2/pymongo-4.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:29c60dce70a300a09c0af39c2dbebd1a0199db9eb4d3ef03a4eeb587e7675668", size = 818600, upload-time = "2026-09-03T15:59:43.753Z" }, + { url = "https://files.pythonhosted.org/packages/ca/eb/c5beaf94967ef5c9aaf9db65fbe1d82337f7cf9c6b6df6c0e8d4c70f9e13/pymongo-4.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0d821ca5865e88c2dc29e259a191d770a53093c6749c6b228f18b078cb598faf", size = 818942, upload-time = "2026-09-03T15:59:45.515Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/3a19ece4d253e6be1d5e293701c2948330927a7e91d19372e9c4a04e2139/pymongo-4.18.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a61148df54b254b4157aaba2139cb8fc97b8232281d783e0b7b2d58cb000fcb", size = 1039154, upload-time = "2026-09-03T15:59:47.222Z" }, + { url = "https://files.pythonhosted.org/packages/50/95/944159a0c4bd68c40f3a96c617d3d1815768e23cd0cb0b730827c7a9115b/pymongo-4.18.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eda647930ecf0419fb3cecc448c40020e3871adbae523b302d15d2c49d22c866", size = 1050185, upload-time = "2026-09-03T15:59:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/3f/90/70faae774a27545f6e9d085877a1acc884e48b0e7afadf51d2f17eab15ae/pymongo-4.18.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04766d0930bc06dc99e3274a800fb81e85f2629f27ba7bc326e2d4d13ea449d5", size = 1074933, upload-time = "2026-09-03T15:59:51.246Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/fb0807668b8ffc0ffbe92786cb0ed6c10e70c32be5123fd49ff4ff2006e2/pymongo-4.18.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:368cd67dd1d3ead5836c20457b84d674256f27b3e99c332d085081680c8465b9", size = 1067686, upload-time = "2026-09-03T15:59:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dc/7c620995d4dff3eb1fdf62236844f3cb67d3042ed1a6d4f4059ef5dc79a6/pymongo-4.18.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b8e25f672f5b2b30c6b834e162454e721cf1f782e753f0e9ea3bca225900f3", size = 1049459, upload-time = "2026-09-03T15:59:54.958Z" }, + { url = "https://files.pythonhosted.org/packages/e7/25/a317ab554f604ce30fa778af82264627a7784c4a750c3d8459589f751cf4/pymongo-4.18.0-cp313-cp313-win32.whl", hash = "sha256:f15feb666fa56a43e4b318471eff7fba6facbfc3b2555185a0fef871f6b9ccf1", size = 814841, upload-time = "2026-09-03T15:59:56.657Z" }, + { url = "https://files.pythonhosted.org/packages/6e/58/a2a4b209a3066db88d82c9b44e5b75286aa5a00172dbca357af20bb8b1e4/pymongo-4.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:a2d96ad52eca16939564cbae9c91ffa92a9705ad46cb8d5de26b95dba0d40793", size = 820431, upload-time = "2026-09-03T15:59:58.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/8cd86494c9001f8e5331f51384b187962696bac3188fcbe05f7edf8dceed/pymongo-4.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:b230196ea62fc4542d9d6b78ddd9dbf0c9437ac2fde5810f7305377ae50fed11", size = 815328, upload-time = "2026-09-03T16:00:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/db/86/6c79e21ddad4a9165f5023c723220f23c68012949efaa89a02d13a684bff/pymongo-4.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4343c06f00fafdd8c8d73521ed8b4c468aa74e1b8f65cd757fb5f6ac3df685b3", size = 818495, upload-time = "2026-09-03T16:00:02.951Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/5b783fa8d6cb08d9590e56969accab39194e801f35a174c77d494dcefb94/pymongo-4.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:277e61864ad6a064d75d7efbf5ce0e57378c420e48af6b7bad63e8356dcb22e1", size = 819063, upload-time = "2026-09-03T16:00:04.866Z" }, + { url = "https://files.pythonhosted.org/packages/ad/19/f671bdb2533ef47ba49bd1cd2a65c16c2e5540abd9f48f0c8661a7243c56/pymongo-4.18.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b43545a785e4054db2f712ae7d2a640874500a2352e705dbe49c8a1b90de87f2", size = 1040923, upload-time = "2026-09-03T16:00:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/b0/36/3c4d10c334f76dcd135313ca21d3a902f13a3e46438b36bbea9413ab7fbf/pymongo-4.18.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34f3c9adcc26dbfdc50cbb27da5c369588e6862241ea5cf96d33139f57fd9e0b", size = 1050899, upload-time = "2026-09-03T16:00:08.976Z" }, + { url = "https://files.pythonhosted.org/packages/76/80/f7f686185a7e386dd1d9ad4bde0353a5150b7a1129957beed764bcf900fa/pymongo-4.18.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cd7577d4f28c882b42b356f86f7cf7566698beb347b75dcd742ffe5aac3c3462", size = 1074920, upload-time = "2026-09-03T16:00:11.099Z" }, + { url = "https://files.pythonhosted.org/packages/19/5e/072839ec02e1a11518120bcc81e59a60f2e3d3629e760f05e0a1c3b7344a/pymongo-4.18.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c50998113e17737fc2048e0184eda8c3aef465acc5a554fb76e2b0266805278", size = 1064362, upload-time = "2026-09-03T16:00:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/6e56a573977a60047fb8bf3646eb5eb121c689678fce4eb2f6ddd5cfbe2b/pymongo-4.18.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b88f2dfa33e1680e8990b45036c7cdb79c5c5e0b1f08b2e5c942893d76853eb6", size = 1049153, upload-time = "2026-09-03T16:00:15.047Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6b/bf0bc7eab0df3e7ef0669ceadbf25528f41fe304979335e49d79c4012515/pymongo-4.18.0-cp314-cp314-win32.whl", hash = "sha256:13b1ad2110fbc8ec151e996ae8ea22727db8b2bd48515141d76d4fb54bd07da3", size = 815986, upload-time = "2026-09-03T16:00:16.919Z" }, + { url = "https://files.pythonhosted.org/packages/14/9f/630977caf0b8022c8a6b2a55c242c7d264ce519bb5a68d8736de338866ce/pymongo-4.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:e11e86c9a0f81d23cdd0d0a42a9312c888139d7d330ddd744973d79fec87630c", size = 821904, upload-time = "2026-09-03T16:00:18.921Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/8a6de9d310207a070ebbf78e93bcda6e1cf9cf137368b73efc2d2b83c704/pymongo-4.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:4c6f85e33ef338148cd70bc682fc73bfd202a1cde554057d807905d2310767ad", size = 816438, upload-time = "2026-09-03T16:00:20.935Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/a2ae13bad8d503115ea4f900802c07e0cc5d897e60c5ab04fb6a36044afc/pymongo-4.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:03de6fdfbcd4ad0634313956bffcced13abc9e575b9c74dec70f69cc248b501c", size = 821472, upload-time = "2026-09-03T16:00:22.873Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/0600e7c3bdfc8fc9166ae25272751f2a47e436ad451e45e8d0152227e869/pymongo-4.18.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e8dd4a0c2bd52dd9f78d8808578d5f2ecf1b0fab41a4a169400c969a3e06ae65", size = 821909, upload-time = "2026-09-03T16:00:24.839Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/dc1121d8c949f50f010c15a521a424042322087c553c649b42c16ca15e6b/pymongo-4.18.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5a0e70d348d87e50406bc932f7998a40cc6cfdd4b0628dd0c00ff2470f07e3b1", size = 1105313, upload-time = "2026-09-03T16:00:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0e/37dc8e6cbb25777dfaadb478fee6d5672ebd14eec1ac74eafb8bcda8b2bd/pymongo-4.18.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7ed67136ee8e69c53c657253dbfe19486edd9560a3d8363cc0ee3614b52297", size = 1125099, upload-time = "2026-09-03T16:00:29.079Z" }, + { url = "https://files.pythonhosted.org/packages/d7/15/0c5e21f6d257c5876632aea3b1a38440577f089d94811d757256e773d98d/pymongo-4.18.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abd0db088de3935b87aa27398d6fa98718c3a1d84e36ffbb927a353a9d76a032", size = 1144554, upload-time = "2026-09-03T16:00:31.121Z" }, + { url = "https://files.pythonhosted.org/packages/93/15/1d57e1a4f922de348ffd6595488f7855a46f7d2152a9ca991d0b878657d2/pymongo-4.18.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:721f9b1a378d5bbf1bc2b9de2b7d3eeb4466d2878c7f430d483af7e3f580c935", size = 1136309, upload-time = "2026-09-03T16:00:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/74392c9eaeea549c25a7e0304116cdfde79b7320bd55d652a03914b53d10/pymongo-4.18.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d971ee533dbd7b836abec1e4ceefaeb9f6637c262561614482f213b8a19085be", size = 1117086, upload-time = "2026-09-03T16:00:35.314Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3e/4dd06523d3784b9229059f8812cbed292ad36136adedf32db106796258db/pymongo-4.18.0-cp314-cp314t-win32.whl", hash = "sha256:996a87a5b9c048e3dddf497acde55c7955748572091c70e1424d3c4779171526", size = 818741, upload-time = "2026-09-03T16:00:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d3/7f5ed7d37cd026b0d59b2504e4015b1370c79359f1e81ca5e3eb0428e8e1/pymongo-4.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47627909a177036117b91a3378df8634dc8e93cf4ccd66b22086b0098d3c72de", size = 826081, upload-time = "2026-09-03T16:00:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b2606ddf52fa615d4ff3edb2aa05fb064337fa871f99ebb184a39e051cc8/pymongo-4.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4a81d166a43e8af1e5152b6854a263ba0a8831f7dd2ca1badc716f219f4f1bc0", size = 817605, upload-time = "2026-09-03T16:00:41.547Z" }, +] + [[package]] name = "pyrefly" version = "1.2.0" From 4d5c63a46a9e1b044936c3806f97701041f2df1b Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 14 Sep 2026 08:13:06 +0000 Subject: [PATCH 047/108] Python: Keep tool diagnostics internal by default (#8235) * Python: keep tool diagnostics out of serialized responses Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: simplify exception serialization contract Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: preserve redacted tool failure state Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../specs/004-python-function-calling-loop.md | 6 ++ .../ag-ui/tests/ag_ui/test_run_common.py | 21 ++++ .../anthropic/tests/test_anthropic_client.py | 25 +++++ .../agent_framework_bedrock/_chat_client.py | 15 +-- .../bedrock/tests/test_bedrock_client.py | 16 ++- .../core/agent_framework/_compaction.py | 8 +- .../packages/core/agent_framework/_tools.py | 3 +- .../packages/core/agent_framework/_types.py | 55 ++++++++-- .../core/tests/core/test_compaction.py | 15 ++- .../core/test_function_invocation_logic.py | 15 ++- python/packages/core/tests/core/test_types.py | 43 ++++++++ .../foundry_hosting/tests/test_responses.py | 55 ++++++++++ .../tests/hosting_a2a/test_conversion.py | 6 ++ .../tests/hosting_mcp/test_conversion.py | 10 ++ .../_parsing.py | 12 ++- .../tests/hosting_responses/test_parsing.py | 100 ++++++++++++++++++ .../agent_framework_openai/_chat_client.py | 12 +-- .../tests/openai/test_openai_chat_client.py | 26 +++++ 18 files changed, 395 insertions(+), 48 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 81019bb5bbe..7639183cc91 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -369,6 +369,10 @@ that manually replay messages own the equivalent rule: do not resend an approval already executing in a worker thread cannot be interrupted and may complete its side effects — its result is discarded either way and never reaches the transcript, the model, or history. Middleware must not catch `MiddlewareFailure` — swallowing it converts a fail-closed abort back into a running, possibly unguarded loop. +- `Content.exception` is host-internal diagnostic state. Default `Content.to_dict()` and nested response serialization replace it with a fixed non-sensitive failure marker, + while the original field remains directly available to trusted local code. Remote protocol serializers use the + marker only for status and use the channel-visible `result` or `items` for output text. `include_detailed_errors=False` keeps the channel-visible + result generic; enabling it explicitly may place diagnostic text in the result for that configured channel. - Parallel calls retain model order in the returned transcript. - `call_id` remains the provider/service correlation id; a locally actionable `function_call` also carries a stable Agent Framework occurrence identity in `Content.id`. @@ -582,6 +586,7 @@ that manually replay messages own the equivalent rule: do not resend an approval |---|---|---| | Rejected execution | Rejection is a normal terminal result, not an exception to the caller. | `test_unapproved_tool_execution_raises_exception` | | Approved tool exception | Generic and detailed error modes preserve one result and one execution. | `test_approved_function_call_with_error_without_detailed_errors`, `test_approved_function_call_with_error_with_detailed_errors` | +| Tool exception diagnostics | Internal diagnostics remain available to trusted local code, serialization preserves only a fixed failure marker, and explicit detailed-error configuration affects only the channel-visible result. | `packages/core/tests/core/test_types.py::test_function_result_exception_is_internal_by_default`, `packages/core/tests/core/test_function_invocation_logic.py::test_function_invocation_config_include_detailed_errors_false`, `test_function_invocation_config_include_detailed_errors_true`, `test_streaming_function_invocation_config_include_detailed_errors_false`, `test_streaming_function_invocation_config_include_detailed_errors_true` | | Approved validation error | Validation failure returns one result without invoking the function body. | `test_approved_function_call_with_validation_error` | | Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` | | Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` | @@ -611,6 +616,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` | | Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` | | Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` | +| Remote tool error serialization | Foundry Responses, Responses hosting, and AG-UI preserve channel-visible results without host-internal diagnostics. A2A and MCP hosting omit unsupported intermediate function results entirely, including their diagnostics. | `packages/foundry_hosting/tests/test_responses.py::TestNonStreaming::test_function_result_omits_internal_exception`, `TestStreaming::test_function_result_omits_internal_exception`, `packages/hosting-responses/tests/hosting_responses/test_parsing.py::TestResponsesRunHelpers::test_responses_from_run_omits_internal_function_exception`, `test_responses_from_streaming_run_omits_internal_function_exception`, `packages/ag-ui/tests/ag_ui/test_run_common.py::TestEmitToolResult::test_tool_result_does_not_emit_internal_exception`, `packages/hosting-a2a/tests/hosting_a2a/test_conversion.py::test_a2a_from_run_omits_unsupported_content`, `packages/hosting-mcp/tests/hosting_mcp/test_conversion.py::test_mcp_from_run_omits_content_not_supported_in_tool_results` | | Foundry encrypted reasoning opt-in | Foundry clients omit `reasoning.encrypted_content` by default and preserve an explicit caller opt-in. | `packages/foundry/tests/foundry/test_foundry_chat_client.py::test_get_response_does_not_request_encrypted_reasoning_by_default`, `test_get_response_preserves_explicit_encrypted_reasoning_opt_in`, `packages/foundry/tests/foundry/test_foundry_agent.py::test_foundry_agent_basic_call_does_not_request_unsupported_encrypted_reasoning`, `test_foundry_agent_preserves_caller_requested_encrypted_reasoning`, `packages/foundry_hosting/tests/test_responses_int.py::TestReasoningHostedMcpReplay::test_second_turn_replays_mcp_call_with_encrypted_reasoning` | | Opaque reasoning signature replay | Provider-specific opaque reasoning metadata is captured and restored on reconstructed calls. | `packages/gemini/tests/test_gemini_client.py::test_function_call_part_captures_thought_signature_as_reasoning_content`, `test_reconstructed_function_call_replays_thought_signature_from_reasoning_content` | | Chat Completions approval wrappers | Framework approval wrappers are not sent as chat messages. | `packages/openai/tests/openai/test_openai_chat_completion_client.py` approval serialization tests | diff --git a/python/packages/ag-ui/tests/ag_ui/test_run_common.py b/python/packages/ag-ui/tests/ag_ui/test_run_common.py index 72ab8f8f98a..ba921638299 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run_common.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run_common.py @@ -253,6 +253,27 @@ def test_tool_result_closes_open_text_message(self): assert flow.message_id is None assert flow.accumulated_text == "" + def test_tool_result_does_not_emit_internal_exception(self): + """AG-UI events and snapshots contain only the channel-visible result.""" + diagnostic = "test-token-value at /srv/private/tool.py" + content = Content.from_function_result( + call_id="call_1", + result="Error: Function failed.", + exception=diagnostic, + ) + flow = FlowState() + + events = _emit_tool_result(content, flow) + payload = json.dumps( + { + "events": [event.model_dump(mode="json", by_alias=True) for event in events], + "snapshot": flow.tool_results, + } + ) + + assert "Error: Function failed." in payload + assert diagnostic not in payload + class TestStateUpdateHelper: """Tests for the public ``state_update`` helper.""" diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index c671076505f..36a65228abd 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -374,6 +374,31 @@ def test_prepare_message_for_anthropic_function_result( assert result["content"][0]["is_error"] is False +def test_prepare_message_for_anthropic_preserves_redacted_error_status( + mock_anthropic_client: MagicMock, +) -> None: + """Persisted tool failures retain error status without exposing diagnostics.""" + client = create_test_anthropic_client(mock_anthropic_client) + diagnostic = "test-token-value at /srv/private/tool.py" + failed_result = Content.from_function_result( + call_id="call_123", + result="Error: Function failed.", + exception=diagnostic, + ) + restored_result = Content.from_dict(failed_result.to_dict()) + message = Message(role="tool", contents=[restored_result]) + + result = client._prepare_message_for_anthropic(message) + + tool_result = result["content"][0] + assert tool_result["type"] == "tool_result" + assert tool_result["is_error"] is True + assert diagnostic not in str(tool_result) + tool_content = tool_result["content"] + assert isinstance(tool_content, list) + assert tool_content[0]["text"] == "Error: Function failed." + + def test_prepare_message_for_anthropic_function_result_with_data_image( mock_anthropic_client: MagicMock, ) -> None: diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index d719027db0a..6d2d222d1dd 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -572,24 +572,13 @@ def _convert_content_to_bedrock_block(self, content: Content) -> dict[str, Any] tool_result_blocks = self._convert_tool_result_to_blocks(tool_result_text) else: tool_result_blocks = self._convert_tool_result_to_blocks(content.result) - tool_result_block = { + return { "toolResult": { "toolUseId": content.call_id, "content": tool_result_blocks, - "status": "error" if content.exception else "success", + "status": "error" if content.exception is not None else "success", } } - if content.exception: - tool_result = tool_result_block["toolResult"] - existing_content = tool_result.get("content") - content_list: list[dict[str, Any]] - if isinstance(existing_content, list): - content_list = existing_content - else: - content_list = [] - tool_result["content"] = content_list - content_list.append({"text": str(content.exception)}) - return tool_result_block case _: # Bedrock does not support other content types at this time pass diff --git a/python/packages/bedrock/tests/test_bedrock_client.py b/python/packages/bedrock/tests/test_bedrock_client.py index 41e277d75f3..ee17483e84d 100644 --- a/python/packages/bedrock/tests/test_bedrock_client.py +++ b/python/packages/bedrock/tests/test_bedrock_client.py @@ -448,24 +448,34 @@ def test_align_tool_results_handles_pending_edge_cases() -> None: def test_convert_content_to_bedrock_block_handles_errors_and_missing_items() -> None: """Function result conversion should serialize items, rich content warnings, and fallback results.""" client = _make_client() + diagnostic = "test-token-value at /srv/private/tool.py" rich_result = Content.from_function_result( call_id="call-1", result=[Content.from_text(text="summary"), Content.from_data(data=b"x", media_type="image/png")], - exception="tool failed", + exception=diagnostic, ) + restored_rich_result = Content.from_dict(rich_result.to_dict()) fallback_result = Content.from_function_result(call_id="call-2", result={"answer": 42}) fallback_result.items = None - rich_block = client._convert_content_to_bedrock_block(rich_result) + rich_block = client._convert_content_to_bedrock_block(restored_rich_result) fallback_block = client._convert_content_to_bedrock_block(fallback_result) assert rich_block == { "toolResult": { "toolUseId": "call-1", - "content": [{"text": "summary"}, {"text": "tool failed"}], + "content": [{"text": "summary"}], "status": "error", } } + assert diagnostic not in str(rich_block) + + empty_diagnostic_block = client._convert_content_to_bedrock_block( + Content.from_function_result(call_id="call-empty", result="failed", exception="") + ) + assert empty_diagnostic_block is not None + assert empty_diagnostic_block["toolResult"]["status"] == "error" + assert fallback_block == { "toolResult": { "toolUseId": "call-2", diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index b9080d0a814..b1d627b6359 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -1273,8 +1273,8 @@ def _format_summary_content(content: Content) -> str: result_text = _format_summary_result_items(content.items) if content.items else "" if not result_text: result_text = _tool_result_text(content.result) if content.result is not None else "no result" - if content.exception: - result_text = f"error({content.exception}): {result_text}" + if content.exception is not None: + result_text = f"error: {result_text}" call_id_suffix = f" [call_id={content.call_id}]" if content.call_id else "" return f"function_result: {result_text}{call_id_suffix}" if content.type == "mcp_server_tool_call": @@ -1285,8 +1285,8 @@ def _format_summary_content(content: Content) -> str: return call if content.type == "mcp_server_tool_result": result_text = _tool_result_text(content.output) - if content.exception: - result_text = f"error({content.exception}): {result_text}" + if content.exception is not None: + result_text = f"error: {result_text}" call_id_suffix = f" [call_id={content.call_id}]" if content.call_id else "" return f"mcp_tool_result: {result_text}{call_id_suffix}" if content.type in ("function_approval_request", "function_approval_response"): diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 7c471bd5d93..a42ca1ef4a2 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1462,7 +1462,8 @@ class FunctionInvocationConfiguration(TypedDict, total=False): - ``additional_tools``: Extra tools available during execution but not advertised to the model in the tool list. - ``include_detailed_errors``: Whether to include exception details in the - function result returned to the model. + function result returned to the model. Exception text may contain sensitive + information regardless of its source, so enable this only for a trusted channel. Note: ``max_iterations``, ``max_function_calls``, and ``max_duration_seconds`` diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index e9b1fcc31b7..d8930e8f15c 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -39,6 +39,8 @@ logger = logging.getLogger("agent_framework") +_SERIALIZED_EXCEPTION_MARKER: Final[str] = "FunctionInvocationError" + if TYPE_CHECKING: from pydantic import BaseModel @@ -271,16 +273,24 @@ def _validate_uri(uri: str, media_type: str | None) -> dict[str, Any]: raise ContentError("URI must contain a scheme (e.g., http://, data:, file://)") -def _serialize_value(value: Any, exclude_none: bool) -> Any: +def _serialize_value(value: Any, exclude_none: bool, *, redact_exception: bool = True) -> Any: """Recursively serialize a value for to_dict.""" if value is None: return None if isinstance(value, Content): - return value.to_dict(exclude_none=exclude_none) + return value._to_dict( # pyright: ignore[reportPrivateUsage] + exclude_none=exclude_none, redact_exception=redact_exception + ) if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): - return [_serialize_value(item, exclude_none) for item in cast(Iterable[Any], value)] + return [ + _serialize_value(item, exclude_none, redact_exception=redact_exception) + for item in cast(Iterable[Any], value) + ] if isinstance(value, Mapping): - return {k: _serialize_value(v, exclude_none) for k, v in value.items()} # type: ignore[reportUnknownVariableType] + return { + k: _serialize_value(v, exclude_none, redact_exception=redact_exception) + for k, v in cast(Mapping[Any, Any], value).items() + } if hasattr(value, "to_dict"): return value.to_dict() # type: ignore[call-arg] return value @@ -479,6 +489,10 @@ class Content: This class provides a single unified type that handles all content variants. Use the class methods like `Content.from_text()`, `Content.from_data()`, `Content.from_uri()`, etc. to create instances. + + The ``exception`` field is host-internal diagnostic state. Its value may originate from a tool, middleware, + provider, or caller and must always be treated as potentially sensitive. Dictionary serialization replaces it + with a fixed marker that preserves failure state; channel-visible error information belongs in the public result. """ _SHALLOW_COPY_FIELDS: ClassVar[set[str]] = {"raw_representation"} @@ -856,8 +870,8 @@ def from_function_call( Keyword Args: arguments: The arguments for the requested function call. May be a JSON string, a mapping that can be serialized as arguments, or None when no arguments were provided. - exception: Error information associated with the function call, if the provider returned the call in an - error state. + exception: Host-internal diagnostic information when the provider returned the call in an error state. + Treat it as potentially sensitive regardless of its source; serialization replaces it with a marker. informational_only: Whether the function call is present only for transcript fidelity and should not be executed by Agent Framework function invocation. id: Stable Agent Framework identity for this occurrence. When omitted, the function invocation layer @@ -906,7 +920,9 @@ def from_function_result( result: The tool output. Accepts a ``list[Content]`` (the canonical form produced by :meth:`~FunctionTool.parse_result`), a plain ``str``, or any other value (which is stringified). - exception: The exception message if the function call failed. + exception: Host-internal diagnostic information when the function call failed. Treat it as potentially + sensitive regardless of whether it came from a tool, middleware, provider, or caller. Serialization + replaces it with a fixed failure marker; use ``result`` for channel-visible error text. annotations: Optional annotations for the content. additional_properties: Optional additional properties. raw_representation: Optional raw representation from the provider. @@ -1407,7 +1423,21 @@ def to_function_approval_response( ) def to_dict(self, *, exclude_none: bool = True, exclude: set[str] | None = None) -> dict[str, Any]: - """Serialize the content to a dictionary.""" + """Serialize content without host-internal exception diagnostics. + + Exception diagnostics are replaced with a fixed marker regardless of their source because they may contain + sensitive information. The marker preserves failure status across persistence round-trips. + """ + return self._to_dict(exclude_none=exclude_none, exclude=exclude, redact_exception=True) + + def _to_dict( + self, + *, + exclude_none: bool, + exclude: set[str] | None = None, + redact_exception: bool, + ) -> dict[str, Any]: + """Serialize content with explicit control over internal exception redaction.""" fields_to_capture = ( "text", "protected_data", @@ -1455,11 +1485,13 @@ def to_dict(self, *, exclude_none: bool = True, exclude: set[str] | None = None) value = getattr(self, field, None) if field in exclude: continue + if field == "exception" and value is not None and redact_exception: + value = _SERIALIZED_EXCEPTION_MARKER if field == "informational_only" and (self.type != "function_call" or not value): continue if exclude_none and value is None: continue - result[field] = _serialize_value(value, exclude_none) + result[field] = _serialize_value(value, exclude_none, redact_exception=redact_exception) if "annotations" not in exclude and self.annotations is not None: result["annotations"] = [dict(annotation) for annotation in self.annotations] @@ -1470,7 +1502,10 @@ def __eq__(self, other: object) -> bool: """Check if two Content instances are equal by comparing their dict representations.""" if not isinstance(other, Content): return False - return self.to_dict(exclude_none=False) == other.to_dict(exclude_none=False) + return self._to_dict(exclude_none=False, redact_exception=False) == other._to_dict( + exclude_none=False, + redact_exception=False, + ) def __str__(self) -> str: """Return a string representation of the Content.""" diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index 2cfadff942b..bc62b0b6d90 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -1016,19 +1016,28 @@ def test_format_summary_message_includes_function_call_details() -> None: assert "[call_id=call_1]" in rendered -def test_format_summary_message_includes_function_result_and_exception() -> None: +def test_format_summary_message_redacts_function_result_exception() -> None: + diagnostic = "test-token-value at /srv/private/tool.py" message = Message( role="tool", - contents=[Content.from_function_result(call_id="call_1", result="42", exception="ValueError")], + contents=[Content.from_function_result(call_id="call_1", result="42", exception=diagnostic)], ) rendered = _format_summary_message(2, message) assert "function_result" in rendered assert "42" in rendered - assert "error(ValueError)" in rendered + assert "error" in rendered + assert diagnostic not in rendered assert "[call_id=call_1]" in rendered + empty_diagnostic_message = Message( + role="tool", + contents=[Content.from_function_result(call_id="call_2", result="failed", exception="")], + ) + empty_diagnostic_rendered = _format_summary_message(3, empty_diagnostic_message) + assert "error: failed" in empty_diagnostic_rendered + def test_format_summary_message_renders_function_result_without_call_id() -> None: message = Message( diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 7e688a2e13c..a9689453e9b 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -37,6 +37,7 @@ _EXPECTED_FUNCTION_INVOCATION_LIMIT_FALLBACK_TEXT = ( "Function invocation limit reached before a final answer could be produced." ) +_PRIVATE_ERROR_DETAIL = "test-token-value at /srv/private/tool.py" def _group_id(message: Message) -> str | None: @@ -2920,7 +2921,7 @@ async def test_function_invocation_config_include_detailed_errors_false(chat_cli @tool(name="error_function", approval_mode="never_require") def error_func(arg1: str) -> str: - raise ValueError("Specific error message that should not appear") + raise ValueError(f"Specific error message that should not appear: {_PRIVATE_ERROR_DETAIL}") chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] ChatResponse( @@ -2949,6 +2950,8 @@ def error_func(arg1: str) -> str: assert error_result.exception is not None assert "Specific error message" not in error_result.result assert "Error:" in error_result.result # Generic error prefix + assert _PRIVATE_ERROR_DETAIL in error_result.exception + assert _PRIVATE_ERROR_DETAIL not in json.dumps(response.to_dict()) async def test_function_invocation_config_include_detailed_errors_true(chat_client_base: SupportsChatGetResponse): @@ -2956,7 +2959,7 @@ async def test_function_invocation_config_include_detailed_errors_true(chat_clie @tool(name="error_function", approval_mode="never_require") def error_func(arg1: str) -> str: - raise ValueError("Specific error message that should appear") + raise ValueError(f"Specific error message that should appear: {_PRIVATE_ERROR_DETAIL}") chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] ChatResponse( @@ -2986,6 +2989,7 @@ def error_func(arg1: str) -> str: assert "Specific error message that should appear" in error_result.result # The error format includes "Function failed. Exception:" prefix assert "Exception:" in error_result.result + assert _PRIVATE_ERROR_DETAIL in json.dumps(response.to_dict()) async def test_function_invocation_config_validation_max_iterations(): @@ -5249,7 +5253,7 @@ async def test_streaming_function_invocation_config_include_detailed_errors_true @tool(name="error_function", approval_mode="never_require") def error_func(arg1: str) -> str: - raise ValueError("Specific error message that should appear") + raise ValueError(f"Specific error message that should appear: {_PRIVATE_ERROR_DETAIL}") chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] [ @@ -5282,6 +5286,7 @@ def error_func(arg1: str) -> str: assert error_result.exception is not None assert "Specific error message that should appear" in error_result.result assert "Exception:" in error_result.result + assert _PRIVATE_ERROR_DETAIL in json.dumps([update.to_dict() for update in updates]) async def test_streaming_function_invocation_config_include_detailed_errors_false( @@ -5291,7 +5296,7 @@ async def test_streaming_function_invocation_config_include_detailed_errors_fals @tool(name="error_function", approval_mode="never_require") def error_func(arg1: str) -> str: - raise ValueError("Specific error message that should not appear") + raise ValueError(f"Specific error message that should not appear: {_PRIVATE_ERROR_DETAIL}") chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] [ @@ -5324,6 +5329,8 @@ def error_func(arg1: str) -> str: assert error_result.exception is not None assert "Specific error message" not in error_result.result assert "Error:" in error_result.result # Generic error prefix + assert _PRIVATE_ERROR_DETAIL in error_result.exception + assert _PRIVATE_ERROR_DETAIL not in json.dumps([update.to_dict() for update in updates]) async def test_streaming_argument_validation_error_with_detailed_errors(chat_client_base: SupportsChatGetResponse): diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 9589f1b0324..d0059318de2 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -2478,6 +2478,49 @@ def test_content_to_dict_exclude_fields() -> None: assert parsed["type"] == "text" +def test_function_result_exception_is_internal_by_default() -> None: + diagnostic = "test-token-value at /srv/private/tool.py" + content = Content.from_function_result( + call_id="call-1", + result="Error: Function failed.", + exception=diagnostic, + ) + + assert content.exception == diagnostic + assert content.to_dict()["exception"] == "FunctionInvocationError" + assert content.to_dict(exclude_none=False)["exception"] == "FunctionInvocationError" + response = AgentResponse(messages=[Message(role="tool", contents=[content])]) + serialized = json.dumps(response.to_dict()) + assert diagnostic not in serialized + assert "FunctionInvocationError" in serialized + + restored = Content.from_dict(content.to_dict()) + assert restored.exception == "FunctionInvocationError" + assert restored != Content.from_function_result( + call_id="call-1", + result="Error: Function failed.", + exception="different diagnostic", + ) + + empty_diagnostic = Content.from_function_result(call_id="call-2", exception="") + assert empty_diagnostic.to_dict()["exception"] == "FunctionInvocationError" + + +def test_content_equality_compares_nested_raw_exception_diagnostics() -> None: + first = Content( + "function_result", + call_id="outer", + items=[Content.from_function_result(call_id="inner", exception="diagnostic-a")], + ) + second = Content( + "function_result", + call_id="outer", + items=[Content.from_function_result(call_id="inner", exception="diagnostic-b")], + ) + + assert first != second + + def test_chat_response_roundtrip_preserves_compaction_annotation_dict() -> None: response = ChatResponse( messages=[ diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 9265346a9f5..b4522d29107 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -85,6 +85,7 @@ ) _OPENAI_HTTPX = cast(Any, import_module(DefaultAsyncHttpxClient.__mro__[1].__module__.partition(".")[0])) +_PRIVATE_ERROR_DETAIL = "test-token-value at /srv/private/tool.py" def _function_approval_store(request: Content) -> MagicMock: @@ -1634,6 +1635,34 @@ async def test_function_call_and_result(self) -> None: assert "function_call_output" in types assert "message" in types + async def test_function_result_omits_internal_exception(self) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "get_weather", arguments="{}")], + ), + Message( + role="tool", + contents=[ + Content.from_function_result( + "call_1", + result="Error: Function failed.", + exception=_PRIVATE_ERROR_DETAIL, + ) + ], + ), + ] + ) + ) + + resp = await _post(_make_server(agent), stream=False) + + assert resp.status_code == 200 + assert "Error: Function failed." in resp.text + assert _PRIVATE_ERROR_DETAIL not in resp.text + @pytest.mark.parametrize( ("result", "expected_output"), [ @@ -2041,6 +2070,32 @@ async def test_function_call_streaming(self) -> None: assert len(args_done) == 1 assert args_done[0]["data"]["arguments"] == '{"q": "hello"}' + async def test_function_result_omits_internal_exception(self) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + role="assistant", + contents=[Content.from_function_call("call_1", "get_weather", arguments="{}")], + ), + AgentResponseUpdate( + role="tool", + contents=[ + Content.from_function_result( + "call_1", + result="Error: Function failed.", + exception=_PRIVATE_ERROR_DETAIL, + ) + ], + ), + ] + ) + + resp = await _post(_make_server(agent), stream=True) + + assert resp.status_code == 200 + assert "Error: Function failed." in resp.text + assert _PRIVATE_ERROR_DETAIL not in resp.text + @pytest.mark.parametrize(("arguments", "expected_count"), [(None, 1), ("", 2)]) async def test_declaration_only_metadata_replay_requires_none_arguments( self, arguments: str | None, expected_count: int diff --git a/python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py b/python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py index b8491f2ea79..d30dcc8e67a 100644 --- a/python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py +++ b/python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py @@ -165,6 +165,11 @@ def test_a2a_from_run_omits_unsupported_content() -> None: "assistant", [ Content(type="function_call", call_id="call-1", name="get_weather", arguments="{}"), + Content.from_function_result( + call_id="call-1", + result="Error: Function failed.", + exception="test-token-value at /srv/private/tool.py", + ), Content.from_text("hello"), ], ) @@ -172,6 +177,7 @@ def test_a2a_from_run_omits_unsupported_content() -> None: assert len(parts) == 1 assert parts[0].text == "hello" + assert "test-token-value" not in str([MessageToDict(part) for part in parts]) def test_a2a_from_run_omits_user_messages() -> None: diff --git a/python/packages/hosting-mcp/tests/hosting_mcp/test_conversion.py b/python/packages/hosting-mcp/tests/hosting_mcp/test_conversion.py index 0cfcf1f88a6..21de73f7afc 100644 --- a/python/packages/hosting-mcp/tests/hosting_mcp/test_conversion.py +++ b/python/packages/hosting-mcp/tests/hosting_mcp/test_conversion.py @@ -1,5 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. +import json + from agent_framework import AgentResponse, Content, Message from mcp import types from pytest import raises @@ -113,6 +115,11 @@ def test_mcp_from_run_omits_content_not_supported_in_tool_results() -> None: "assistant", [ Content.from_function_call(call_id="call-1", name="get_weather", arguments="{}"), + Content.from_function_result( + call_id="call-1", + result="Error: Function failed.", + exception="test-token-value at /srv/private/tool.py", + ), Content.from_text("hello"), ], ) @@ -120,6 +127,9 @@ def test_mcp_from_run_omits_content_not_supported_in_tool_results() -> None: assert len(blocks) == 1 assert isinstance(blocks[0], types.TextContent) + payload = json.dumps([block.model_dump(mode="json", by_alias=True) for block in blocks]) + + assert "test-token-value" not in payload def test_mcp_from_run_omits_user_messages() -> None: diff --git a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py index 6cfd77f63e4..62e4bd2b095 100644 --- a/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py +++ b/python/packages/hosting-responses/agent_framework_hosting_responses/_parsing.py @@ -773,9 +773,15 @@ def _function_call_output_item(content: Content, *, status: str) -> ResponseOutp def _function_result_output_item(content: Content, *, status: str) -> ResponseOutputItem: - if content.exception: - output: str | list[Any] = content.exception - elif output_parts := _content_parts_to_input_items(content.items): + """Project channel-visible output without exposing potentially sensitive exception diagnostics.""" + output_parts = _content_parts_to_input_items(content.items) + has_visible_output = any( + getattr(part, "type", None) != "input_text" or bool(getattr(part, "text", None)) for part in output_parts + ) + result_is_empty = content.result is None or (isinstance(content.result, str) and not content.result) + if content.exception is not None and result_is_empty and not has_visible_output: + output: str | list[Any] = "Error: Function failed." + elif output_parts: output = output_parts elif isinstance(content.result, str): output = content.result diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index 23a0753f768..e3c3079de15 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -340,6 +340,78 @@ def test_responses_from_run_preserves_tool_role_function_result(self) -> None: assert payload["output"][0]["output"] == [{"type": "input_text", "text": "sunny"}] assert payload["output"][0]["status"] == "completed" + def test_responses_from_run_omits_internal_function_exception(self) -> None: + diagnostic = "test-token-value at /srv/private/tool.py" + result = AgentResponse( + messages=Message( + role="tool", + contents=[ + Content.from_function_result( + "call_1", + result="Error: Function failed.", + exception=diagnostic, + ) + ], + ) + ) + + payload = responses_from_run(result, response_id="resp_new") + serialized = json.dumps(payload) + + assert "Error: Function failed." in serialized + assert diagnostic not in serialized + + @pytest.mark.parametrize("diagnostic", ["test-token-value at /srv/private/tool.py", ""]) + def test_responses_from_run_uses_generic_output_for_exception_only_result(self, diagnostic: str) -> None: + result = AgentResponse( + messages=Message( + role="tool", + contents=[Content.from_function_result("call_1", exception=diagnostic)], + ) + ) + + payload = responses_from_run(result, response_id="resp_new") + serialized = json.dumps(payload) + + assert "Error: Function failed." in serialized + if diagnostic: + assert diagnostic not in serialized + + @pytest.mark.parametrize( + ("result", "expected_output"), + [ + (0, "0"), + (False, "false"), + ([], "[]"), + ({}, "{}"), + ], + ) + def test_responses_from_run_preserves_falsey_error_result(self, result: object, expected_output: str) -> None: + diagnostic = "test-token-value at /srv/private/tool.py" + content = Content("function_result", call_id="call_1", result=result, exception=diagnostic) + response = AgentResponse(messages=Message(role="tool", contents=[content])) + + payload = responses_from_run(response, response_id="resp_new") + serialized = json.dumps(payload) + + assert payload["output"][0]["output"] == expected_output + assert diagnostic not in serialized + + def test_responses_from_run_uses_generic_error_when_items_project_to_nothing(self) -> None: + diagnostic = "test-token-value at /srv/private/tool.py" + content = Content.from_function_result( + "call_1", + result=[Content("uri", uri=None)], + exception=diagnostic, + ) + response = AgentResponse(messages=Message(role="tool", contents=[content])) + + payload = responses_from_run(response, response_id="resp_new") + serialized = json.dumps(payload) + + assert payload["output"][0]["output"] == "Error: Function failed." + assert diagnostic not in serialized + def test_responses_from_run_rejects_standalone_media(self) -> None: result = AgentResponse( messages=Message( @@ -809,6 +881,34 @@ def finalizer(items: Sequence[AgentResponseUpdate]) -> AgentResponse: completed_output = cast("list[dict[str, object]]", completed_response["output"]) assert done_item["id"] == completed_output[0]["id"] + async def test_responses_from_streaming_run_omits_internal_function_exception(self) -> None: + diagnostic = "test-token-value at /srv/private/tool.py" + + async def updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate( + role="tool", + contents=[ + Content.from_function_result( + "call_1", + result="Error: Function failed.", + exception=diagnostic, + ) + ], + ) + + stream = ResponseStream(updates(), finalizer=AgentResponse.from_updates) + events = [ + event + async for event in responses_from_streaming_run( + stream, + response_id="resp_new", + ) + ] + serialized = "".join(events) + + assert "Error: Function failed." in serialized + assert diagnostic not in serialized + async def test_responses_from_streaming_run_preserves_marked_refusal_deltas(self) -> None: marker = {"model_output_kind": "refusal"} diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 0cc2bc3e34c..60c6c2aafd9 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -2133,10 +2133,8 @@ def _to_local_shell_output_payload(content: Content) -> str: payload = { "stdout": "" if content.result is None else str(content.result), } - if content.exception is not None and "stderr" not in payload: - payload["stderr"] = str(content.exception) if "exit_code" not in payload: - payload["exit_code"] = 1 if content.exception else 0 + payload["exit_code"] = 1 if content.exception is not None else 0 return json.dumps(payload, ensure_ascii=False) @staticmethod @@ -2149,8 +2147,6 @@ def _to_shell_call_output_payload(content: Content) -> list[dict[str, Any]]: payload = { "stdout": "" if content.result is None else str(content.result), } - if content.exception is not None and "stderr" not in payload: - payload["stderr"] = str(content.exception) # Pass through native payload shape when tool already returns shell output entries. direct_output = payload.get("output") @@ -2165,9 +2161,11 @@ def _to_shell_call_output_payload(content: Content) -> list[dict[str, Any]]: else: exit_code_raw = payload.get("exit_code") try: - exit_code = int(exit_code_raw) if exit_code_raw is not None else (1 if content.exception else 0) + exit_code = ( + int(exit_code_raw) if exit_code_raw is not None else (1 if content.exception is not None else 0) + ) except (TypeError, ValueError): - exit_code = 1 if content.exception else 0 + exit_code = 1 if content.exception is not None else 0 outcome = {"type": "exit", "exit_code": exit_code} return [ { diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 7258c50cae9..58d625fb0ec 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -2159,6 +2159,32 @@ def local_exec(command: str) -> str: assert response_tools[0]["environment"]["type"] == "local" +def test_shell_output_payloads_do_not_expose_exception_diagnostics() -> None: + diagnostic = "test-token-value at /srv/private/tool.py" + content = Content.from_function_result( + call_id="call-1", + result="Error: Function failed.", + exception=diagnostic, + ) + + local_payload = json.loads(OpenAIChatClient._to_local_shell_output_payload(content)) + shell_payload = OpenAIChatClient._to_shell_call_output_payload(content) + serialized = json.dumps({"local": local_payload, "shell": shell_payload}) + + assert local_payload["stdout"] == "Error: Function failed." + assert local_payload["exit_code"] == 1 + assert shell_payload == [ + {"stdout": "Error: Function failed.", "stderr": "", "outcome": {"type": "exit", "exit_code": 1}} + ] + assert diagnostic not in serialized + + empty_diagnostic = Content.from_function_result(call_id="call-2", result="failed", exception="") + empty_local_payload = json.loads(OpenAIChatClient._to_local_shell_output_payload(empty_diagnostic)) + empty_shell_payload = OpenAIChatClient._to_shell_call_output_payload(empty_diagnostic) + assert empty_local_payload["exit_code"] == 1 + assert empty_shell_payload[0]["outcome"] == {"type": "exit", "exit_code": 1} + + def test_prepared_local_shell_tool_survives_make_tools() -> None: """Regression: the prepared shell tool must be a subscriptable dict. From 216e7616c8846143364dbdcaee66d9853277231c Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 14 Sep 2026 08:28:05 +0000 Subject: [PATCH 048/108] Python: allow middleware to repair function arguments (#8288) * Python: allow middleware to repair function arguments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: address argument validation review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: preserve validated middleware arguments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: harden function argument snapshots Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: reject opaque argument authority Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../specs/004-python-function-calling-loop.md | 18 + .../core/agent_framework/_middleware.py | 35 +- .../packages/core/agent_framework/_tools.py | 429 +++++++++-- .../packages/core/agent_framework/security.py | 22 +- .../core/test_function_invocation_logic.py | 684 +++++++++++++++++- python/packages/core/tests/test_security.py | 401 +++++++++- .../02-agents/devui/agent_weather/agent.py | 8 +- python/samples/02-agents/tools/README.md | 8 +- .../function_tool_with_explicit_schema.py | 13 +- 9 files changed, 1535 insertions(+), 83 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 7639183cc91..23526ba47c0 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -381,6 +381,17 @@ that manually replay messages own the equivalent rule: do not resend an approval not invent one. - A completed function call/result pair is inert on later turns. - Informational-only and declaration-only calls are not executed as local tools. +- For automatic local execution, provider arguments are JSON-parsed before function middleware. Schema-compatible + arguments retain the existing normalized mapping contract. A provisional validation failure is not terminal: + middleware receives the raw mapping and may repair it before calling `call_next()`. The innermost handler validates + changed or previously invalid arguments immediately before the tool body and writes the normalized mapping back to + `FunctionInvocationContext.arguments`; unchanged provisionally normalized arguments are reused without running + validators twice. Middleware that short-circuits without `call_next()` skips final validation and tool execution. +- Argument-repair middleware must precede security or policy middleware so enforcement observes the effective, + normalized invocation. Built-in security middleware normalizes hidden-value expansions before policy inspection. + Changing arguments after security middleware has processed them fails closed with `MiddlewareFailure`. +- Argument-validation failures after middleware retain the established `Argument parsing failed` result contract. + Exceptions raised by middleware or the tool body retain the separate `Function failed` contract. ### Reasoning-bound calls @@ -418,6 +429,11 @@ that manually replay messages own the equivalent rule: do not resend an approval - If policy middleware detects that the exact resolved invocation changed after approval, the old response executes nothing and yields a caller-visible, session-persisted replacement request for the same occurrence; execution requires a second approval and happens exactly once. +- If function middleware repairs an approval-bound call, the old response likewise executes nothing and yields a + caller-visible, session-persisted replacement request containing the repaired approval-visible arguments. The + replacement retains the call occurrence identity, rotates request-generation identity, and requires a second + approval before exactly-once execution. Security middleware transformations that preserve an approval-visible + placeholder do not disclose the resolved value or trigger a spurious replacement. - If session-bound middleware no longer holds the reviewed authority because it expired or was evicted, the matched response executes nothing and produces a replacement approval request with the same occurrence identity and a fresh request generation. The replacement is caller-visible, becomes the authoritative pending session snapshot, and @@ -588,6 +604,8 @@ that manually replay messages own the equivalent rule: do not resend an approval | Approved tool exception | Generic and detailed error modes preserve one result and one execution. | `test_approved_function_call_with_error_without_detailed_errors`, `test_approved_function_call_with_error_with_detailed_errors` | | Tool exception diagnostics | Internal diagnostics remain available to trusted local code, serialization preserves only a fixed failure marker, and explicit detailed-error configuration affects only the channel-visible result. | `packages/core/tests/core/test_types.py::test_function_result_exception_is_internal_by_default`, `packages/core/tests/core/test_function_invocation_logic.py::test_function_invocation_config_include_detailed_errors_false`, `test_function_invocation_config_include_detailed_errors_true`, `test_streaming_function_invocation_config_include_detailed_errors_false`, `test_streaming_function_invocation_config_include_detailed_errors_true` | | Approved validation error | Validation failure returns one result without invoking the function body. | `test_approved_function_call_with_validation_error` | +| Pre-validation middleware repair | Schema-compatible calls retain normalized middleware arguments without duplicate validation. Prepared-value reuse does not require validator outputs to be copyable, and unchanged NaNs remain stable. Identity-only opaque values may be reused for ordinary execution, but security authority rejects them before downstream middleware or the tool body because in-place mutation cannot be detected safely. When provisional validation fails, function middleware observes raw parsed arguments, may repair them before final validation, and the body receives normalized values; short-circuiting skips final validation and execution. Repair after security middleware fails closed using recursive type-aware, float-bit-exact comparison, including invalid and short-circuited mutations. Security inspects exact normalized values, and validation errors after hidden-value resolution do not disclose resolved values or mapping keys, including validator `TypeError` paths. | `test_function_middleware_keeps_normalized_arguments_for_valid_calls`, `test_prepared_arguments_support_noncopyable_validator_output`, `test_nan_prepared_and_approval_snapshots_are_stable`, `test_function_middleware_repairs_raw_arguments_before_validation`, `test_function_middleware_can_short_circuit_before_argument_validation`, `test_invalid_arguments_produced_by_middleware_keep_argument_error_contract`, `packages/core/tests/test_security.py::TestVariableArgumentPolicy::test_security_rejects_opaque_mutable_validator_output`, `test_argument_mutation_after_security_middleware_fails_closed`, `test_security_snapshot_accepts_unchanged_nan`, `test_argument_mutation_after_security_short_circuit_fails_closed`, `test_security_policy_observes_custom_validator_transform_once`, `test_hidden_argument_validation_error_does_not_disclose_resolved_value`, `test_hidden_mapping_key_is_not_disclosed_by_validation_error`, `test_hidden_value_is_not_disclosed_by_validator_type_error`, `test_hidden_argument_can_be_normalized_after_security_check` | +| Approved middleware repair | Approval binds to the normalized middleware-entry representation, so ordinary Pydantic coercion still completes in one approval round. A changed approval-bound call executes zero times under the old grant, returns a persisted occurrence-bound replacement request in both response modes, and executes once only after the replacement is approved. Recursive type-aware, float-bit-exact comparison treats booleans and numbers, and positive and negative zero, as distinct while keeping unchanged NaNs stable. Opaque mutable normalized values fail closed before approval authority is established. The same replacement rule applies when middleware short-circuits instead of calling the tool. Security expansion preserves approval-visible placeholders. | `test_approved_coercing_arguments_execute_without_replacement`, `test_approved_argument_repair_requires_replacement_approval`, `test_approved_argument_repair_short_circuit_requires_replacement_approval`, `test_approval_snapshot_distinguishes_exact_values`, `test_approval_rejects_opaque_mutable_validator_output`, `packages/core/tests/test_security.py::TestVariableArgumentPolicy::test_hidden_argument_resolution_does_not_require_reapproval` | | Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` | | Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` | | Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents | diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 419cff8f185..bc1f4610a75 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -370,7 +370,15 @@ class FunctionInvocationContext: Attributes: function: The function being invoked. - arguments: The validated arguments for the function. + arguments: The function arguments. In the automatic function-calling loop, + schema-compatible provider arguments retain the existing normalized + mapping contract. If provisional normalization rejects provider + arguments, middleware instead receives the raw JSON-parsed mapping and + may repair it before calling ``call_next()``. The innermost handler + validates changed or previously invalid arguments immediately before + execution, then stores the normalized mapping back on this attribute. + Middleware that short-circuits without calling ``call_next()`` skips + final validation and function execution. session: The agent session for this invocation, if any. metadata: Metadata dictionary for sharing data between function middleware. result: Function execution result. This attribute carries no guaranteed type. @@ -438,7 +446,9 @@ def __init__( Args: function: The function being invoked. - arguments: The validated arguments for the function. + arguments: The function arguments. Automatic invocation supplies a normalized + mapping when provisional validation succeeds, otherwise the raw JSON-parsed + mapping so middleware can repair it before final validation. session: The agent session for this invocation, if any. metadata: Metadata dictionary for sharing data between function middleware. result: Function execution result. Observed and overridden values do not @@ -714,8 +724,16 @@ class FunctionMiddleware(ABC): """Abstract base class for function middleware that can intercept function invocations. Function middleware allows you to intercept and modify function/tool invocations before - and after execution. You can validate arguments, cache results, log invocations, or - override function execution. + and after execution. On entry, schema-compatible calls retain normalized arguments. + When provisional normalization rejects provider arguments, middleware receives the raw + JSON-parsed mapping so it can repair provider-specific deviations before calling + ``call_next()``. The innermost handler validates changed or previously invalid arguments + immediately before execution and updates ``context.arguments`` with normalized values. + You can also cache results, log invocations, or override function execution. + + Argument-repair middleware must run before security or policy middleware so those + layers inspect the effective invocation. Changing arguments after security middleware + has processed them fails closed with :class:`MiddlewareFailure`. Note: FunctionMiddleware is an abstract base class. You must subclass it and implement @@ -768,8 +786,13 @@ async def process( Args: context: Function invocation context containing function, arguments, and metadata. - MiddlewareTypes can set context.result to override execution, or observe - the actual execution result after calling call_next(). + Before ``call_next()``, automatic invocation exposes normalized + arguments for schema-compatible calls and raw JSON-parsed arguments + when provisional validation failed. Middleware may inspect or replace + either mapping. After ``call_next()`` reaches the function, arguments + contain their validated, normalized values. MiddlewareTypes can set + context.result to override execution, or observe the actual execution + result after calling call_next(). call_next: Function to call the next middleware or final function execution. Does not return anything - all data flows through the context. diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index a42ca1ef4a2..805c3df5849 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -8,6 +8,7 @@ import inspect import json import logging +import struct import sys import typing import warnings @@ -121,10 +122,94 @@ def _has_authoritative_approval_session(invocation_session: AgentSession | None) _FUNCTION_RESULT_CARRIER_CONTEXT_KEY: Final[str] = "_function_result_carrier" _FUNCTION_RESULT_PAYLOAD_BUDGET_CONTEXT_KEY: Final[str] = "_function_result_payload_budget" _FUNCTION_RESULT_PAYLOAD_BUDGET_STATE_KEY: Final[str] = "_function_result_payload_budget" +_APPROVED_ARGUMENTS_CONTEXT_KEY: Final[str] = "_approved_function_arguments" +_SECURITY_ARGUMENTS_SNAPSHOT_CONTEXT_KEY: Final[str] = "_security_function_arguments" +_PREPARED_ARGUMENTS_CONTEXT_KEY: Final[str] = "_prepared_function_arguments" +_AUTO_ARGUMENT_PREPARATION_CONTEXT_KEY: Final[str] = "_auto_prepare_function_arguments" _FUNCTION_INVOCATION_LIMIT_FALLBACK_TEXT: Final[str] = ( "Function invocation limit reached before a final answer could be produced." ) _USER_VISIBLE_CONTENT_TYPES: Final[set[str]] = {"data", "uri", "error", "hosted_file", "hosted_vector_store"} + + +class _FunctionArgumentValidationError(TypeError): + """An argument-validation failure raised before the function body starts.""" + + def __init__(self, message: str, *, redacted_message: str | None = None) -> None: + super().__init__(message) + self.redacted_message = redacted_message or message + + +class _FunctionArgumentsChangedAfterApproval(Exception): + """Signal that middleware changed an approval-bound invocation.""" + + def __init__(self, arguments: Mapping[str, Any]) -> None: + super().__init__("Function arguments changed after approval.") + self.arguments = dict(arguments) + + +@dataclass(frozen=True) +class _OpaqueArgumentToken: + """Identity token that is unsuitable for approval or security authority.""" + + value_type_name: str + identity: int + + +def _argument_comparison_token(value: Any) -> Any: + """Build an immutable, type-aware token without copying argument objects.""" + if isinstance(value, BaseModel): + return _argument_comparison_token(value.model_dump(exclude_unset=True)) + if isinstance(value, dict): + return ( + "dict", + frozenset( + (_argument_comparison_token(key), _argument_comparison_token(item)) + for key, item in cast(dict[Any, Any], value).items() + ), + ) + if isinstance(value, list): + return ("list", tuple(_argument_comparison_token(item) for item in cast(list[Any], value))) + if isinstance(value, tuple): + return ("tuple", tuple(_argument_comparison_token(item) for item in cast(tuple[Any, ...], value))) + if isinstance(value, float): + return ("float", struct.pack("!d", value)) + if value is None or isinstance(value, bool | int | str | bytes): + return (type(value), value) + value_type = cast(type[object], type(value)) + return _OpaqueArgumentToken(f"{value_type.__module__}.{value_type.__qualname__}", id(value)) + + +def _contains_opaque_argument_token(token: Any) -> bool: + """Return whether a comparison token contains identity-only values.""" + if isinstance(token, _OpaqueArgumentToken): + return True + if isinstance(token, tuple | frozenset): + return any(_contains_opaque_argument_token(item) for item in cast(Iterable[Any], token)) + return False + + +def _argument_authority_token(value: Any, *, boundary: str) -> Any: + """Build an argument token that is safe to use as authority.""" + token = _argument_comparison_token(value) + if _contains_opaque_argument_token(token): + from ._middleware import MiddlewareFailure + + raise MiddlewareFailure( + f"Cannot safely bind {boundary} to opaque mutable function arguments. " + "Use JSON-native values or an immutable Pydantic representation." + ) + return token + + +@dataclass(frozen=True) +class _PreparedArgumentsState: + """Exact prepared arguments and their immutable comparison token.""" + + arguments: dict[str, Any] + token: Any + + ApprovalMode: TypeAlias = Literal["always_require", "never_require"] ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @@ -323,7 +408,10 @@ class FunctionTool(SerializationMixin): """A tool that wraps a Python function to make it callable by AI models. This class wraps a Python function to make it callable by AI models with automatic - parameter validation and JSON schema generation. + parameter validation and JSON schema generation. Inferred and Pydantic input models + provide recursive runtime validation. Caller-supplied JSON schema mappings are passed + through to providers and receive only the lightweight checks documented on + :paramref:`input_model`; they are not an authorization or security boundary. Attributes: name: The name of the tool. @@ -434,6 +522,15 @@ def __init__( parameters, explicitly provide ``input_model`` (either a Pydantic ``BaseModel`` or a JSON schema dictionary) so the model can reason about the expected arguments. + + A dictionary is preserved as supplied and checked only for top-level + ``required`` fields, ``additionalProperties: false``, and property + ``enum`` and primitive ``type`` values. Nested constraints, + compositions such as ``oneOf``, references such as ``$ref``, and other + JSON Schema keywords are not comprehensively enforced at runtime. Use a + Pydantic model when runtime validation matters. Treat dictionary schemas + as declarations for trusted settings and non-sensitive functions only; + never rely on them as an authorization or security boundary. result_parser: An optional callable with signature ``Callable[[Any], str]`` that overrides the default result parsing behavior. When provided, this callable is used to convert the raw function return value to a string instead of the @@ -637,6 +734,137 @@ async def _invoke_function(self, call_kwargs: Mapping[str, Any]) -> Any: res = await asyncio.to_thread(self.__call__, **call_kwargs) return await res if inspect.isawaitable(res) else res + def _prepare_arguments(self, arguments: BaseModel | Mapping[str, Any] | None) -> dict[str, Any]: + """Validate and normalize arguments immediately before function execution.""" + if arguments is None: + return {} + + try: + if isinstance(arguments, Mapping): + parsed_arguments = dict(arguments) + if self.input_model is not None and not self._schema_supplied: + # exclude_unset (not exclude_none): keep arguments the model + # explicitly provided even when their value is null, and drop + # only the ones it left out, so the function's own defaults + # apply. Excluding null instead would strip a required nullable + # parameter the model deliberately set to null, failing the + # invocation on the missing argument (#5934). + parsed_arguments = self.input_model.model_validate(parsed_arguments).model_dump(exclude_unset=True) + elif isinstance(arguments, BaseModel): + if ( + self.input_model is not None + and not self._schema_supplied + and not isinstance(arguments, self.input_model) + ): + raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}") + parsed_arguments = arguments.model_dump(exclude_unset=True) + else: + raise TypeError( + f"Expected mapping-like arguments for tool '{self.name}', got {type(arguments).__name__}" + ) + except ValidationError as exc: + raise _FunctionArgumentValidationError( + f"Invalid arguments for '{self.name}': {exc}", + redacted_message=f"Invalid arguments for '{self.name}'.", + ) from exc + except TypeError as exc: + raise _FunctionArgumentValidationError( + str(exc), + redacted_message=f"Invalid arguments for '{self.name}'.", + ) from exc + + try: + return _validate_arguments_against_schema( + arguments=parsed_arguments, + schema=self.parameters(), + tool_name=self.name, + ) + except TypeError as exc: + raise _FunctionArgumentValidationError(str(exc)) from exc + + @staticmethod + def _arguments_as_mapping(arguments: Any) -> dict[str, Any] | None: + """Return arguments as a mapping without applying schema validation.""" + candidate = arguments + if candidate is None: + return {} + if isinstance(candidate, BaseModel): + return candidate.model_dump(exclude_unset=True) + if isinstance(candidate, Mapping): + return dict(cast(Mapping[str, Any], candidate)) + return None + + @classmethod + def _approval_visible_arguments( + cls, + arguments: BaseModel | Mapping[str, Any] | None, + context: FunctionInvocationContext | None, + ) -> dict[str, Any] | None: + """Return the non-expanded arguments that an approval request may disclose.""" + if context is not None and "original_arguments_for_messages" in context.metadata: + return cls._arguments_as_mapping(context.metadata["original_arguments_for_messages"]) + return cls._arguments_as_mapping(arguments) + + def _prepare_context_arguments( + self, + context: FunctionInvocationContext, + arguments: BaseModel | Mapping[str, Any] | None, + ) -> dict[str, Any]: + """Prepare current context arguments once, reusing an unchanged prepared snapshot.""" + current_arguments = self._arguments_as_mapping(arguments) + prepared_state = context.metadata.get(_PREPARED_ARGUMENTS_CONTEXT_KEY) + if ( + current_arguments is not None + and isinstance(prepared_state, _PreparedArgumentsState) + and _argument_comparison_token(current_arguments) == prepared_state.token + ): + context.arguments = prepared_state.arguments + return prepared_state.arguments + + validated_arguments = self._prepare_arguments(arguments) + context.arguments = validated_arguments + context.metadata[_PREPARED_ARGUMENTS_CONTEXT_KEY] = _PreparedArgumentsState( + arguments=validated_arguments, + token=_argument_comparison_token(validated_arguments), + ) + return validated_arguments + + @staticmethod + def _ensure_security_arguments_unchanged( + context: FunctionInvocationContext | None, + current_arguments: Mapping[str, Any] | None, + ) -> None: + """Fail closed when arguments change after security middleware processed them.""" + if context is None: + return + security_token = context.metadata.get(_SECURITY_ARGUMENTS_SNAPSHOT_CONTEXT_KEY) + if security_token is not None and ( + current_arguments is None + or _argument_authority_token(dict(current_arguments), boundary="security policy") != security_token + ): + from ._middleware import MiddlewareFailure + + raise MiddlewareFailure( + "Function arguments changed after security middleware processed them. " + "Install argument-repair middleware before security middleware." + ) + + @staticmethod + def _ensure_approved_arguments_unchanged( + context: FunctionInvocationContext | None, + approval_visible_arguments: Mapping[str, Any] | None, + ) -> None: + """Require a replacement approval when middleware changes approved arguments.""" + if context is None: + return + if approval_visible_arguments is None: + return + approved_token = context.metadata.get(_APPROVED_ARGUMENTS_CONTEXT_KEY) + if approved_token is None: + return + if _argument_authority_token(dict(approval_visible_arguments), boundary="approval") != approved_token: + raise _FunctionArgumentsChangedAfterApproval(approval_visible_arguments) + @overload async def invoke( self, @@ -728,42 +956,14 @@ async def invoke( if arguments is None and context is not None: arguments = context.arguments - if arguments is None: - validated_arguments: dict[str, Any] = {} - else: - try: - if isinstance(arguments, Mapping): - parsed_arguments = dict(arguments) - if self.input_model is not None and not self._schema_supplied: - # exclude_unset (not exclude_none): keep arguments the model - # explicitly provided even when their value is null, and drop - # only the ones it left out, so the function's own defaults - # apply. Excluding null instead would strip a required nullable - # parameter the model deliberately set to null, failing the - # invocation on the missing argument (#5934). - parsed_arguments = self.input_model.model_validate(parsed_arguments).model_dump( - exclude_unset=True - ) - elif isinstance(arguments, BaseModel): - if ( - self.input_model is not None - and not self._schema_supplied - and not isinstance(arguments, self.input_model) - ): - raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}") - parsed_arguments = arguments.model_dump(exclude_unset=True) - else: - raise TypeError( - f"Expected mapping-like arguments for tool '{self.name}', got {type(arguments).__name__}" - ) - except ValidationError as exc: - raise TypeError(f"Invalid arguments for '{self.name}': {exc}") from exc - - validated_arguments = _validate_arguments_against_schema( - arguments=parsed_arguments, - schema=self.parameters(), - tool_name=self.name, - ) + current_arguments = self._arguments_as_mapping(arguments) + approval_visible_arguments = self._approval_visible_arguments(arguments, context) + self._ensure_security_arguments_unchanged(context, current_arguments) + validated_arguments = ( + self._prepare_context_arguments(context, arguments) + if context is not None + else self._prepare_arguments(arguments) + ) effective_context = context if effective_context is None and self._context_parameter_name is not None: @@ -777,6 +977,8 @@ async def invoke( effective_context.arguments = validated_arguments effective_context.kwargs = dict(runtime_kwargs) + self._ensure_approved_arguments_unchanged(effective_context, approval_visible_arguments) + call_kwargs = dict(validated_arguments) observable_kwargs = dict(validated_arguments) if self._context_parameter_name is not None and effective_context is not None: @@ -1183,7 +1385,7 @@ def _validate_arguments_against_schema( schema: Mapping[str, Any], tool_name: str, ) -> dict[str, Any]: - """Run lightweight argument checks for schema-supplied tools.""" + """Run lightweight, top-level argument checks for schema-supplied tools.""" parsed_arguments = dict(arguments) required_fields = [field for field in schema.get("required", []) if isinstance(field, str)] @@ -1203,9 +1405,7 @@ def _validate_arguments_against_schema( enum_values = properties.get(field_name, {}).get("enum") if isinstance(enum_values, list) and enum_values and field_value not in enum_values: - raise TypeError( - f"Invalid value for '{field_name}' in '{tool_name}': {field_value!r} is not in {enum_values!r}" - ) + raise TypeError(f"Invalid value for '{field_name}' in '{tool_name}': value is not in {enum_values!r}") schema_type = properties.get(field_name, {}).get("type") if isinstance(schema_type, str): @@ -1299,9 +1499,14 @@ def tool( docstring will be used. schema: An explicit input schema for the function. This can be a Pydantic ``BaseModel`` subclass or a JSON schema dictionary (``Mapping[str, Any]``). - When a dictionary is provided, it must be a flat object schema with a - ``properties`` key (complex JSON Schema features such as ``oneOf``, - ``$ref``, or nested compositions are not supported). + Dictionary schemas are passed through to providers and receive only + lightweight top-level checks for ``required``, ``additionalProperties: + false``, property ``enum``, and primitive property ``type``. Nested + constraints, compositions such as ``oneOf``, references such as ``$ref``, + and other JSON Schema keywords are not comprehensively enforced at runtime. + Use a Pydantic model when runtime validation matters. Dictionary schemas are + intended for trusted settings and non-sensitive functions and must not be + treated as an authorization or security boundary. When provided, the schema is used instead of inferring one from the function's signature. Defaults to ``None`` (infer from signature). approval_mode: Whether or not approval is required to run this tool. @@ -1549,6 +1754,62 @@ def _function_execution_error_result( ) +def _function_argument_validation_error_result( + function_call: Content, + exception: _FunctionArgumentValidationError, + config: FunctionInvocationConfiguration, + context: FunctionInvocationContext | None = None, +) -> Content: + """Build the stable tool result for argument-validation failures.""" + from ._types import Content + + exception_message = ( + exception.redacted_message + if context is not None and _SECURITY_ARGUMENTS_SNAPSHOT_CONTEXT_KEY in context.metadata + else str(exception) + ) + message = "Error: Argument parsing failed." + if config.get("include_detailed_errors", False): + message = f"{message} Exception: {exception_message}" + return Content.from_function_result( + call_id=function_call.call_id, # type: ignore[arg-type] + result=message, + exception=exception_message, + additional_properties=function_call.additional_properties, + ) + + +def _replacement_approval_request( + function_call: Content, + arguments: Mapping[str, Any], +) -> Content: + """Create a new approval generation for middleware-repaired arguments.""" + from ._types import Content + + call_id = function_call.call_id + if call_id is None: + raise KeyError(f'Function "{function_call.name}" is missing call_id.') + occurrence_id = function_call.id or call_id + request_id = f"{occurrence_id}:replacement:{uuid4().hex}" + repaired_call = Content.from_function_call( + call_id=call_id, + name=function_call.name, # type: ignore[arg-type] + arguments=copy.deepcopy(dict(arguments)), + id=occurrence_id, + annotations=copy.deepcopy(function_call.annotations), + additional_properties=copy.deepcopy(function_call.additional_properties), + ) + return Content.from_function_approval_request( + id=request_id, + function_call=repaired_call, + additional_properties={ + _APPROVAL_REQUEST_ID_KEY: request_id, + "_replacement_approval_request": True, + "reason": "Function arguments changed after approval.", + }, + ) + + def _finalize_function_result( *, call_id: str, @@ -1679,29 +1940,7 @@ async def _auto_invoke_function( } if invocation_session is not None: runtime_kwargs["session"] = invocation_session - try: - if not cast(bool, getattr(tool, "_schema_supplied", False)) and tool.input_model is not None: - # exclude_unset (not exclude_none) so an argument the model explicitly set - # to null still reaches the function; see FunctionTool.invoke for the full - # rationale. This is the auto-calling path #5934 actually hits. - args = tool.input_model.model_validate(parsed_args).model_dump(exclude_unset=True) - else: - args = dict(parsed_args) - args = _validate_arguments_against_schema( - arguments=args, - schema=tool.parameters(), - tool_name=tool.name, - ) - except (TypeError, ValidationError) as exc: - message = "Error: Argument parsing failed." - if config.get("include_detailed_errors", False): - message = f"{message} Exception: {exc}" - return Content.from_function_result( - call_id=function_call_content.call_id, # type: ignore[arg-type] - result=message, - exception=str(exc), - additional_properties=function_call_content.additional_properties, - ) + args = dict(parsed_args) from ._middleware import FunctionInvocationContext, MiddlewareFailure @@ -1734,9 +1973,20 @@ async def _auto_invoke_function( # Explicit control-flow signals escape the loop; only ordinary exceptions # are absorbed into tool-error results below. raise + except _FunctionArgumentValidationError as exc: + return _function_argument_validation_error_result(function_call_content, exc, config) except Exception as exc: return _function_execution_error_result(function_call_content, tool.name, exc, config, direct_context) # Execute through middleware pipeline if available + arguments_prepared = False + try: + args = tool._prepare_arguments(args) # pyright: ignore[reportPrivateUsage] + arguments_prepared = True + except _FunctionArgumentValidationError: + # Invalid provider arguments are intentionally exposed to middleware so + # it has a supported opportunity to repair them before final validation. + pass + middleware_context = FunctionInvocationContext( function=tool, arguments=args, @@ -1746,6 +1996,12 @@ async def _auto_invoke_function( ) if host_payload_budget is not None: middleware_context.metadata[_FUNCTION_RESULT_PAYLOAD_BUDGET_CONTEXT_KEY] = host_payload_budget + middleware_context.metadata[_AUTO_ARGUMENT_PREPARATION_CONTEXT_KEY] = True + if arguments_prepared: + middleware_context.metadata[_PREPARED_ARGUMENTS_CONTEXT_KEY] = _PreparedArgumentsState( + arguments=args, + token=_argument_comparison_token(args), + ) call_id = function_call_content.call_id if call_id is None: @@ -1760,14 +2016,36 @@ async def _auto_invoke_function( # this replay corresponds to a middleware-specific approval flow. if approval_response is not None: middleware_context.metadata["approval_response"] = approval_response + middleware_context.metadata[_APPROVED_ARGUMENTS_CONTEXT_KEY] = _argument_authority_token( + args, + boundary="approval", + ) + + final_handler_started = False async def final_function_handler(context_obj: Any) -> Any: + nonlocal final_handler_started + final_handler_started = True return await tool.invoke( arguments=context_obj.arguments, context=context_obj, tool_call_id=call_id, ) + def ensure_short_circuit_arguments_are_authorized() -> None: + current_arguments = tool._arguments_as_mapping( # pyright: ignore[reportPrivateUsage] + middleware_context.arguments + ) + tool._ensure_security_arguments_unchanged( # pyright: ignore[reportPrivateUsage] + middleware_context, current_arguments + ) + tool._ensure_approved_arguments_unchanged( # pyright: ignore[reportPrivateUsage] + middleware_context, + tool._approval_visible_arguments( # pyright: ignore[reportPrivateUsage] + middleware_context.arguments, middleware_context + ), + ) + from ._middleware import MiddlewareTermination # MiddlewareTermination bubbles up to signal loop termination @@ -1776,6 +2054,8 @@ async def final_function_handler(context_obj: Any) -> Any: context=middleware_context, final_handler=final_function_handler, ) + if not final_handler_started: + ensure_short_circuit_arguments_are_authorized() # Pass through function_approval_request directly (e.g., from security middleware) if isinstance(function_result, Content) and function_result.type == "function_approval_request": @@ -1788,6 +2068,14 @@ async def final_function_handler(context_obj: Any) -> Any: context=middleware_context, ) except MiddlewareTermination as term_exc: + if not final_handler_started: + try: + ensure_short_circuit_arguments_are_authorized() + except _FunctionArgumentsChangedAfterApproval as exc: + raise MiddlewareTermination( + "Function arguments changed after approval.", + result=_replacement_approval_request(function_call_content, exc.arguments), + ) from exc # Re-raise to signal loop termination, but first capture any result set by middleware if middleware_context.result is not None: # Pass through function_approval_request directly (e.g., from security policy middleware) @@ -1806,6 +2094,13 @@ async def final_function_handler(context_obj: Any) -> Any: context=middleware_context, ) raise + except _FunctionArgumentsChangedAfterApproval as exc: + raise MiddlewareTermination( + "Function arguments changed after approval.", + result=_replacement_approval_request(function_call_content, exc.arguments), + ) from exc + except _FunctionArgumentValidationError as exc: + return _function_argument_validation_error_result(function_call_content, exc, config, middleware_context) except (MiddlewareFailure, UserInputRequiredException): # MiddlewareFailure is the loop's explicit fail-closed escape: middleware that # must abort the run (enforcement layers, guardrails) raises it instead of diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index d20f0d67586..e0e50166714 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -36,7 +36,14 @@ from ._middleware import FunctionInvocationContext, FunctionMiddleware, MiddlewareTermination from ._serialization import SerializationMixin from ._sessions import AgentSession, ContextProvider -from ._tools import _APPROVAL_REQUEST_ID_KEY, FunctionTool, tool # pyright: ignore[reportPrivateUsage] +from ._tools import ( + _APPROVAL_REQUEST_ID_KEY, # pyright: ignore[reportPrivateUsage] + _AUTO_ARGUMENT_PREPARATION_CONTEXT_KEY, # pyright: ignore[reportPrivateUsage] + _SECURITY_ARGUMENTS_SNAPSHOT_CONTEXT_KEY, # pyright: ignore[reportPrivateUsage] + FunctionTool, + _argument_authority_token, # pyright: ignore[reportPrivateUsage] + tool, +) from ._types import Content, Message if TYPE_CHECKING: @@ -1549,6 +1556,19 @@ async def process( # Expand hidden references before execution and retain their stored labels. resolved_labels = self._expand_variable_references_in_context(context) + context.metadata[_SECURITY_ARGUMENTS_SNAPSHOT_CONTEXT_KEY] = _argument_authority_token( + context.arguments, + boundary="security policy", + ) + if context.metadata.get(_AUTO_ARGUMENT_PREPARATION_CONTEXT_KEY) is True: + context.function._prepare_context_arguments( # pyright: ignore[reportPrivateUsage] + context, + context.arguments, + ) + context.metadata[_SECURITY_ARGUMENTS_SNAPSHOT_CONTEXT_KEY] = _argument_authority_token( + context.arguments, + boundary="security policy", + ) argument_labels = [*input_labels, *resolved_labels] argument_label = combine_labels(*argument_labels) if argument_labels else ContentLabel() diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index a9689453e9b..d829aaa4c31 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3,11 +3,14 @@ import asyncio import json import logging +import math +import threading import warnings from collections.abc import AsyncIterable, Awaitable, Callable, Sequence from typing import Any, Literal import pytest +from pydantic import BaseModel, field_validator from agent_framework import ( Agent, @@ -32,7 +35,13 @@ annotate_message_groups, included_token_count, ) -from agent_framework._middleware import FunctionInvocationContext, FunctionMiddleware, MiddlewareTermination +from agent_framework._middleware import ( + FunctionInvocationContext, + FunctionMiddleware, + FunctionMiddlewarePipeline, + MiddlewareFailure, + MiddlewareTermination, +) _EXPECTED_FUNCTION_INVOCATION_LIMIT_FALLBACK_TEXT = ( "Function invocation limit reached before a final answer could be produced." @@ -3126,6 +3135,679 @@ def typed_func(arg1: int) -> str: # Expects int, not str assert "Exception:" not in error_result.result # No detailed error +async def test_function_middleware_repairs_raw_arguments_before_validation( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Function middleware can repair provider arguments before final validation.""" + observed_arguments: list[dict[str, Any]] = [] + executed_arguments: list[int] = [] + + class RepairArgumentsMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + assert isinstance(context.arguments, dict) + observed_arguments.append(dict(context.arguments)) + context.arguments["count"] = int(context.arguments.pop("count_text")) + await call_next() + assert context.arguments == {"count": 3} + + @tool(name="count_items", approval_mode="never_require") + def count_items(count: int) -> str: + executed_arguments.append(count) + return str(count) + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="repair-1", + name="count_items", + arguments='{"count_text": "3"}', + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + await chat_client_base.get_response( + [Message(role="user", contents=["count"])], + options={"tools": [count_items]}, + client_kwargs={"middleware": [RepairArgumentsMiddleware()]}, + ) + + assert observed_arguments == [{"count_text": "3"}] + assert executed_arguments == [3] + + +async def test_function_middleware_keeps_normalized_arguments_for_valid_calls( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Valid calls preserve the existing normalized middleware argument contract.""" + validation_count = 0 + observed_arguments: list[dict[str, Any]] = [] + + class CountArgs(BaseModel): + count: int + + @field_validator("count") + @classmethod + def track_validation(cls, value: int) -> int: + nonlocal validation_count + validation_count += 1 + return value + + class ObserveArgumentsMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + assert isinstance(context.arguments, dict) + observed_arguments.append(dict(context.arguments)) + await call_next() + + @tool(name="count_items", schema=CountArgs, approval_mode="never_require") + def count_items(count: int) -> str: + return str(count) + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="valid-1", name="count_items", arguments='{"count": "3"}') + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + await chat_client_base.get_response( + [Message(role="user", contents=["count"])], + options={"tools": [count_items]}, + client_kwargs={"middleware": [ObserveArgumentsMiddleware()]}, + ) + + assert observed_arguments == [{"count": 3}] + assert validation_count == 1 + + +async def test_approved_coercing_arguments_execute_without_replacement() -> None: + """Approval binds to the normalized middleware-entry representation.""" + from agent_framework._tools import _auto_invoke_function, normalize_function_invocation_configuration + + class CountArgs(BaseModel): + count: int + + class PassthroughMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + assert context.arguments == {"count": 3} + await call_next() + + received: list[int] = [] + + @tool(name="approved_count", schema=CountArgs, approval_mode="always_require") + def approved_count(count: int) -> str: + received.append(count) + return str(count) + + function_call = Content.from_function_call( + call_id="approved-coercion", + id="approved-coercion-occurrence", + name=approved_count.name, + arguments={"count": "3"}, + ) + approval_response = Content.from_function_approval_request( + id="approved-coercion-occurrence", + function_call=function_call, + ).to_function_approval_response(approved=True) + + result = await _auto_invoke_function( + approval_response, + config=normalize_function_invocation_configuration(None), + tool_map={approved_count.name: approved_count}, + middleware_pipeline=FunctionMiddlewarePipeline(PassthroughMiddleware()), + ) + + assert result.type == "function_result" + assert result.result == "3" + assert received == [3] + + +async def test_prepared_arguments_support_noncopyable_validator_output() -> None: + """Prepared argument reuse does not require validator outputs to be deepcopyable.""" + from agent_framework._tools import _auto_invoke_function, normalize_function_invocation_configuration + + validation_count = 0 + received: list[Any] = [] + + class LockArgs(BaseModel): + value: Any + + @field_validator("value") + @classmethod + def create_lock(cls, value: Any) -> Any: + nonlocal validation_count + validation_count += 1 + return threading.Lock() if value == "lock" else value + + class PassthroughMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + await call_next() + + @tool(name="lock_tool", schema=LockArgs, approval_mode="never_require") + def lock_tool(value: Any) -> str: + received.append(value) + return "locked" if value.locked() else "unlocked" + + result = await _auto_invoke_function( + Content.from_function_call(call_id="noncopyable", name=lock_tool.name, arguments={"value": "lock"}), + config=normalize_function_invocation_configuration(None), + tool_map={lock_tool.name: lock_tool}, + middleware_pipeline=FunctionMiddlewarePipeline(PassthroughMiddleware()), + ) + + assert result.type == "function_result" + assert result.result == "unlocked" + assert len(received) == 1 + assert validation_count == 1 + + +async def test_approval_rejects_opaque_mutable_validator_output() -> None: + """Approval fails closed when normalized arguments cannot be safely snapshotted.""" + from agent_framework._tools import _auto_invoke_function, normalize_function_invocation_configuration + + middleware_called = False + tool_called = False + + class MutableValue: + def __init__(self) -> None: + self.value = "initial" + + class MutableArgs(BaseModel): + value: Any + + @field_validator("value") + @classmethod + def create_mutable_value(cls, value: Any) -> Any: + return MutableValue() if value == "mutable" else value + + class MutatingMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + nonlocal middleware_called + middleware_called = True + assert isinstance(context.arguments, dict) + context.arguments["value"].value = "changed" + await call_next() + + @tool(name="opaque_approval_tool", schema=MutableArgs, approval_mode="always_require") + def opaque_approval_tool(value: Any) -> str: + nonlocal tool_called + tool_called = True + return value.value + + function_call = Content.from_function_call( + call_id="opaque-approval", + id="opaque-approval-occurrence", + name=opaque_approval_tool.name, + arguments={"value": "mutable"}, + ) + approval_response = Content.from_function_approval_request( + id="opaque-approval-occurrence", + function_call=function_call, + ).to_function_approval_response(approved=True) + + with pytest.raises(MiddlewareFailure, match="Cannot safely bind approval to opaque mutable function arguments"): + await _auto_invoke_function( + approval_response, + config=normalize_function_invocation_configuration(None), + tool_map={opaque_approval_tool.name: opaque_approval_tool}, + middleware_pipeline=FunctionMiddlewarePipeline(MutatingMiddleware()), + ) + + assert not middleware_called + assert not tool_called + + +async def test_nan_prepared_and_approval_snapshots_are_stable() -> None: + """An unchanged NaN survives prepared and approval snapshot comparison.""" + from agent_framework._tools import _auto_invoke_function, normalize_function_invocation_configuration + + validation_count = 0 + received: list[float] = [] + + class FloatArgs(BaseModel): + value: float + + @field_validator("value") + @classmethod + def track_validation(cls, value: float) -> float: + nonlocal validation_count + validation_count += 1 + return value + + class PassthroughMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + assert isinstance(context.arguments, dict) + assert math.isnan(context.arguments["value"]) + await call_next() + + @tool(name="nan_tool", schema=FloatArgs, approval_mode="always_require") + def nan_tool(value: float) -> str: + received.append(value) + return "nan" + + function_call = Content.from_function_call( + call_id="nan-call", + id="nan-occurrence", + name=nan_tool.name, + arguments={"value": "NaN"}, + ) + approval_response = Content.from_function_approval_request( + id="nan-occurrence", + function_call=function_call, + ).to_function_approval_response(approved=True) + + result = await _auto_invoke_function( + approval_response, + config=normalize_function_invocation_configuration(None), + tool_map={nan_tool.name: nan_tool}, + middleware_pipeline=FunctionMiddlewarePipeline(PassthroughMiddleware()), + ) + + assert result.type == "function_result" + assert result.result == "nan" + assert len(received) == 1 and math.isnan(received[0]) + assert validation_count == 1 + + +async def test_function_middleware_can_short_circuit_before_argument_validation( + chat_client_base: SupportsChatGetResponse, +) -> None: + """A middleware result can handle an invalid call without invoking validation or the tool.""" + executions = 0 + + class ShortCircuitMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + del call_next + assert context.arguments == {"wrong_name": "not-an-int"} + context.result = "handled by middleware" + + @tool(name="strict_tool", approval_mode="never_require") + def strict_tool(count: int) -> str: + nonlocal executions + executions += 1 + return str(count) + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="short-circuit-1", + name="strict_tool", + arguments='{"wrong_name": "not-an-int"}', + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["count"])], + options={"tools": [strict_tool]}, + client_kwargs={"middleware": [ShortCircuitMiddleware()]}, + ) + + assert executions == 0 + result = next( + content for message in response.messages for content in message.contents if content.type == "function_result" + ) + assert result.result == "handled by middleware" + + +async def test_invalid_arguments_produced_by_middleware_keep_argument_error_contract( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Final validation still rejects invalid middleware output before the tool body.""" + executions = 0 + + class InvalidRepairMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + context.arguments = {"count": "not-an-int"} + await call_next() + + @tool(name="strict_tool", approval_mode="never_require") + def strict_tool(count: int) -> str: + nonlocal executions + executions += 1 + return str(count) + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="invalid-repair-1", name="strict_tool", arguments='{"count": 1}') + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["count"])], + options={"tools": [strict_tool]}, + client_kwargs={"middleware": [InvalidRepairMiddleware()]}, + ) + + assert executions == 0 + result = next( + content for message in response.messages for content in message.contents if content.type == "function_result" + ) + assert result.result == "Error: Argument parsing failed." + assert result.exception is not None + + +async def test_middleware_type_error_remains_function_error(chat_client_base: SupportsChatGetResponse) -> None: + """A middleware TypeError is not misclassified as an argument-validation failure.""" + + class FailingMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + del context, call_next + raise TypeError("middleware failed") + + @tool(name="strict_tool", approval_mode="never_require") + def strict_tool(count: int) -> str: + return str(count) + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="middleware-error-1", name="strict_tool", arguments='{"count": 1}' + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["count"])], + options={"tools": [strict_tool]}, + client_kwargs={"middleware": [FailingMiddleware()]}, + ) + + result = next( + content for message in response.messages for content in message.contents if content.type == "function_result" + ) + assert result.result == "Error: Function failed." + assert result.exception == "middleware failed" + + +@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) +async def test_approved_argument_repair_requires_replacement_approval( + chat_client_base: SupportsChatGetResponse, + streaming: bool, +) -> None: + """Middleware cannot silently execute repaired arguments under an approval for a different call.""" + executions: list[int] = [] + + class RepairArgumentsMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + assert isinstance(context.arguments, dict) + if "count_text" in context.arguments: + context.arguments = {"count": int(context.arguments["count_text"])} + await call_next() + + @tool(name="approved_count", approval_mode="always_require") + def approved_count(count: int) -> str: + executions.append(count) + return str(count) + + function_call = Content.from_function_call( + call_id="approved-repair-1", + name="approved_count", + arguments='{"count_text": "3"}', + ) + if streaming: + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ChatResponseUpdate(role="assistant", contents=[function_call])], + [ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])], + ] + else: + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=[function_call])), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + agent = Agent( + client=chat_client_base, + tools=[approved_count], + middleware=[RepairArgumentsMiddleware()], + ) + session = agent.create_session() + + async def run(value: str | Message): + if not streaming: + return await agent.run(value, session=session) + stream = agent.run(value, session=session, stream=True) + async for _ in stream: + pass + return await stream.get_final_response() + + first_response = await run("count") + first_request = next( + content + for message in first_response.messages + for content in message.contents + if content.type == "function_approval_request" + ) + replacement_response = await run( + Message(role="user", contents=[first_request.to_function_approval_response(approved=True)]) + ) + replacement_request = next( + content + for message in replacement_response.messages + for content in message.contents + if content.type == "function_approval_request" + ) + + assert executions == [] + assert replacement_request.id != first_request.id + assert replacement_request.additional_properties["_replacement_approval_request"] is True + assert replacement_request.function_call is not None + assert first_request.function_call is not None + assert replacement_request.function_call.id == first_request.function_call.id + assert replacement_request.function_call.parse_arguments() == {"count": 3} + + final_response = await run( + Message(role="user", contents=[replacement_request.to_function_approval_response(approved=True)]) + ) + + assert executions == [3] + assert final_response.text == "done" + + +async def test_approved_argument_repair_short_circuit_requires_replacement_approval( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Short-circuiting middleware cannot return under stale argument approval.""" + executions = 0 + + class RepairAndShortCircuitMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + del call_next + assert isinstance(context.arguments, dict) + if "count_text" in context.arguments: + context.arguments = {"count": int(context.arguments["count_text"])} + context.result = "handled by middleware" + + @tool(name="approved_short_circuit", approval_mode="always_require") + def approved_short_circuit(count: int) -> str: + nonlocal executions + executions += 1 + return str(count) + + function_call = Content.from_function_call( + call_id="approved-short-circuit-1", + name="approved_short_circuit", + arguments='{"count_text": "3"}', + ) + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse(messages=Message(role="assistant", contents=[function_call])), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + agent = Agent( + client=chat_client_base, + tools=[approved_short_circuit], + middleware=[RepairAndShortCircuitMiddleware()], + ) + session = agent.create_session() + + first_response = await agent.run("count", session=session) + first_request = next( + content + for message in first_response.messages + for content in message.contents + if content.type == "function_approval_request" + ) + replacement_response = await agent.run( + Message(role="user", contents=[first_request.to_function_approval_response(approved=True)]), + session=session, + ) + replacement_request = next( + content + for message in replacement_response.messages + for content in message.contents + if content.type == "function_approval_request" + ) + + assert executions == 0 + assert replacement_request.function_call is not None + assert replacement_request.function_call.parse_arguments() == {"count": 3} + + final_response = await agent.run( + Message(role="user", contents=[replacement_request.to_function_approval_response(approved=True)]), + session=session, + ) + + assert executions == 0 + assert final_response.text == "done" + result = next( + content + for message in final_response.messages + for content in message.contents + if content.type == "function_result" + ) + assert result.result == "handled by middleware" + + +@pytest.mark.parametrize( + ("approved_value", "changed_value"), + [(True, 1), (-0.0, 0.0)], + ids=["boolean-to-integer", "negative-zero-to-positive-zero"], +) +async def test_approval_snapshot_distinguishes_exact_values( + approved_value: Any, + changed_value: Any, +) -> None: + """A type or floating-point bit change requires replacement approval.""" + from agent_framework._tools import _auto_invoke_function, normalize_function_invocation_configuration + + class ChangeTypeMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + context.arguments = {"value": changed_value} + await call_next() + + @tool(name="typed_value", approval_mode="always_require") + def typed_value(value: Any) -> str: + return f"{type(value).__name__}:{value}" + + function_call = Content.from_function_call( + call_id="typed-approval", + id="typed-approval-occurrence", + name=typed_value.name, + arguments={"value": approved_value}, + ) + approval_response = Content.from_function_approval_request( + id="typed-approval-occurrence", + function_call=function_call, + ).to_function_approval_response(approved=True) + + with pytest.raises(MiddlewareTermination) as exc_info: + await _auto_invoke_function( + approval_response, + config=normalize_function_invocation_configuration(None), + tool_map={typed_value.name: typed_value}, + middleware_pipeline=FunctionMiddlewarePipeline(ChangeTypeMiddleware()), + ) + + replacement = exc_info.value.result + assert isinstance(replacement, Content) + assert replacement.type == "function_approval_request" + assert replacement.function_call is not None + replacement_arguments = replacement.function_call.parse_arguments() + assert replacement_arguments is not None + replacement_value = replacement_arguments["value"] + if isinstance(changed_value, float): + assert isinstance(replacement_value, float) + assert math.copysign(1.0, replacement_value) == math.copysign(1.0, changed_value) + else: + assert replacement_value == changed_value + + async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetResponse): """Test handling of approval responses for hosted tools (tools not in tool_map).""" diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 4fae7108ab5..51d9421b01a 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -5,13 +5,14 @@ import asyncio import json import logging +import math from datetime import timedelta from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock import pytest -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from agent_framework import ( Agent, @@ -23,7 +24,7 @@ Message, SessionContext, ) -from agent_framework._middleware import FunctionMiddlewarePipeline, MiddlewareTermination +from agent_framework._middleware import FunctionMiddlewarePipeline, MiddlewareFailure, MiddlewareTermination from agent_framework._tools import ( FunctionTool, _auto_invoke_function, @@ -5887,6 +5888,402 @@ async def execute(current: FunctionInvocationContext) -> list[Content]: assert context.metadata["argument_label"].integrity == IntegrityLabel.UNTRUSTED assert tracker.get_context_label().integrity == IntegrityLabel.TRUSTED + async def test_hidden_argument_resolution_does_not_require_reapproval(self) -> None: + """Security expansion preserves the approval-visible placeholder.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + variable_id = tracker.get_variable_store().store( + "payload", + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + sink = self._sink(accepts_untrusted=True) + function_call = Content.from_function_call( + call_id="approved-hidden-value", + id="approved-hidden-value-occurrence", + name=sink.name, + arguments={"value": f"[{variable_id}]"}, + ) + approval_response = Content.from_function_approval_request( + id="approved-hidden-value-occurrence", + function_call=function_call, + ).to_function_approval_response(approved=True) + + result = await _auto_invoke_function( + approval_response, + config=normalize_function_invocation_configuration(None), + tool_map={sink.name: sink}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy), + ) + + assert result.type == "function_result" + assert result.result == "payload" + + async def test_hidden_argument_can_be_normalized_after_security_check(self) -> None: + """Final Pydantic coercion is not mistaken for post-policy mutation.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + variable_id = tracker.get_variable_store().store( + "3", + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + + class StrictArgs(BaseModel): + value: int + + received: list[int] = [] + + def strict_sink(value: int) -> str: + received.append(value) + return str(value) + + strict_tool = FunctionTool( + func=strict_sink, + name="strict_sink", + input_model=StrictArgs, + additional_properties={"accepts_untrusted": True}, + ) + function_call = Content.from_function_call( + call_id="hidden-normalization", + name=strict_tool.name, + arguments={"value": f"[{variable_id}]"}, + ) + + result = await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration(None), + tool_map={strict_tool.name: strict_tool}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy), + ) + + assert result.type == "function_result" + assert result.exception is None + assert received == [3] + + @pytest.mark.parametrize("include_detailed_errors", [False, True]) + async def test_hidden_argument_validation_error_does_not_disclose_resolved_value( + self, + include_detailed_errors: bool, + ) -> None: + """Validation failures redact values resolved by security middleware.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + variable_id = tracker.get_variable_store().store( + "secret payload", + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + + class StrictArgs(BaseModel): + value: int + + strict_tool = FunctionTool( + func=lambda value: str(value), + name="strict_sink", + input_model=StrictArgs, + additional_properties={"accepts_untrusted": True}, + ) + function_call = Content.from_function_call( + call_id="hidden-validation-error", + name=strict_tool.name, + arguments={"value": f"[{variable_id}]"}, + ) + + result = await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration({"include_detailed_errors": include_detailed_errors}), + tool_map={strict_tool.name: strict_tool}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy), + ) + + assert result.type == "function_result" + assert "secret payload" not in str(result.result) + assert "secret payload" not in str(result.exception) + assert result.exception == "Invalid arguments for 'strict_sink'." + + async def test_hidden_mapping_key_is_not_disclosed_by_validation_error(self) -> None: + """Pydantic error locations cannot expose keys from resolved hidden mappings.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + variable_id = tracker.get_variable_store().store( + {"secret-key": 1}, + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + + class StrictArgs(BaseModel): + value: dict[int, int] + + strict_tool = FunctionTool( + func=lambda value: str(value), + name="strict_mapping_sink", + input_model=StrictArgs, + additional_properties={"accepts_untrusted": True}, + ) + function_call = Content.from_function_call( + call_id="hidden-mapping-key-error", + name=strict_tool.name, + arguments={"value": f"[{variable_id}]"}, + ) + + result = await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration({"include_detailed_errors": True}), + tool_map={strict_tool.name: strict_tool}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy), + ) + + assert result.type == "function_result" + assert "secret-key" not in str(result.result) + assert "secret-key" not in str(result.exception) + assert result.exception == "Invalid arguments for 'strict_mapping_sink'." + + async def test_hidden_value_is_not_disclosed_by_validator_type_error(self) -> None: + """Direct validator TypeErrors use the generic security redaction.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + variable_id = tracker.get_variable_store().store( + "secret payload", + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + + class StrictArgs(BaseModel): + value: str + + @field_validator("value") + @classmethod + def reject_value(cls, value: str) -> str: + raise TypeError(f"rejected: {value}") + + strict_tool = FunctionTool( + func=lambda value: value, + name="validator_type_error_sink", + input_model=StrictArgs, + additional_properties={"accepts_untrusted": True}, + ) + function_call = Content.from_function_call( + call_id="hidden-validator-type-error", + name=strict_tool.name, + arguments={"value": f"[{variable_id}]"}, + ) + + result = await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration({"include_detailed_errors": True}), + tool_map={strict_tool.name: strict_tool}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy), + ) + + assert result.type == "function_result" + assert "secret payload" not in str(result.result) + assert "secret payload" not in str(result.exception) + assert result.exception == "Invalid arguments for 'validator_type_error_sink'." + + @pytest.mark.parametrize( + ("inspected_value", "changed_value"), + [(True, 1), (-0.0, 0.0)], + ids=["boolean-to-integer", "negative-zero-to-positive-zero"], + ) + async def test_argument_mutation_after_security_middleware_fails_closed( + self, + inspected_value: Any, + changed_value: Any, + ) -> None: + """A later type or float-bit change fails closed after policy inspection.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + sink = self._sink(accepts_untrusted=True) + + class LateRepairMiddleware(FunctionMiddleware): + async def process(self, context, call_next): + context.arguments = {"value": changed_value} + await call_next() + + function_call = Content.from_function_call( + call_id="late-repair", + name=sink.name, + arguments={"value": inspected_value}, + ) + + with pytest.raises(MiddlewareFailure, match="Install argument-repair middleware before security middleware"): + await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration(None), + tool_map={sink.name: sink}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy, LateRepairMiddleware()), + ) + + async def test_security_snapshot_accepts_unchanged_nan(self) -> None: + """An unchanged NaN remains stable through security snapshot comparison.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + received: list[float] = [] + + class FloatArgs(BaseModel): + value: float + + def nan_sink(value: float) -> str: + received.append(value) + return "nan" + + nan_tool = FunctionTool( + func=nan_sink, + name="nan_sink", + input_model=FloatArgs, + additional_properties={"accepts_untrusted": True}, + ) + function_call = Content.from_function_call( + call_id="security-nan", + name=nan_tool.name, + arguments={"value": "NaN"}, + ) + + result = await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration(None), + tool_map={nan_tool.name: nan_tool}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy), + ) + + assert result.type == "function_result" + assert len(received) == 1 and math.isnan(received[0]) + + async def test_security_policy_observes_custom_validator_transform_once(self) -> None: + """Security middleware inspects the exact normalized value delivered to the tool.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + validation_count = 0 + observed: list[str] = [] + received: list[str] = [] + + class TransformArgs(BaseModel): + value: str + + @field_validator("value") + @classmethod + def transform_value(cls, value: str) -> str: + nonlocal validation_count + validation_count += 1 + return "dangerous-operation" if value == "safe" else value + + class ObserveAfterTrackingMiddleware(FunctionMiddleware): + async def process(self, context, call_next): + observed.append(cast(dict[str, str], context.arguments)["value"]) + await call_next() + + def transformed_sink(value: str) -> str: + received.append(value) + return value + + transformed_tool = FunctionTool( + func=transformed_sink, + name="transformed_sink", + input_model=TransformArgs, + additional_properties={"accepts_untrusted": True}, + ) + function_call = Content.from_function_call( + call_id="validator-transform", + name=transformed_tool.name, + arguments={"value": "safe"}, + ) + + result = await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration(None), + tool_map={transformed_tool.name: transformed_tool}, + middleware_pipeline=FunctionMiddlewarePipeline( + tracker, + ObserveAfterTrackingMiddleware(), + policy, + ), + ) + + assert result.type == "function_result" + assert observed == ["dangerous-operation"] + assert received == ["dangerous-operation"] + assert validation_count == 1 + + async def test_security_rejects_opaque_mutable_validator_output(self) -> None: + """Security fails closed when normalized arguments cannot be safely snapshotted.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + middleware_called = False + tool_called = False + + class MutableValue: + def __init__(self) -> None: + self.value = "initial" + + class MutableArgs(BaseModel): + value: Any + + @field_validator("value") + @classmethod + def create_mutable_value(cls, value: Any) -> Any: + return MutableValue() if value == "mutable" else value + + class MutatingMiddleware(FunctionMiddleware): + async def process(self, context, call_next): + nonlocal middleware_called + middleware_called = True + assert isinstance(context.arguments, dict) + cast(Any, context.arguments["value"]).value = "changed" + await call_next() + + def opaque_security_tool(value: Any) -> str: + nonlocal tool_called + tool_called = True + return value.value + + function = FunctionTool( + func=opaque_security_tool, + name="opaque_security_tool", + input_model=MutableArgs, + additional_properties={"accepts_untrusted": True}, + ) + function_call = Content.from_function_call( + call_id="opaque-security", + name=function.name, + arguments={"value": "mutable"}, + ) + + with pytest.raises( + MiddlewareFailure, + match="Cannot safely bind security policy to opaque mutable function arguments", + ): + await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration(None), + tool_map={function.name: function}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy, MutatingMiddleware()), + ) + + assert not middleware_called + assert not tool_called + + async def test_argument_mutation_after_security_short_circuit_fails_closed(self) -> None: + """Post-policy mutation cannot evade the guard by short-circuiting execution.""" + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware() + sink = self._sink(accepts_untrusted=True) + + class LateShortCircuitMiddleware(FunctionMiddleware): + async def process(self, context, call_next): + del call_next + context.arguments = {"value": "changed after policy"} + context.result = "short-circuited" + + function_call = Content.from_function_call( + call_id="late-short-circuit", + name=sink.name, + arguments={"value": "approved value"}, + ) + + with pytest.raises(MiddlewareFailure, match="Install argument-repair middleware before security middleware"): + await _auto_invoke_function( + function_call, + config=normalize_function_invocation_configuration(None), + tool_map={sink.name: sink}, + middleware_pipeline=FunctionMiddlewarePipeline(tracker, policy, LateShortCircuitMiddleware()), + ) + async def test_private_hidden_argument_is_blocked_from_public_sink(self) -> None: tracker = LabelTrackingFunctionMiddleware() policy = PolicyEnforcementFunctionMiddleware() diff --git a/python/samples/02-agents/devui/agent_weather/agent.py b/python/samples/02-agents/devui/agent_weather/agent.py index 201053ab923..b2a316ae6bd 100644 --- a/python/samples/02-agents/devui/agent_weather/agent.py +++ b/python/samples/02-agents/devui/agent_weather/agent.py @@ -3,7 +3,7 @@ import logging import os -from collections.abc import AsyncIterable, Awaitable, Callable +from collections.abc import AsyncIterable, Awaitable, Callable, Mapping from typing import Annotated from agent_framework import ( @@ -93,7 +93,11 @@ async def atlantis_location_filter_middleware( ) -> None: """Function middleware that blocks weather requests for Atlantis.""" # Check if location parameter is "atlantis" - location = getattr(context.arguments, "location", None) + location = ( + context.arguments.get("location") + if isinstance(context.arguments, Mapping) + else getattr(context.arguments, "location", None) + ) if location and location.lower() == "atlantis": context.result = ( "Blocked! Hold up right there!! Tell the user that " diff --git a/python/samples/02-agents/tools/README.md b/python/samples/02-agents/tools/README.md index b24fe2223be..c4bfaf4bc4e 100644 --- a/python/samples/02-agents/tools/README.md +++ b/python/samples/02-agents/tools/README.md @@ -8,7 +8,7 @@ injection, and dynamic (progressive) tool exposure. | File | Demonstrates | |------|--------------| -| [`function_tool_with_explicit_schema.py`](function_tool_with_explicit_schema.py) | Defining a tool with an explicit JSON schema. | +| [`function_tool_with_explicit_schema.py`](function_tool_with_explicit_schema.py) | Choosing between Pydantic validation and a trusted, non-sensitive JSON schema declaration. | | [`function_tool_declaration_only.py`](function_tool_declaration_only.py) | A declaration-only tool (schema without a local implementation). | | [`function_tool_with_kwargs.py`](function_tool_with_kwargs.py) | Passing extra keyword arguments into a tool. | | [`function_tool_from_dict_with_dependency_injection.py`](function_tool_from_dict_with_dependency_injection.py) | Dependency injection into a tool defined from a dict. | @@ -16,6 +16,12 @@ injection, and dynamic (progressive) tool exposure. | [`tool_in_class.py`](tool_in_class.py) | Using a method on a class as a tool. | | [`agent_as_tool_with_session_propagation.py`](agent_as_tool_with_session_propagation.py) | Exposing an agent as a tool with session propagation. | +> [!WARNING] +> Caller-supplied JSON schema mappings receive only lightweight top-level runtime +> checks. Nested constraints and other JSON Schema keywords are not comprehensively +> enforced, so mappings must not be used as an authorization or security boundary. +> Use a Pydantic model for sensitive tools or whenever runtime validation matters. + ## Approvals & invocation control | File | Demonstrates | diff --git a/python/samples/02-agents/tools/function_tool_with_explicit_schema.py b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py index 8090d4a2a02..9ef8b187afe 100644 --- a/python/samples/02-agents/tools/function_tool_with_explicit_schema.py +++ b/python/samples/02-agents/tools/function_tool_with_explicit_schema.py @@ -10,8 +10,14 @@ represent the desired schema. Two approaches are shown: -1. Using a Pydantic BaseModel subclass as the schema -2. Using a raw JSON schema dictionary as the schema +1. Using a Pydantic BaseModel subclass for recursive runtime validation +2. Using a raw JSON schema dictionary for a trusted, non-sensitive tool + +Raw schema dictionaries are passed through to the model and receive only +lightweight top-level checks at runtime. They do not comprehensively enforce +nested JSON Schema keywords and must not be used as an authorization or +security boundary. Prefer Pydantic for sensitive tools or whenever runtime +validation matters. """ import asyncio @@ -45,7 +51,8 @@ def get_weather(location: str, unit: str = "celsius") -> str: return f"The weather in {location} is 22 degrees {unit}." -# Approach 2: JSON schema dictionary as explicit schema +# Approach 2: JSON schema dictionary for a trusted, non-sensitive tool. +# This receives lightweight top-level checks only; use Pydantic for runtime enforcement. get_current_time_schema = { "type": "object", "properties": { From 31bf0112506b92ec3482dcdd82fe146736a57b2f Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 14 Sep 2026 09:27:48 +0000 Subject: [PATCH 049/108] Python: Clean Hyperlight output files after execution (#8230) * Python: Clean Hyperlight output files after execution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Bound Hyperlight output cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Replace sandboxes after cleanup failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Isolate delayed Hyperlight output Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Stabilize Hyperlight cleanup tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../_execute_code_tool.py | 226 ++++++++--- .../hyperlight/test_hyperlight_codeact.py | 351 +++++++++++++++++- 2 files changed, 515 insertions(+), 62 deletions(-) diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index 3dfb20f0afe..3618cc9e6b8 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -57,6 +57,14 @@ class _OutputMaterializationError(RuntimeError): """Raised when sandbox output cannot be safely materialized.""" +class _OutputCleanupError(RuntimeError): + """Raised when sandbox output cannot be safely cleaned within configured bounds.""" + + +def _output_cleanup_error_content(error: _OutputCleanupError) -> Content: + return Content.from_error(message="Execution error", error_details=str(error)) + + @dataclass(frozen=True, slots=True) class _ValidatedOutputFile: relative_path: str @@ -148,7 +156,7 @@ class _SandboxWorker: copy (preserving message and exception type) is re-raised on the caller. """ - __slots__ = ("_executor", "_initialized", "_sandbox", "_snapshot") + __slots__ = ("_executor", "_initialized", "_reusable", "_sandbox", "_snapshot") def __init__(self, *, name: str = "hl-sandbox") -> None: self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix=name) @@ -156,6 +164,7 @@ def __init__(self, *, name: str = "hl-sandbox") -> None: self._sandbox: Any = None self._snapshot: Any = None self._initialized = False + self._reusable = True def _run_on_worker(self, fn: Callable[[], _T]) -> _T: """Run ``fn`` on the worker thread; sanitize any exception's traceback there. @@ -232,30 +241,63 @@ def execute( Returns a plain ``list[Content]`` whose elements never carry strong references to the underlying sandbox or snapshot. """ + cleanup_max_entries = _output_traversal_entry_limit(max_output_files) def _on_worker() -> list[Content]: + if not self._reusable: + return [_output_cleanup_error_content(_OutputCleanupError("Could not clear sandbox output safely."))] + sandbox = self._sandbox snapshot = self._snapshot sandbox.restore(snapshot) - _clear_directory(output_dir) - result = sandbox.run(code=code) try: - return build_contents( - result=result, - output_dir=output_dir, - code=code, - max_output_files=max_output_files, - max_output_file_bytes=max_output_file_bytes, - max_output_total_bytes=max_output_total_bytes, + _clear_directory( + output_dir, + max_entries=cleanup_max_entries, + max_depth=OUTPUT_TRAVERSAL_MAX_DEPTH, ) + except _OutputCleanupError as exc: + self._reusable = False + return [_output_cleanup_error_content(exc)] + + contents: list[Content] | None = None + try: + result = sandbox.run(code=code) + try: + contents = build_contents( + result=result, + output_dir=output_dir, + max_output_files=max_output_files, + max_output_file_bytes=max_output_file_bytes, + max_output_total_bytes=max_output_total_bytes, + ) + if output_dir is not None and not any(item.type == "data" for item in contents): + # Isolate any file that becomes visible after the finite discovery window + # from later invocations by retiring this output generation. + self._reusable = False + return contents + finally: + # ``result`` may carry a back-reference to the sandbox. Force its + # final dec_ref on this thread so Drop runs here, not on whatever + # thread later GCs the ``Content`` list. + del result finally: - # ``result`` may carry a back-reference to the sandbox. Force its - # final dec_ref on this thread so Drop runs here, not on whatever - # thread later GCs the ``Content`` list. - del result + try: + _clear_directory( + output_dir, + max_entries=cleanup_max_entries, + max_depth=OUTPUT_TRAVERSAL_MAX_DEPTH, + ) + except _OutputCleanupError as exc: + self._reusable = False + if contents is not None and not any(item.type == "error" for item in contents): + contents.append(_output_cleanup_error_content(exc)) return self._run_on_worker(_on_worker) + def is_reusable(self) -> bool: + return self._reusable + def is_alive(self) -> bool: """Return ``True`` while the worker thread can still accept new submissions. @@ -321,15 +363,20 @@ class _SandboxEntry: input_dir: TemporaryDirectory[str] | None output_dir: TemporaryDirectory[str] | None + def cleanup_temp_dirs(self) -> None: + """Clean up temporary directories after the sandbox worker has stopped.""" + temp_dirs = (self.input_dir, self.output_dir) + self.input_dir = None + self.output_dir = None + for tmp_dir in temp_dirs: + if tmp_dir is not None: + with suppress(Exception): + _remove_temporary_directory(tmp_dir) + def dispose(self) -> None: """Release the sandbox+snapshot on the worker thread and clean up temp dirs.""" self.worker.dispose() - for tmp_dir in (self.input_dir, self.output_dir): - if tmp_dir is not None: - with suppress(Exception): - tmp_dir.cleanup() - self.input_dir = None - self.output_dir = None + self.cleanup_temp_dirs() def _load_sandbox_class() -> type[Any]: @@ -1137,7 +1184,6 @@ def _build_execution_contents( *, result: Any, output_dir: TemporaryDirectory[str] | None, - code: str, max_output_files: int, max_output_file_bytes: int, max_output_total_bytes: int, @@ -1154,7 +1200,7 @@ def _build_execution_contents( try: output_files = _parse_output_files( output_dir=output_dir, - expect_output_files="/output" in code, + expect_output_files=output_dir is not None, max_output_files=max_output_files, max_output_file_bytes=max_output_file_bytes, max_output_total_bytes=max_output_total_bytes, @@ -1224,33 +1270,110 @@ def _run() -> None: return _callback -def _clear_directory(output_dir: TemporaryDirectory[str] | None) -> None: - """Remove all contents of the output directory without deleting the directory itself.""" +def _clear_directory( + output_dir: TemporaryDirectory[str] | None, + *, + max_entries: int = OUTPUT_TRAVERSAL_MAX_ENTRIES, + max_depth: int = OUTPUT_TRAVERSAL_MAX_DEPTH, +) -> None: + """Remove output entries without following links or exceeding traversal bounds.""" if output_dir is None: return + root = Path(output_dir.name) - for child in root.iterdir(): + entries_visited = 0 + + def _remove_contents(path: Path, depth: int) -> None: + nonlocal entries_visited + try: + with os.scandir(path) as entries: + for entry in entries: + entries_visited += 1 + if entries_visited > max_entries: + raise _OutputCleanupError(f"Sandbox output exceeded the cleanup entry limit of {max_entries}.") + + child = Path(entry.path) + child_stat = child.lstat() + if _is_link_or_reparse_point(child, child_stat): + if stat.S_ISDIR(child_stat.st_mode): + child.rmdir() + else: + try: + child.unlink() + except OSError: + child.rmdir() + continue + + if not stat.S_ISDIR(child_stat.st_mode): + child.unlink() + continue + + child_depth = depth + 1 + if child_depth > max_depth: + raise _OutputCleanupError( + f"Sandbox output exceeded the cleanup nesting depth limit of {max_depth}." + ) + _remove_contents(child, child_depth) + child.rmdir() + except _OutputCleanupError: + raise + except OSError as exc: + raise _OutputCleanupError("Could not clear sandbox output safely.") from exc + + _remove_contents(root, 0) + + +def _remove_temporary_directory(tmp_dir: TemporaryDirectory[str]) -> None: + """Remove a stopped sandbox's temporary tree without recursive traversal.""" + root = Path(tmp_dir.name) + scan_stack: list[tuple[Path, Any]] = [] + + try: try: + scan_stack.append((root, os.scandir(root))) + except FileNotFoundError: + tmp_dir.cleanup() + return + + while scan_stack: + current, entries = scan_stack[-1] + try: + entry = next(entries) + except StopIteration: + entries.close() + scan_stack.pop() + current.rmdir() + continue + + child = Path(entry.path) child_stat = child.lstat() if _is_link_or_reparse_point(child, child_stat): if stat.S_ISDIR(child_stat.st_mode): child.rmdir() - continue - try: - child.unlink() - except OSError: - child.rmdir() - elif stat.S_ISREG(child_stat.st_mode): - child.unlink() - elif stat.S_ISDIR(child_stat.st_mode): - shutil.rmtree(child, ignore_errors=True) - except OSError: - pass + else: + try: + child.unlink() + except OSError: + child.rmdir() + continue + + if stat.S_ISDIR(child_stat.st_mode): + scan_stack.append((child, os.scandir(child))) + continue + + child.unlink() + finally: + for _, entries in scan_stack: + with suppress(OSError): + entries.close() + + tmp_dir.cleanup() class _SandboxRegistry(SandboxRuntime): def __init__(self) -> None: self._entries: dict[tuple[Any, ...], _SandboxEntry] = {} + self._cleanup_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="hl-cleanup") self._entries_lock = threading.RLock() def execute(self, *, config: _RunConfig, code: str) -> list[Content]: @@ -1261,16 +1384,30 @@ def execute(self, *, config: _RunConfig, code: str) -> list[Content]: both serializes concurrent callers and satisfies the PyO3 ``unsendable`` invariant that the sandbox can only be touched from the thread that created it. The unsendable objects never escape the worker; this method returns only sendable plain Python data. + Entries whose output cannot be cleaned safely are evicted before another invocation. """ + cache_key = config.cache_key() entry = self._get_or_create_entry(config) - return entry.worker.execute( - code=code, - output_dir=entry.output_dir, - build_contents=_build_execution_contents, - max_output_files=config.max_output_files, - max_output_file_bytes=config.max_output_file_bytes, - max_output_total_bytes=config.max_output_total_bytes, - ) + try: + return entry.worker.execute( + code=code, + output_dir=entry.output_dir, + build_contents=_build_execution_contents, + max_output_files=config.max_output_files, + max_output_file_bytes=config.max_output_file_bytes, + max_output_total_bytes=config.max_output_total_bytes, + ) + finally: + if not entry.worker.is_reusable(): + self._discard_entry(cache_key=cache_key, entry=entry) + + def _discard_entry(self, *, cache_key: tuple[Any, ...], entry: _SandboxEntry) -> None: + with self._entries_lock: + if self._entries.get(cache_key) is not entry: + return + del self._entries[cache_key] + entry.worker.dispose() + self._cleanup_executor.submit(entry.cleanup_temp_dirs) def _get_or_create_entry(self, config: _RunConfig) -> _SandboxEntry: cache_key = config.cache_key() @@ -1294,6 +1431,7 @@ def close(self) -> None: for entry in entries: entry.dispose() finally: + self._cleanup_executor.shutdown(wait=True, cancel_futures=False) # Drop our local strong references; entries' own refs to sandbox/snapshot # were already moved into the per-worker disposal closure inside dispose(). del entries diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index 636ede98b78..897d69d94d4 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -342,7 +342,10 @@ class _FakeSandboxWithDelayedUnlistedOutput(_FakeSandboxWithoutOutputListing): writer_threads: list[threading.Thread] = [] def run(self, code: str) -> _FakeResult: - if 'Path("/output/report.txt").write_text("artifact", encoding="utf-8")' in code: + if ( + 'Path("/output/report.txt").write_text("artifact", encoding="utf-8")' in code + or code == "create-dynamic-delayed-output" + ): if self.output_dir is None: raise AssertionError("Expected output directory for delayed output test.") output_dir = self.output_dir @@ -395,6 +398,14 @@ def run(self, code: str) -> _FakeResult: (output_root / "nested").mkdir() (output_root / "nested" / "report.bin").write_bytes(b"data") self.output_files = ["nested/report.bin"] + elif code == "create-wide-output": + for index in range(101): + (output_root / f"directory-{index}").mkdir() + elif code == "create-deep-output": + current = output_root + for index in range(execute_code_module.OUTPUT_TRAVERSAL_MAX_DEPTH + 1): + current /= f"level-{index}" + current.mkdir() else: return super().run(code) @@ -584,14 +595,17 @@ async def test_execute_code_tool_populates_input_dir_with_workspace_and_file_mou workspace_root=workspace_root, file_mounts=[FileMount(mounted_file, "data/input.txt")], ) - result = await execute_code.invoke(arguments={"code": "None"}) + try: + result = await execute_code.invoke(arguments={"code": "create-output"}) - assert result[0].type == "text" - assert _FakeSandbox.instances[0].input_dir is not None + assert result[0].type == "text" + assert _FakeSandbox.instances[0].input_dir is not None - input_root = Path(_FakeSandbox.instances[0].input_dir) - assert (input_root / "notes.txt").read_text(encoding="utf-8") == "workspace note" - assert (input_root / "data" / "input.txt").read_text(encoding="utf-8") == "hello from mount" + input_root = Path(_FakeSandbox.instances[0].input_dir) + assert (input_root / "notes.txt").read_text(encoding="utf-8") == "workspace note" + assert (input_root / "data" / "input.txt").read_text(encoding="utf-8") == "hello from mount" + finally: + _close_execute_code_registry(execute_code) def _build_run_config( @@ -1155,6 +1169,35 @@ def test_clear_directory_removes_junction_without_deleting_target(tmp_path: Path assert not (output_root / "linked_dir").exists() +def test_clear_directory_bounds_directory_only_breadth(tmp_path: Path) -> None: + output_root = tmp_path / "output" + output_root.mkdir() + for index in range(101): + (output_root / f"directory-{index}").mkdir() + + with pytest.raises(execute_code_module._OutputCleanupError, match="cleanup entry limit of 100"): + execute_code_module._clear_directory( + cast("TemporaryDirectory[str]", _OutputDirShim(output_root)), + max_entries=100, + max_depth=execute_code_module.OUTPUT_TRAVERSAL_MAX_DEPTH, + ) + + assert sum(1 for _ in output_root.iterdir()) == 1 + + +def test_clear_directory_bounds_nesting_depth(tmp_path: Path) -> None: + output_root = tmp_path / "output" + current = output_root + for index in range(3): + current /= f"level-{index}" + current.mkdir(parents=True) + + with pytest.raises(execute_code_module._OutputCleanupError, match="cleanup nesting depth limit of 2"): + execute_code_module._clear_directory( + cast("TemporaryDirectory[str]", _OutputDirShim(output_root)), max_entries=100, max_depth=2 + ) + + def test_is_safe_output_file_rejects_parent_traversal(tmp_path: Path) -> None: """A lexical ``..`` component must be rejected even without any symlink.""" output_root = tmp_path / "output" @@ -1240,6 +1283,163 @@ def guarded_fdopen(fd: int, *args: Any, **kwargs: Any) -> _BoundedReadGuard: _assert_bounded_output_error(contents, "per-file output limit") +async def test_execute_code_tool_clears_output_after_rejection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandboxWithBoundedOutputs) + execute_code = HyperlightExecuteCodeTool( + workspace_root=tmp_path, + max_output_file_bytes=3, + ) + config = execute_code._build_run_config() + output_root = Path(cast(Any, execute_code._registry)._get_or_create_entry(config).output_dir.name) + + try: + contents = await execute_code.invoke(arguments={"code": "create-memory-output"}) + + _assert_bounded_output_error(contents, "per-file output limit") + remaining_outputs = await asyncio.to_thread(lambda: list(output_root.iterdir())) + assert remaining_outputs == [] + finally: + _close_execute_code_registry(execute_code) + + +async def test_execute_code_tool_preserves_result_when_post_cleanup_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + execute_code = HyperlightExecuteCodeTool(workspace_root=tmp_path) + config = execute_code._build_run_config() + registry = cast(Any, execute_code._registry) + output_root = Path(registry._get_or_create_entry(config).output_dir.name) + original_scandir = os.scandir + output_scan_calls = 0 + + def fail_post_cleanup(path: str | os.PathLike[str]) -> Any: + nonlocal output_scan_calls + if Path(path) == output_root: + output_scan_calls += 1 + if output_scan_calls == 3: + raise PermissionError("simulated cleanup failure") + return original_scandir(path) + + monkeypatch.setattr(execute_code_module.os, "scandir", fail_post_cleanup) + + try: + contents = await execute_code.invoke(arguments={"code": "create-output"}) + + assert any(item.type == "text" and item.text == "done\n" for item in contents) + assert any(item.type == "data" for item in contents) + cleanup_errors = [item for item in contents if item.type == "error"] + assert len(cleanup_errors) == 1 + assert cleanup_errors[0].error_details == "Could not clear sandbox output safely." + assert str(output_root) not in cleanup_errors[0].error_details + assert registry._entries == {} + + recovered = await execute_code.invoke(arguments={"code": "fail"}) + assert not any(item.type == "data" for item in recovered) + assert any(item.type == "error" and item.error_details == "sandbox boom" for item in recovered) + assert len(_FakeSandbox.instances) == 2 + assert registry._entries == {} + finally: + _close_execute_code_registry(execute_code) + + +async def test_cleanup_failed_entry_disposes_directories_without_blocking_result( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + registry = execute_code_module._SandboxRegistry() + execute_code = HyperlightExecuteCodeTool(workspace_root=tmp_path, _registry=registry) + config = execute_code._build_run_config() + entry = registry._get_or_create_entry(config) + assert entry.output_dir is not None + output_root = Path(entry.output_dir.name) + original_scandir = os.scandir + output_scan_calls = 0 + cleanup_started = threading.Event() + release_cleanup = threading.Event() + original_cleanup_temp_dirs = execute_code_module._SandboxEntry.cleanup_temp_dirs + + def fail_post_cleanup(path: str | os.PathLike[str]) -> Any: + nonlocal output_scan_calls + if Path(path) == output_root: + output_scan_calls += 1 + if output_scan_calls == 3: + raise PermissionError("simulated cleanup failure") + return original_scandir(path) + + def blocking_cleanup_temp_dirs(entry_to_clean: Any) -> None: + cleanup_started.set() + if not release_cleanup.wait(timeout=5): + raise TimeoutError("timed out waiting to release cleanup") + original_cleanup_temp_dirs(entry_to_clean) + + monkeypatch.setattr(execute_code_module.os, "scandir", fail_post_cleanup) + monkeypatch.setattr(execute_code_module._SandboxEntry, "cleanup_temp_dirs", blocking_cleanup_temp_dirs) + + try: + contents = await asyncio.wait_for( + execute_code.invoke(arguments={"code": "create-output"}), + timeout=2, + ) + + assert any(item.type == "data" for item in contents) + assert any(item.type == "error" for item in contents) + assert await asyncio.to_thread(cleanup_started.wait, 1) + assert await asyncio.to_thread(output_root.exists) + + release_cleanup.set() + for _ in range(100): + if not await asyncio.to_thread(output_root.exists): + break + await asyncio.sleep(0.01) + assert not await asyncio.to_thread(output_root.exists) + finally: + release_cleanup.set() + _close_execute_code_registry(execute_code) + + +async def test_execute_code_tool_preserves_exception_when_post_cleanup_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + execute_code = HyperlightExecuteCodeTool(workspace_root=tmp_path) + config = execute_code._build_run_config() + registry = cast(Any, execute_code._registry) + output_root = Path(registry._get_or_create_entry(config).output_dir.name) + original_scandir = os.scandir + output_scan_calls = 0 + + def fail_post_cleanup(path: str | os.PathLike[str]) -> Any: + nonlocal output_scan_calls + if Path(path) == output_root: + output_scan_calls += 1 + if output_scan_calls == 2: + raise PermissionError("simulated cleanup failure") + return original_scandir(path) + + def fail_build_contents(**kwargs: Any) -> list[Content]: + del kwargs + raise RuntimeError("primary build failure") + + monkeypatch.setattr(execute_code_module.os, "scandir", fail_post_cleanup) + monkeypatch.setattr(execute_code_module, "_build_execution_contents", fail_build_contents) + + try: + with pytest.raises(RuntimeError, match="primary build failure"): + await execute_code.invoke(arguments={"code": "create-output"}) + assert registry._entries == {} + finally: + _close_execute_code_registry(execute_code) + + async def test_execute_code_tool_checks_output_count_before_reading( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1290,12 +1490,15 @@ async def test_execute_code_tool_streams_directory_enumeration_to_count_limit( execute_code = HyperlightExecuteCodeTool(workspace_root=tmp_path, max_output_files=2) config = execute_code._build_run_config() output_root = Path(cast(Any, execute_code._registry)._get_or_create_entry(config).output_dir.name) - scanned_entries = 0 + scanned_entries: list[int] = [] class _BoundedScandir: def __init__(self, path: str | os.PathLike[str]) -> None: self._entries = original_scandir(path) - self._track = Path(path) == output_root + self._scan_index: int | None = None + if Path(path) == output_root: + self._scan_index = len(scanned_entries) + scanned_entries.append(0) def __enter__(self) -> _BoundedScandir: self._entries.__enter__() @@ -1311,12 +1514,11 @@ def __iter__(self) -> _BoundedScandir: return self def __next__(self) -> os.DirEntry[str]: - nonlocal scanned_entries entry = next(self._entries) - if self._track: - scanned_entries += 1 - if scanned_entries > 3: - pytest.fail("output directory enumeration continued past max_output_files + 1") + if self._scan_index is not None: + scanned_entries[self._scan_index] += 1 + if scanned_entries[self._scan_index] > 3: + pytest.fail("an output directory scan continued past max_output_files + 1") return entry monkeypatch.setattr(execute_code_module.os, "scandir", _BoundedScandir) @@ -1324,13 +1526,56 @@ def __next__(self) -> os.DirEntry[str]: try: contents = await execute_code.invoke(arguments={"code": "create-count-output"}) finally: - monkeypatch.setattr(execute_code_module.os, "scandir", original_scandir) _close_execute_code_registry(execute_code) + monkeypatch.setattr(execute_code_module.os, "scandir", original_scandir) - assert scanned_entries == 3 + assert scanned_entries == [0, 3, 3, 0] _assert_bounded_output_error(contents, "output file count limit") +@pytest.mark.parametrize( + ("code", "error_match"), + [ + ("create-wide-output", "traversal entry limit of 100"), + ("create-deep-output", "nesting depth limit of 32"), + ], +) +async def test_execute_code_tool_invalidates_entry_when_cleanup_exceeds_bounds( + code: str, + error_match: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandboxWithBoundedOutputs) + execute_code = HyperlightExecuteCodeTool(workspace_root=tmp_path, max_output_files=1) + config = execute_code._build_run_config() + registry = cast(Any, execute_code._registry) + original_entry = registry._get_or_create_entry(config) + assert original_entry.output_dir is not None + original_output_root = Path(original_entry.output_dir.name) + + try: + rejected = await execute_code.invoke(arguments={"code": code}) + assert registry._entries == {} + for _ in range(100): + if not await asyncio.to_thread(original_output_root.exists): + break + await asyncio.sleep(0.01) + assert not await asyncio.to_thread(original_output_root.exists) + + recovered = await execute_code.invoke(arguments={"code": "create-memory-output"}) + replacement_output_root = Path(registry._get_or_create_entry(config).output_dir.name) + remaining_after_recovery = await asyncio.to_thread(lambda: list(replacement_output_root.iterdir())) + finally: + _close_execute_code_registry(execute_code) + + _assert_bounded_output_error(rejected, error_match) + assert len(_FakeSandbox.instances) == 2 + assert [_decode_content_bytes(item) for item in recovered if item.type == "data"] == [b"data"] + assert remaining_after_recovery == [] + + async def test_execute_code_tool_reads_only_observed_file_size_with_large_limits( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1684,6 +1929,76 @@ async def test_execute_code_tool_waits_for_unlisted_output_files_to_appear( assert any(item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result) +async def test_execute_code_tool_waits_for_dynamically_addressed_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandboxWithDelayedUnlistedOutput.writer_threads.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandboxWithDelayedUnlistedOutput) + execute_code = HyperlightExecuteCodeTool( + file_mounts=[FileMount(Path(__file__), "fixtures/source.py")], + ) + + try: + result = await execute_code.invoke(arguments={"code": "create-dynamic-delayed-output"}) + + for writer_thread in _FakeSandboxWithDelayedUnlistedOutput.writer_threads: + writer_thread.join() + + assert any( + item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result + ) + next_result = await execute_code.invoke(arguments={"code": "fail"}) + assert not any(item.type == "data" for item in next_result) + finally: + _close_execute_code_registry(execute_code) + + +async def test_execute_code_tool_replaces_output_generation_after_empty_retry_window( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + registry = execute_code_module._SandboxRegistry() + execute_code = HyperlightExecuteCodeTool( + file_mounts=[FileMount(Path(__file__), "fixtures/source.py")], + _registry=registry, + ) + config = execute_code._build_run_config() + first_entry = registry._get_or_create_entry(config) + assert first_entry.output_dir is not None + first_output_root = Path(first_entry.output_dir.name) + cleanup_started = threading.Event() + release_cleanup = threading.Event() + original_cleanup_temp_dirs = execute_code_module._SandboxEntry.cleanup_temp_dirs + + def blocking_cleanup_temp_dirs(entry_to_clean: Any) -> None: + cleanup_started.set() + if not release_cleanup.wait(timeout=5): + raise TimeoutError("timed out waiting to release cleanup") + original_cleanup_temp_dirs(entry_to_clean) + + monkeypatch.setattr(execute_code_module._SandboxEntry, "cleanup_temp_dirs", blocking_cleanup_temp_dirs) + + try: + first_result = await execute_code.invoke(arguments={"code": "None"}) + assert not any(item.type == "data" for item in first_result) + assert await asyncio.to_thread(cleanup_started.wait, 1) + + await asyncio.to_thread( + (first_output_root / "late.txt").write_text, + "late-output", + encoding="utf-8", + ) + second_result = await execute_code.invoke(arguments={"code": "fail"}) + + assert not any(item.type == "data" for item in second_result) + assert any(item.type == "error" and item.error_details == "sandbox boom" for item in second_result) + assert len(_FakeSandbox.instances) == 2 + finally: + release_cleanup.set() + _close_execute_code_registry(execute_code) + + async def test_execute_code_tool_failure_returns_error_content(monkeypatch: pytest.MonkeyPatch) -> None: _FakeSandbox.instances.clear() monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) @@ -1856,8 +2171,8 @@ async def test_output_limits_are_invocation_scoped_when_registry_is_shared( ) try: - rejected = await restrictive_tool.invoke(arguments={"code": "create-growing-output"}) accepted = await permissive_tool.invoke(arguments={"code": "create-growing-output"}) + rejected = await restrictive_tool.invoke(arguments={"code": "create-growing-output"}) finally: registry.close() @@ -2385,7 +2700,7 @@ def close(self) -> None: workspace.mkdir() registry = execute_code_module._SandboxRegistry() execute_code = HyperlightExecuteCodeTool(workspace_root=workspace, _registry=registry) - asyncio.run(execute_code.invoke(arguments={"code": "None"})) + asyncio.run(execute_code.invoke(arguments={"code": "create-output"})) entries = list(registry._entries.values()) assert len(entries) == 1 From 4b4d849602d31471a0fa4721b7d34d40b6e42114 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:28:43 +0000 Subject: [PATCH 050/108] Python: ag-ui improvements (#8279) * Python: preserve policy on AG-UI approval resume Decisions:\n- Build adapter-owned execution middleware in canonical client, Agent, run, and applicable context-provider order without deduplicating configured entries.\n- Keep MiddlewareFailure fatal during approved local execution and expose inner Agent middleware through manual A2UI wrappers.\n\nFiles:\n- Update AG-UI approval execution and A2UI delegation.\n- Add plain, manual A2UI, automatic A2UI, and direct Agent.run policy regressions.\n- Document approval consent versus authorization.\n\nNext iteration:\n- Reuse the effective function middleware preparation for A2UI mixed tool batches in local issue 002.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: 55cc19bf-978f-4ce7-9059-ca4146d77244 * Python: record AG-UI approval enforcement decisions Key decisions: preserve canonical middleware ordering, propagate MiddlewareFailure as fatal, and delegate inner Agent middleware through A2UI wrappers. Files changed: AG-UI approval runner, A2UI agent wrapper, endpoint regressions, and AG-UI package guidance. Next iteration: local issue 002 can reuse the effective function middleware preparation for mixed A2UI tool batches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 55cc19bf-978f-4ce7-9059-ca4146d77244 * Python: enforce Agent policy in A2UI mixed batches Decisions: - Reuse the approval-resume effective function middleware preparation for A2UI server-tool siblings, preserving canonical client, Agent, run, bundle, and context-provider order without deduplication. - Forward function_invocation_kwargs as execution context and keep MiddlewareFailure fatal before rendering. Files: - Update the A2UI mixed-batch executor and shared preparation typing. - Add real endpoint denial controls plus middleware composition, session, and invocation-context regressions. - Document mixed-batch enforcement in AG-UI package guidance. Next iteration: - Local issue 003 can add the invocation-disabled approval-resume gate and safe retry behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef1276d0-4856-4276-84da-379b8d1e5d53 * Python: block disabled AG-UI approval resumes Decisions: - Gate claimed local approvals against the current function-invocation switch before execution and preserve their original authority deadline for explicit retry. - Keep hidden auto-approved siblings server-resumable so releasing a mixed local batch does not fabricate a new client decision requirement. Files: - Update AG-UI approval execution and lifecycle claim recovery. - Add plain, wrapped, snapshot, repeated-block, multi-grant, rejection, cancellation, and retention regressions. - Document disabled-resume behavior in AG-UI package guidance. Blocker / next iteration: - Root-level function-loop mapping and the local issue move remain blocked by the workspace access boundary; local issue 004 follows after those artifacts are reachable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: de26b7f7-490f-46a5-afd6-98d9fb231c3d * Python: preserve disabled mixed approval batches Decisions: - Settle and project valid rejections and cancellations while invocation-disabled grants retain their original pending authority. - Admit retained terminal siblings only for active, distinct occurrences, preserving replacement-generation binding and terminal-only hosted replay behavior. - Reproject completed sibling results without re-executing their effects when an unfinished grant is retried. Files: - Update AG-UI resume normalization, disabled mixed-batch projection, snapshot retirement, and retained-result replay. - Add endpoint regressions for repeated blocked mixed decisions, middleware observation, snapshot state, and completed siblings. - Document mixed disabled-resume behavior in AG-UI package guidance. Blocker / next iteration: - Root-level function-loop scenario mapping and moving local issue 004 to issues/done remain blocked by the workspace access boundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8c59c982-8ccc-4d88-ae9f-b3e16ed7434d * Python: maintenance updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 374ec67a-1a47-42d9-a5f9-70f29be29950 * Python: maintenance follow-up Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 374ec67a-1a47-42d9-a5f9-70f29be29950 * Python: refine integration handling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 374ec67a-1a47-42d9-a5f9-70f29be29950 * Python: refine UI integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 374ec67a-1a47-42d9-a5f9-70f29be29950 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 55cc19bf-978f-4ce7-9059-ca4146d77244 Copilot-Session: ef1276d0-4856-4276-84da-379b8d1e5d53 Copilot-Session: de26b7f7-490f-46a5-afd6-98d9fb231c3d Copilot-Session: 8c59c982-8ccc-4d88-ae9f-b3e16ed7434d Copilot-Session: 374ec67a-1a47-42d9-a5f9-70f29be29950 --- python/packages/ag-ui/AGENTS.md | 37 + python/packages/ag-ui/README.md | 6 + .../agent_framework_ag_ui/_a2ui/_agent.py | 478 ++- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 361 ++- .../_approval_execution.py | 189 ++ .../_approval_lifecycle.py | 69 +- .../packages/ag-ui/tests/ag_ui/test_a2ui.py | 923 +++++- .../tests/ag_ui/test_approval_lifecycle.py | 25 + .../ag-ui/tests/ag_ui/test_endpoint.py | 2576 +++++++++++++---- 9 files changed, 3923 insertions(+), 741 deletions(-) create mode 100644 python/packages/ag-ui/agent_framework_ag_ui/_approval_execution.py diff --git a/python/packages/ag-ui/AGENTS.md b/python/packages/ag-ui/AGENTS.md index 683db67bdda..25233eaf56d 100644 --- a/python/packages/ag-ui/AGENTS.md +++ b/python/packages/ag-ui/AGENTS.md @@ -37,6 +37,43 @@ AG-UI protocol integration for building agent UIs with the AG-UI standard. resolved siblings in the same complete resume still proceed. - Approval-time execution preserves each call's complete result group. Follow-up user-input requests remain in the resumed messages, while `TOOL_CALL_RESULT` events are emitted only for terminal `function_result` contents. +- Approval consent does not bypass policy. Built-in Agents receive validated local approvals through their normal + `Agent.run` path, including Agent middleware, provider `before_run` preparation, and function middleware. + `_approval_execution.py` tracks execution through existing function/chat middleware interfaces; it does not + prepare providers or invoke the tools itself. `MiddlewareFailure` remains fatal. +- Queued approvals follow the wrapped Agent's scheduling. With `ToolApprovalMiddleware`, collected decisions + remain pending until that middleware releases the batch; AG-UI does not execute a tool ahead of it. + Collected server-side grants do not require the client to submit the same approval again. +- A2UI continuation retains the inner response's original model-turn and reasoning/call/result groups. UI + rendering and its accounting remain adapter-owned while server tools use the existing function loop. + Streaming uses an executable adapter handoff tool that returns a private control request through the existing + function-result contract. That request is not a client approval prompt; completing the render clears only its + matching internal pending request. Rendering uses the completed handoff's effective arguments, keyed by + function-call occurrence, rather than the original streamed model arguments. Only privately marked requests + for the adapter's handoff tool are hidden. Unmarked requests remain visible; a pending approval for that tool + suspends rendering in both native and opaque-agent modes. + Session-backed approval flows let core retain implicit siblings without requesting extra user decisions; + stateless flows preserve the approvals core surfaces. A run-scoped control observer is installed once through the + client's function-middleware extension point; its A2UI state is scoped to active stream pulls, preserving other runs' + control outcomes and the original middleware's relative order. + Adapter-side compatibility execution is limited to non-core agents without context providers; unsupported + provider preparation fails explicitly instead of executing with a partial policy. +- Local approval resume checks the current function-invocation configuration before any approved side effect begins. + When invocation is disabled, grants remain pending under their original retention deadline and the endpoint emits + `APPROVAL_INVOCATION_DISABLED`; only a later explicit retry after re-enablement can execute them. Rejections and + cancellations in a mixed resume still settle and retire their snapshot controls while grants remain pending. + Repeating that mixed response or explicitly retrying it after re-enablement reuses retained terminal outcomes + without reviving cancelled/rejected authority or replaying completed tool effects. + Returning early or failing provider preparation releases every unstarted claimed grant, including hosted and + deferred siblings, without extending pending retention or changing the execution owner. The adapter's in-run + middleware rechecks enablement after provider preparation, before beginning any execution. +- Approval claim cleanup covers the complete run, including approval-time failures before streaming starts. + In-run owners begin execution when the function or hosted request reaches its middleware seam. Cleanup releases + unstarted claims without renewing retention and recovers possibly started executions without replaying + uncertain work; settled and cancelled outcomes stay inert. +- Complete resume payloads may include a retained result alongside a distinct pending occurrence that reuses + the provider call id. Match logical function-call occurrences and current request generations, not provider + ids alone; an older request generation for the same occurrence is still invalid. - Approval responses for tools injected during `before_run` are deferred to the in-run approval middleware rather than executed or rejected by the transport before those tools exist. - `_approval_lifecycle.py` is the sole owner of approval occurrence registration, trusted aliases, authority diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md index ee078020569..6130f0c7588 100644 --- a/python/packages/ag-ui/README.md +++ b/python/packages/ag-ui/README.md @@ -211,6 +211,12 @@ argument fields remain supported as partial edits. Cancellation is a normal term not execute, while resolved siblings in the same complete resume continue normally. The same tool-approval shape and resume payloads apply when an agent approval is surfaced through a workflow `request_info` event. +For built-in Agents, validated approvals resume through the normal `Agent.run()` path. Agent middleware still +controls admission and scheduling, and provider and function middleware still apply before tool execution. +For example, `ToolApprovalMiddleware` collects queued decisions before releasing the batch; AG-UI does not +execute an individually approved tool ahead of that middleware. Completed results remain replayable without +repeating tool execution. + ```json { "threadId": "thread-1", diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_a2ui/_agent.py b/python/packages/ag-ui/agent_framework_ag_ui/_a2ui/_agent.py index 6e444b1c880..f725f59c4e5 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_a2ui/_agent.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_a2ui/_agent.py @@ -11,22 +11,26 @@ * **Non-streaming** advertises a REAL ``generate_a2ui`` tool whose body runs the toolkit's synchronous ``run_a2ui_generation_with_recovery``; ordinary automatic function invocation executes it. -* **Streaming** advertises ``generate_a2ui`` as a declaration-only tool (``func=None``) - so the planner's call surfaces on the update stream instead of being auto-invoked. - An agent-level planner-rounds loop then runs the render sub-agent with a streaming - chat call, forwards its per-chunk ``render_a2ui`` argument fragments (progressive - paint), balances the forwarded call with a ``{"status": "rendered"}`` tool result, - feeds the envelope back to the planner, and continues — the same wire shape the - LangGraph adapters produce. MAF-python is async-native, so the streaming loop runs - on one event loop with no worker-thread/queue bridge. +* **Streaming** advertises an executable ``generate_a2ui`` handoff tool. Its body returns + a private marked control after all sibling tools have traversed the normal + function/provider middleware pipeline. The adapter consumes that control, runs the + render sub-agent with a streaming chat call, forwards its per-chunk ``render_a2ui`` + argument fragments (progressive paint), balances the forwarded call with a + ``{"status": "rendered"}`` tool result, feeds the envelope back to the planner, and + continues — the same wire shape the LangGraph adapters produce. MAF-python is + async-native, so the streaming loop runs on one event loop with no worker-thread/queue + bridge. """ from __future__ import annotations import asyncio +import contextvars +import copy import json import logging -from collections.abc import AsyncIterator +import threading +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from typing import Any, cast from ag_ui_a2ui_toolkit import ( @@ -45,7 +49,16 @@ validate_a2ui_components, wrap_error_envelope, ) -from agent_framework import Content, FunctionTool, Message, normalize_messages +from agent_framework import ( + SKIP_PARSING, + Content, + FunctionInvocationContext, + FunctionMiddleware, + FunctionTool, + Message, + normalize_messages, +) +from agent_framework._tools import _FUNCTION_INVOCATION_BUDGET_STATE_KEY # pyright: ignore[reportPrivateUsage] from ._state import to_history_messages @@ -83,6 +96,88 @@ def _recovery_exhausted_envelope(max_attempts: int, attempts: list[dict[str, Any # Cap on planner rounds (model turn -> generation -> result fed back) per run, # guarding against a planner that keeps requesting surfaces without terminating. MAX_PLANNER_ROUNDS = 8 +_A2UI_HANDOFF_MARKER = "_a2ui_adapter_handoff" +_A2UI_TERMINATION_STATE: contextvars.ContextVar[_A2UITerminationState | None] +_A2UI_OBSERVER_REGISTRATION_LOCK = threading.Lock() + + +class _A2UIHandoffControlMiddleware(FunctionMiddleware): + """Force handoff tools through the identity-bearing middleware invocation path.""" + + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + await call_next() + + +class _A2UITerminationState: + """Mutable run-local termination signal shared with copied execution contexts.""" + + def __init__(self) -> None: + self.terminated = False + + +_A2UI_TERMINATION_STATE = contextvars.ContextVar("_a2ui_termination_state", default=None) + + +class _A2UITerminationObserverMiddleware(FunctionMiddleware): + """Observe graceful middleware termination without changing its propagation.""" + + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + from agent_framework._middleware import MiddlewareTermination + + try: + await call_next() + except MiddlewareTermination: + state = _A2UI_TERMINATION_STATE.get() + if state is not None: + state.terminated = True + raise + + +def _ensure_termination_observer(client: Any) -> None: + """Install one transparent outer observer on a function-invocation client.""" + with _A2UI_OBSERVER_REGISTRATION_LOCK: + middleware = getattr(client, "function_middleware", None) + if not isinstance(middleware, list): + return + if any(isinstance(item, _A2UITerminationObserverMiddleware) for item in middleware): + return + middleware.insert(0, _A2UITerminationObserverMiddleware()) + + +class _A2UIExecutionBudget: + """Adapter-owned view of the shared function-invocation budget.""" + + def __init__(self) -> None: + self._state: dict[str, Any] = {} + + def client_kwargs(self, existing: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Install or preserve the core request's existing budget state.""" + client_kwargs = dict(existing) if existing is not None else {} + existing_state = client_kwargs.get(_FUNCTION_INVOCATION_BUDGET_STATE_KEY) + if isinstance(existing_state, dict): + self._state = existing_state + client_kwargs[_FUNCTION_INVOCATION_BUDGET_STATE_KEY] = self._state + return client_kwargs + + @property + def calls_used(self) -> int: + """Return the cumulative calls already charged to the request.""" + value = self._state.get("total_function_calls", 0) + return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0 + + def record_adapter_calls(self, count: int) -> None: + """Charge completed renders or opaque-agent compatibility executions.""" + if count < 0: + raise ValueError("Adapter function-call count cannot be negative.") + self._state["total_function_calls"] = self.calls_used + count def _generate_tool_schema() -> dict[str, Any]: @@ -108,6 +203,14 @@ def _as_tool_list(tools: Any) -> list[Any]: return [tools] +def _as_middleware_list(middleware: Any) -> list[Any]: + if middleware is None: + return [] + if isinstance(middleware, (list, tuple)): + return list(middleware) + return [middleware] + + def _string_arg(args: dict[str, Any], name: str) -> str | None: value = args.get(name) return value if isinstance(value, str) else None @@ -351,6 +454,10 @@ def default_options(self) -> Any: def context_providers(self) -> Any: return getattr(self.inner_agent, "context_providers", []) + @property + def middleware(self) -> Any: + return getattr(self.inner_agent, "middleware", None) + @property def service_session_state_keys(self) -> Any: return getattr(self.inner_agent, "service_session_state_keys", ()) @@ -374,11 +481,18 @@ def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> A context_slice = kwargs.pop("a2ui_context", None) if context_slice is None: context_slice = self._context_slice - messages = self._with_context_prompt(messages, context_slice) + messages = self._prepare_run_messages(messages, a2ui_context=context_slice) if stream: return self._run_streaming(messages, context_slice, **kwargs) return self._run_non_streaming(messages, context_slice, **kwargs) + def _prepare_run_messages( + self, messages: Any = None, *, a2ui_context: dict[str, Any] | None = None + ) -> list[Message]: + """Prepare one A2UI request's model input.""" + context_slice = self._context_slice if a2ui_context is None else a2ui_context + return _sanitize_unanswered_tool_calls(self._with_context_prompt(messages, context_slice)) + def _with_context_prompt(self, messages: Any, context_slice: dict[str, Any]) -> list[Message]: """Prepend the forwarded component catalog + guidelines as a system message.""" normalized = normalize_messages(messages) @@ -488,6 +602,76 @@ def _invoke(prompt: str, attempt: int) -> dict[str, Any] | None: input_model=_generate_tool_schema(), ) + def _build_generate_handoff_tool(self) -> FunctionTool: + """Build the streaming tool that hands completed planner calls back to this adapter.""" + + async def _handoff_generate_a2ui( + intent: str | None = None, + target_surface_id: str | None = None, + changes: str | None = None, + context: FunctionInvocationContext | None = None, + ) -> Content: + arguments = { + name: value + for name, value in { + "intent": intent, + "target_surface_id": target_surface_id, + "changes": changes, + }.items() + if value is not None + } + if context is not None: + if isinstance(context.arguments, Mapping): + arguments = dict(context.arguments) + else: + arguments = context.arguments.model_dump(exclude_none=True, exclude_unset=True) + call_id = context.metadata.get("call_id") if context is not None else None + occurrence_id = context.metadata.get("function_call_occurrence_id") if context is not None else None + if not isinstance(call_id, str) or not call_id: + raise RuntimeError("A2UI handoff requires function-call identity metadata.") + occurrence_id = occurrence_id if isinstance(occurrence_id, str) and occurrence_id else call_id + function_call = Content.from_function_call( + call_id=call_id, + name=context.function.name if context is not None else self.params["tool_name"], + arguments=arguments, + id=occurrence_id, + ) + function_call.additional_properties[_A2UI_HANDOFF_MARKER] = True + return Content.from_function_approval_request( + id=occurrence_id, + function_call=function_call, + additional_properties={_A2UI_HANDOFF_MARKER: True}, + ) + + return FunctionTool( + name=self.params["tool_name"], + description=self.params["tool_description"], + func=_handoff_generate_a2ui, + input_model=_generate_tool_schema(), + result_parser=SKIP_PARSING, + ) + + @staticmethod + def _clear_internal_handoff_requests(session: Any, request_ids: set[str]) -> None: + """Remove only completed private A2UI handoffs from core pending snapshots.""" + if session is None or not request_ids: + return + from agent_framework._tools import ( # pyright: ignore[reportPrivateUsage] + _load_pending_approval_requests, + _save_pending_approval_requests, + ) + + pending = _load_pending_approval_requests(session) + changed = False + for request_id in request_ids: + request = pending.get(request_id) + if request is None or request.additional_properties.get(_A2UI_HANDOFF_MARKER) is not True: + continue + pending.pop(request_id) + changed = True + if changed: + _save_pending_approval_requests(session, pending) + def _inner_default_tools(self) -> list[Any]: """The inner agent's own configured tools (``default_options["tools"]``). @@ -511,13 +695,8 @@ async def _execute_server_tools( ) -> tuple[list[Any], list[Any], bool]: """Execute server tools called alongside generate_a2ui through the shared executor. - Runs them via the framework's function-invocation helper with the run's ``session``, - the run ``config``, and a middleware pipeline built from the client's static function - middleware plus the runtime ``middleware`` (bare objects and MiddlewareBundles - normalized/expanded via ``categorize_middleware``, so a bundle's function middleware - is applied, not skipped) — the same helper AG-UI approval resume uses, so - authorization/audit/policy middleware, the session, and approval controls all apply. - Kept here in the adapter (not behind a new core abstraction). Returns + Compatibility execution for non-core agents without context providers. + Core Agents execute their own siblings inside the prepared function loop. Returns ``(results, control, should_terminate)``; ``control`` are non-result contents (e.g. a ``function_approval_request``) that must reach the client. A ``MiddlewareFailure`` (the fail-closed escape an authorization/guardrail middleware raises) propagates, @@ -529,11 +708,17 @@ async def _execute_server_tools( from agent_framework._middleware import FunctionMiddlewarePipeline, MiddlewareFailure, categorize_middleware from agent_framework._tools import _try_execute_function_call_groups - client = getattr(self.inner_agent, "client", None) - runtime_middleware = run_kwargs.get("middleware") - runtime_fn_mw = categorize_middleware(runtime_middleware)["function"] if runtime_middleware is not None else [] - pipeline = FunctionMiddlewarePipeline(*(getattr(client, "function_middleware", None) or ()), *runtime_fn_mw) - custom_args = {k: v for k, v in run_kwargs.items() if k not in ("options", "middleware")} + if getattr(self.inner_agent, "context_providers", None): + raise TypeError("A2UI server tools with context providers require a core Agent execution path.") + pipeline = FunctionMiddlewarePipeline( + *categorize_middleware( + getattr(getattr(self.inner_agent, "client", None), "function_middleware", None), + getattr(self.inner_agent, "middleware", None), + run_kwargs.get("middleware"), + )["function"] + ) + invocation_kwargs = run_kwargs.get("function_invocation_kwargs") + custom_args = dict(cast(Mapping[str, Any], invocation_kwargs)) if invocation_kwargs is not None else {} try: groups, should_terminate = await _try_execute_function_call_groups( custom_args=custom_args, @@ -587,7 +772,11 @@ async def _run_streaming( self, messages: Any = None, context_slice: dict[str, Any] | None = None, **kwargs: Any ) -> Any: from agent_framework import AgentResponseUpdate - from agent_framework._tools import normalize_function_invocation_configuration + from agent_framework._agents import RawAgent + from agent_framework._tools import ( + FunctionInvocationLayer, + normalize_function_invocation_configuration, + ) history = _sanitize_unanswered_tool_calls(normalize_messages(messages)) session = kwargs.pop("session", None) @@ -595,12 +784,7 @@ async def _run_streaming( t for t in _as_tool_list(kwargs.pop("tools", None)) if getattr(t, "name", None) != self.params["tool_name"] ] state = {"ag-ui": context_slice or {}} - generate_decl = FunctionTool( - name=self.params["tool_name"], - description=self.params["tool_description"], - func=None, - input_model=_generate_tool_schema(), - ) + generate_tool = self._build_generate_handoff_tool() # Honor the inner agent's function-invocation configuration locally so this loop # behaves like the core loop: the invocation toggle, the cumulative call budget @@ -615,6 +799,17 @@ async def _run_streaming( max_iters = fi_config.get("max_iterations") max_rounds = MAX_PLANNER_ROUNDS if max_iters is None else min(MAX_PLANNER_ROUNDS, max_iters) calls_used = 0 + core_execution = isinstance(self.inner_agent, RawAgent) and isinstance( + self.inner_agent.client, FunctionInvocationLayer + ) + execution_budget = _A2UIExecutionBudget() + if core_execution: + _ensure_termination_observer(self.inner_agent.client) + kwargs["client_kwargs"] = execution_budget.client_kwargs(kwargs.get("client_kwargs")) + kwargs["middleware"] = [ + *_as_middleware_list(kwargs.get("middleware")), + _A2UIHandoffControlMiddleware(), + ] pending: list[Any] = history pending_session = session @@ -640,61 +835,126 @@ async def _run_streaming( all_concat: dict[str, str] = {} cid_by_index: dict[int, str] = {} active_cid: str | None = None - async for update in self.inner_agent.run( + core_results: list[Content] = [] + core_control: list[Content] = [] + handoff_call_ids: set[str] = set() + handoff_calls: dict[str, Content] = {} + handoff_request_ids: set[str] = set() + application_approval_pending = False + termination_state = _A2UITerminationState() + inner_stream = self.inner_agent.run( pending, stream=True, session=pending_session, - tools=[*incoming_tools, generate_decl], + tools=[*incoming_tools, generate_tool], **kwargs, - ): - for content in update.contents: - ctype = getattr(content, "type", None) - if ctype == "text": - text_contents.append(content) - continue - if ctype != "function_call": - continue - name = getattr(content, "name", None) - cid = getattr(content, "call_id", None) - raw = getattr(content, "arguments", None) - frag = raw if isinstance(raw, str) else (json.dumps(raw) if isinstance(raw, dict) else "") - idx = _tool_call_index(content) - if cid: - # Opening or coalesced fragment of some call. - if cid not in all_concat: - call_order.append(cid) - named_concat[cid] = "" - all_concat[cid] = "" - if name: - name_by_cid[cid] = name - named_concat[cid] += frag - if idx is not None: - cid_by_index[idx] = cid - active_cid = cid - all_concat[cid] += frag - else: - # Nameless continuation delta: attribute by index if known, else - # to the most recently opened call. - target = cid_by_index.get(idx) if idx is not None else None - target = target or active_cid - if target is not None: - all_concat[target] += frag - yield update - - # Classify the coalesced calls. The declaration-only generate_a2ui poisons - # the inner agent's batch invocation (a batch with any declaration-only call - # is paused before execution), so tools called alongside it are NOT run by the - # inner agent — handle them here by TYPE: - # * generate_a2ui -> stream the surface (below). - # * server tool (executable FunctionTool, found across incoming_tools OR the - # inner agent's default tools) -> execute through the agent's real - # function-invocation pipeline so middleware/config are preserved. - # * client / declaration-only tool (func=None, browser-side) -> leave as a - # user-input request for the frontend to execute and resume; do NOT - # synthesize a result (that would break the resumable client-tool flow). + ) + token: contextvars.Token[_A2UITerminationState | None] | None = ( + _A2UI_TERMINATION_STATE.set(termination_state) if core_execution else None + ) + try: + async for update in inner_stream: + visible_contents: list[Content] = [] + for content in update.contents: + handoff_call = content.function_call if content.type == "function_approval_request" else content + if ( + handoff_call is not None + and handoff_call.name == self.params["tool_name"] + and ( + content.additional_properties.get(_A2UI_HANDOFF_MARKER) is True + or handoff_call.additional_properties.get(_A2UI_HANDOFF_MARKER) is True + ) + ): + if content.id: + handoff_request_ids.add(content.id) + if handoff_call.call_id: + handoff_call_ids.add(handoff_call.call_id) + completed_call = copy.deepcopy(handoff_call) + completed_call.additional_properties.pop(_A2UI_HANDOFF_MARKER, None) + handoff_calls[handoff_call.id or handoff_call.call_id] = completed_call + if handoff_call.call_id not in all_concat: + call_order.append(handoff_call.call_id) + name_by_cid[handoff_call.call_id] = handoff_call.name or self.params["tool_name"] + raw_arguments = handoff_call.arguments + handoff_arguments = ( + raw_arguments + if isinstance(raw_arguments, str) + else json.dumps(raw_arguments) + if isinstance(raw_arguments, dict) + else "" + ) + named_concat[handoff_call.call_id] = handoff_arguments + all_concat[handoff_call.call_id] = handoff_arguments + continue + if ( + content.type == "function_approval_request" + and content.function_call is not None + and content.function_call.name == self.params["tool_name"] + ): + application_approval_pending = True + visible_contents.append(content) + ctype = getattr(content, "type", None) + if core_execution: + if ctype == "function_result": + core_results.append(content) + elif ctype != "function_call" and content.user_input_request: + core_control.append(content) + if ctype == "text": + text_contents.append(content) + continue + if ctype != "function_call": + continue + name = getattr(content, "name", None) + cid = getattr(content, "call_id", None) + raw = getattr(content, "arguments", None) + frag = raw if isinstance(raw, str) else (json.dumps(raw) if isinstance(raw, dict) else "") + idx = _tool_call_index(content) + if cid: + # Opening or coalesced fragment of some call. + if cid not in all_concat: + call_order.append(cid) + named_concat[cid] = "" + all_concat[cid] = "" + if name: + name_by_cid[cid] = name + named_concat[cid] += frag + if idx is not None: + cid_by_index[idx] = cid + active_cid = cid + all_concat[cid] += frag + else: + # Nameless continuation delta: attribute by index if known, else + # to the most recently opened call. + target = cid_by_index.get(idx) if idx is not None else None + target = target or active_cid + if target is not None: + all_concat[target] += frag + update.contents = visible_contents + if visible_contents: + if token is not None: + _A2UI_TERMINATION_STATE.reset(token) + token = None + try: + yield update + finally: + if core_execution: + token = _A2UI_TERMINATION_STATE.set(termination_state) + inner_response_messages: list[Message] | None = None + if core_execution: + inner_response = await inner_stream.get_final_response() + inner_response_messages = list(inner_response.messages) + finally: + if token is not None: + _A2UI_TERMINATION_STATE.reset(token) + + # Core Agents already executed server siblings and the adapter handoff using + # their prepared provider context. Opaque agents use the compatibility executor + # below and must not have unprepared context providers. executable_tools = [*incoming_tools, *self._inner_default_tools()] tool_by_name = {getattr(t, "name", None): t for t in executable_tools} - generate_calls: list[Content] = [] + core_result_ids = {result.call_id for result in core_results} + # Completed handoffs carry the invocation arguments after function middleware. + generate_calls: list[Content] = list(handoff_calls.values()) if core_execution else [] server_calls: list[Content] = [] client_calls: list[Content] = [] for cid in call_order: @@ -703,6 +963,11 @@ async def _run_streaming( continue obj = _first_parsable_object(named_concat[cid], all_concat[cid]) if nm == self.params["tool_name"]: + if core_execution: + continue + if cid in handoff_call_ids: + generate_calls.extend(call for call in handoff_calls.values() if call.call_id == cid) + continue generate_calls.append( Content.from_function_call( call_id=cid, name=nm, arguments=json.dumps(obj) if obj is not None else "" @@ -712,7 +977,7 @@ async def _run_streaming( args_str = json.dumps(obj) if isinstance(obj, dict) else (all_concat[cid] or "{}") call = Content.from_function_call(call_id=cid, name=nm, arguments=args_str) tool = tool_by_name.get(nm) - if tool is not None and getattr(tool, "func", None) is not None: + if cid in core_result_ids or (tool is not None and getattr(tool, "func", None) is not None): server_calls.append(call) else: call.user_input_request = True @@ -720,20 +985,29 @@ async def _run_streaming( client_calls.append(call) if not generate_calls: - # No surface requested this round. A batch WITHOUT a declaration-only call - # is not poisoned, so the inner agent already executed/surfaced its calls - # and looped; nothing more to do here. + # No completed adapter handoff reached us. The inner agent either stopped + # requesting a surface, paused for external input/approval, or invocation + # was disabled/limited before generate_a2ui could execute. + return + if application_approval_pending: + return + if core_execution and termination_state.terminated: + return + if core_execution and core_control: return # Execute server tools batched with generate_a2ui, honoring the invocation # toggle + the shared per-request call budget locally. Calls we must not run # (invocation disabled, or over budget) are deferred (left unexecuted) and force # the run to stop below rather than be fabricated. - server_results: list[Content] = [] + server_results: list[Content] = core_results server_control: list[Content] = [] server_terminated = False deferred_calls: list[Content] = [] - if server_calls: + if core_execution: + calls_used = execution_budget.calls_used + deferred_calls = [call for call in server_calls if call.call_id not in core_result_ids] + elif server_calls: runnable = server_calls if fi_enabled else [] if max_calls is not None: runnable = runnable[: max(0, max_calls - calls_used)] @@ -742,7 +1016,8 @@ async def _run_streaming( server_results, server_control, server_terminated = await self._execute_server_tools( runnable, executable_tools, session, fi_config, kwargs ) - calls_used += len(runnable) + execution_budget.record_adapter_calls(len(runnable)) + calls_used = execution_budget.calls_used # generate_a2ui also charges the call budget — each is a render-subagent # invocation — so a generate-only planner cannot exceed max_function_calls @@ -755,7 +1030,8 @@ async def _run_streaming( allowed = max(0, max_calls - calls_used) deferred_generate = generate_calls[allowed:] generate_calls = generate_calls[:allowed] - calls_used += len(generate_calls) + execution_budget.record_adapter_calls(len(generate_calls)) + calls_used = execution_budget.calls_used generate_results: list[Content] = [] for call in generate_calls: @@ -763,12 +1039,13 @@ async def _run_streaming( async for update in self._run_generate_streaming(call, history, state, box): yield update generate_results.append(Content.from_function_result(call_id=call.call_id or "", result=box[0])) + self._clear_internal_handoff_requests(session, handoff_request_ids) all_results = [*server_results, *generate_results] # Surface tool results on the wire; also surface any executor control contents # (e.g. a function_approval_request for a protected tool) so they are not dropped. - yield AgentResponseUpdate(role="tool", contents=all_results) - if server_control: + yield AgentResponseUpdate(role="tool", contents=generate_results if core_execution else all_results) + if server_control and not core_execution: yield AgentResponseUpdate(role="assistant", contents=server_control) # Two ways this turn ends the loop. Calls awaiting EXTERNAL resolution — client @@ -784,12 +1061,23 @@ async def _run_streaming( # Record this round's assistant call(s) + results BEFORE deciding to stop, so # the tools-off final narration below sees the surface this turn just produced # (otherwise it receives only the original user messages and cannot narrate it). - assistant_contents = [*text_contents, *server_calls, *generate_calls] - history = [ - *history, - Message(role="assistant", contents=assistant_contents), - Message(role="tool", contents=all_results), - ] + if inner_response_messages is not None: + completed_messages = copy.deepcopy(inner_response_messages) + if ( + completed_messages + and getattr(completed_messages[-1].role, "value", completed_messages[-1].role) == "tool" + ): + completed_messages[-1].contents.extend(generate_results) + else: + completed_messages.append(Message(role="tool", contents=generate_results)) + history = [*history, *completed_messages] + else: + assistant_contents = [*text_contents, *server_calls, *generate_calls] + history = [ + *history, + Message(role="assistant", contents=assistant_contents), + Message(role="tool", contents=all_results), + ] budget_exhausted = max_calls is not None and calls_used >= max_calls if budget_exhausted: diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index e6ecb54a82d..77d900f5228 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -40,10 +40,14 @@ ) from agent_framework._middleware import ( FunctionMiddlewarePipeline, - _as_middleware_list, # pyright: ignore[reportPrivateUsage] + MiddlewareFailure, categorize_middleware, ) +from agent_framework._agents import ( + RawAgent, +) from agent_framework._tools import ( + FunctionInvocationLayer, _ALREADY_APPROVED_APPROVAL_REQUEST_GROUPS_KEY, # type: ignore _APPROVAL_REQUEST_ID_KEY, # pyright: ignore[reportPrivateUsage] _collect_approval_responses, # type: ignore @@ -77,6 +81,12 @@ LocalPendingToolTransitionOwner, ResumeDecision, ) +from ._approval_execution import ( + COLLECTED_APPROVAL_OCCURRENCE_KEY, + ApprovalInvocationDisabledError, + InRunApprovalExecution, + InRunPendingToolTransitionOwner, +) from ._approval_state import _APPROVAL_SCOPE_INPUT_KEY, InMemoryAGUIApprovalStateStore, approval_state_thread_id from ._message_adapters import _APPROVAL_DECISION_IS_BOOLEAN_KEY, normalize_agui_input_messages from ._predictive_state import PredictiveStateHandler @@ -894,16 +904,28 @@ def _register_server_generated_approval_response( """Register a server-owned approval response so normal validation can consume it.""" if response.function_call is None or not response.function_call.name: return None - response_id = response.id or response.function_call.call_id + response_id = ( + response.additional_properties.get(_APPROVAL_REQUEST_ID_KEY) or response.id or response.function_call.call_id + ) if not response_id: return None + retained_occurrence_id = response.additional_properties.get(COLLECTED_APPROVAL_OCCURRENCE_KEY) + if retained_occurrence_id is not None: + retained = lifecycle.occurrence_for_alias(thread_id=thread_id, interrupt_id=str(response_id)) + if ( + retained is None + or retained.identity.occurrence_id != retained_occurrence_id + or retained.status is not ApprovalStatus.PENDING + ): + raise ValueError("Collected approval authority is no longer pending.") + response.id = str(response_id) execution_owner = _function_call_execution_owner( response.function_call, tools, has_deferred_owner=has_deferred_owner, ) arguments = canonical_function_arguments(response.function_call) or "{}" - lifecycle.register( + occurrence = lifecycle.register( owner=execution_owner, scope=approval_scope, thread_ids=[thread_id], @@ -915,7 +937,9 @@ def _register_server_generated_approval_response( aliases=[str(response.function_call.call_id)] if response.function_call.call_id else None, response_id=str(response_id), server_label=_function_call_server_label(response.function_call), + requires_client_resume=False, ) + response.additional_properties[COLLECTED_APPROVAL_OCCURRENCE_KEY] = occurrence.identity.occurrence_id if response.approved is not True: batch = lifecycle.claim_batch( thread_id=thread_id, @@ -958,8 +982,9 @@ def _pop_collected_tool_approval_response_messages( lifecycle: ApprovalLifecycle, authorized_executions: dict[ApprovalOccurrenceIdentity, AuthorizedExecution] | None = None, approval_scope: str | None = None, + consume: bool = True, ) -> list[Message]: - """Pop server-collected auto-approved responses into provider-visible messages.""" + """Load server-collected responses, optionally consuming them for transport-owned execution.""" raw_state = session.state.get(_TOOL_APPROVAL_STATE_KEY) if not isinstance(raw_state, Mapping): return [] @@ -986,7 +1011,10 @@ def _pop_collected_tool_approval_response_messages( authorized_executions[intent.identity] = intent responses.append(response) - state[_COLLECTED_APPROVAL_RESPONSES_KEY] = [] + if consume: + state[_COLLECTED_APPROVAL_RESPONSES_KEY] = [] + else: + state[_COLLECTED_APPROVAL_RESPONSES_KEY] = [response.to_dict() for response in responses] session.state[_TOOL_APPROVAL_STATE_KEY] = state if not responses: return [] @@ -1245,8 +1273,29 @@ def _canonical_approval_resume_messages( messages: list[dict[str, Any]] = [] handled_ids: set[str] = set() cancelled_ids: set[str] = set() - pending_interrupt_ids = lifecycle.pending_interrupt_ids(thread_id=thread_id) - contract_interrupt_ids = expected_ids | pending_interrupt_ids + pending_interrupt_ids = lifecycle.pending_client_resume_interrupt_ids(thread_id=thread_id) + pending_occurrence_ids = { + occurrence.function_call_id + for interrupt_id in pending_interrupt_ids + if (occurrence := lifecycle.pending_occurrence(thread_id=thread_id, interrupt_id=interrupt_id)) is not None + } + retained_interrupt_ids: set[str] = set() + for interrupt in _normalize_resume_interrupts(resume_payload): + interrupt_id = str(interrupt["id"]) + occurrence = lifecycle.occurrence_for_alias(thread_id=thread_id, interrupt_id=interrupt_id) + if ( + occurrence is not None + and occurrence.status.is_terminal + and ( + pending_interrupt_ids + or expected_ids + or occurrence.status in {ApprovalStatus.SETTLED, ApprovalStatus.REJECTED, ApprovalStatus.CANCELLED} + ) + and occurrence.active_interrupt_id == interrupt_id + and occurrence.function_call_id not in pending_occurrence_ids + ): + retained_interrupt_ids.add(interrupt_id) + contract_interrupt_ids = expected_ids | pending_interrupt_ids | retained_interrupt_ids if not contract_interrupt_ids: if _resume_payload_has_approval_decision(resume_payload): normalized_interrupts = _normalize_resume_interrupts(resume_payload) @@ -1303,7 +1352,7 @@ def _canonical_approval_resume_messages( server_label=occurrence.server_label if occurrence is not None else None, ) if validation_error is not None: - return [], handled_ids, cancelled_ids, validation_error + return [], set(), set(), validation_error if accepted is None or canonical_arguments is None: break decisions.append( @@ -1362,7 +1411,7 @@ def _canonical_approval_resume_messages( for entry in entries: interrupt_id = cast(str, entry["interrupt_id"]) status = entry["status"] - pending_entry = lifecycle.pending_occurrence(thread_id=thread_id, interrupt_id=interrupt_id) + pending_entry = lifecycle.occurrence_for_alias(thread_id=thread_id, interrupt_id=interrupt_id) if pending_entry is None: if status == "cancelled" and interrupt_id in expected_ids: handled_ids.add(interrupt_id) @@ -1406,14 +1455,23 @@ def _canonical_approval_resume_messages( continue pending_arguments = _pending_approval_arguments(pending_entry) + original_arguments = ( + pending_entry.decision.original_arguments + if pending_entry.status.is_terminal + and pending_entry.decision is not None + and pending_entry.decision.original_arguments is not None + else pending_arguments + ) accepted, canonical_arguments, merged_arguments, validation_error = _canonical_approval_decision( entry.get("payload"), interrupt_id=interrupt_id, - original_arguments_text=pending_arguments, + original_arguments_text=original_arguments, server_label=_pending_approval_server_label(pending_entry), ) if validation_error is not None: - return [], handled_ids, cancelled_ids, validation_error + if pending_interrupt_ids or expected_ids: + return [], handled_ids, cancelled_ids, validation_error + return [], set(), set(), validation_error if accepted is None or canonical_arguments is None or merged_arguments is None: raise RuntimeError("Validated approval decision is missing canonical values.") lifecycle_decisions.append( @@ -1422,7 +1480,7 @@ def _canonical_approval_resume_messages( accepted=accepted, arguments=canonical_arguments, name=_pending_approval_name(pending_entry), - original_arguments=pending_arguments, + original_arguments=original_arguments, ) ) response_id = pending_entry.response_id or interrupt_id @@ -1458,18 +1516,30 @@ def _canonical_approval_resume_messages( tools, has_deferred_owner=has_deferred_owner, ) - lifecycle.register( - owner=execution_owner, - scope=approval_scope, - thread_ids=[thread_id], - interrupt_id=sibling_interrupt_id, - call_id=sibling_call_id, - name=function_call.name, - arguments=sibling_arguments, - function_call_id=function_call.id, - response_id=str(response_id), - server_label=_function_call_server_label(function_call), - ) + sibling_occurrence_id = function_call.id or sibling_interrupt_id + existing_siblings = [ + occurrence + for occurrence in lifecycle.occurrences_for_thread(thread_id=thread_id) + if occurrence.function_call_id == sibling_occurrence_id + ] + if len(existing_siblings) > 1: + raise ValueError("An already-approved sibling has ambiguous occurrence identity.") + if not existing_siblings: + if pending_entry.status.is_terminal: + raise ValueError("Retained sibling approval authority is no longer available.") + lifecycle.register( + owner=execution_owner, + scope=approval_scope, + thread_ids=[thread_id], + interrupt_id=sibling_interrupt_id, + call_id=sibling_call_id, + name=function_call.name, + arguments=sibling_arguments, + function_call_id=function_call.id, + response_id=str(response_id), + server_label=_function_call_server_label(function_call), + requires_client_resume=False, + ) lifecycle_decisions.append( ResumeDecision( interrupt_id=sibling_interrupt_id, @@ -1508,6 +1578,21 @@ def _canonical_approval_resume_messages( ) if snapshot_reconciliations is not None: snapshot_reconciliations.extend(intents.snapshot_reconciliations) + if retained_results is not None: + for outcome in intents.retained_outcomes: + if outcome.snapshot_reconciliation.status is ApprovalStatus.SETTLED: + retained_results.extend(result.content for result in outcome.replayable_results) + retained_response_ids = { + lifecycle.get(outcome.identity).response_id or outcome.identity.interrupt_id + for outcome in intents.retained_outcomes + } + for message in messages: + message["function_approvals"] = [ + approval + for approval in message["function_approvals"] + if approval["id"] not in retained_response_ids + ] + messages[:] = [message for message in messages if message["function_approvals"]] for intent in intents: authorized_executions[intent.identity] = intent except (KeyError, ValueError) as exc: @@ -1533,21 +1618,22 @@ def _canonical_approval_resume_messages( return messages, handled_ids, cancelled_ids, None -def _approval_resolution_middleware_pipeline( +def _approval_observer_middleware_pipeline( agent: SupportsAgentRun, session: AgentSession, + run_middleware: Any = None, ) -> FunctionMiddlewarePipeline: - """Build the function middleware used for authenticated approval resolution.""" + """Build approval observers; core execution uses middleware from public provider preparation.""" client = getattr(agent, "client", None) - configured_middleware: list[Any] = [ - *getattr(client, "function_middleware", ()), - *_as_middleware_list(getattr(agent, "middleware", None)), - ] - function_middleware = categorize_middleware(configured_middleware)["function"] + function_middleware = categorize_middleware( + getattr(client, "function_middleware", None), + getattr(agent, "middleware", None), + run_middleware, + )["function"] for provider in cast(list[Any], getattr(agent, "context_providers", [])): provider_middleware = getattr(provider, "_function_middleware_for_approval_resolution", None) if callable(provider_middleware): - function_middleware.extend(cast("Sequence[Any]", provider_middleware(session))) + function_middleware.extend(categorize_middleware(cast(Any, provider_middleware(session)))["function"]) return FunctionMiddlewarePipeline(*function_middleware) @@ -1588,6 +1674,8 @@ async def _resolve_approval_responses( *, lifecycle: ApprovalLifecycle, middleware_pipeline: FunctionMiddlewarePipeline, + in_run_execution: InRunApprovalExecution | None = None, + additional_approval_messages: Sequence[Message] = (), authorized_executions: dict[ApprovalOccurrenceIdentity, AuthorizedExecution] | None = None, forwarded_executions: ( dict[str, list[tuple[ForwardedPendingToolTransitionOwner, AuthorizedExecution, Content]]] | None @@ -1617,7 +1705,7 @@ async def _resolve_approval_responses( """ approval_responses: list[Content] = [] responses_by_id: dict[str, list[Content]] = {} - for message in messages: + for message in [*additional_approval_messages, *messages]: for content in message.contents: if content.type == "function_approval_response" and content.id is not None: approval_responses.append(content) @@ -1740,16 +1828,26 @@ async def _resolve_approval_responses( continue intents_by_response_content_id[id(primary_response)] = intent valid_response_content_ids.add(id(primary_response)) - if pending_entry.owner is ApprovalExecutionOwner.DEFERRED: + if pending_entry.owner is ApprovalExecutionOwner.DEFERRED or ( + in_run_execution is not None + and pending_entry.owner is ApprovalExecutionOwner.LOCAL + and primary_response.approved is True + ): deferred_response_content_ids.add(id(primary_response)) if intent is not None and ( intent.owner is ApprovalExecutionOwner.DEFERRED or (primary_response.approved is True and intent.owner is ApprovalExecutionOwner.HOSTED) + or (in_run_execution is not None and intent.owner is ApprovalExecutionOwner.LOCAL) ): validated_forwarded_approvals.append(primary_response) if not server_label: pending_local_response_content_ids.add(id(primary_response)) - if validated_approved_responses is not None and primary_response.approved is True and not server_label: + if ( + validated_approved_responses is not None + and primary_response.approved is True + and not server_label + and in_run_execution is None + ): validated_approved_responses.append(primary_response) if authenticated_non_grants: @@ -1798,16 +1896,16 @@ async def _resolve_approval_responses( async def forward_hosted_decision(approval: Content = approval) -> list[Content]: return [approval] - if intent.owner is ApprovalExecutionOwner.HOSTED: + if in_run_execution is not None: + in_run_execution.register(intent, approval) + forwarded_owner = InRunPendingToolTransitionOwner(forward_hosted_decision, owner=intent.owner) + elif intent.owner is ApprovalExecutionOwner.HOSTED: forwarded_owner: ForwardedPendingToolTransitionOwner = HostedPendingToolTransitionOwner( forward_hosted_decision ) else: forwarded_owner = DeferredPendingToolTransitionOwner(forward_hosted_decision) - forwarded = await forwarded_owner.forward(intent, lifecycle=lifecycle) - if len(forwarded) != 1: - raise RuntimeError("Hosted transition owner did not forward exactly one approval decision.") - forwarded_executions.setdefault(call_id, []).append((forwarded_owner, intent, forwarded[0])) + forwarded_executions.setdefault(call_id, []).append((forwarded_owner, intent, approval)) fcc_todo = _collect_approval_responses(messages) if valid_response_content_ids is not None: @@ -1836,6 +1934,8 @@ async def forward_hosted_decision(approval: Content = approval) -> list[Content] # Execute lifecycle-authorized local calls only through their transition owner. if static_approved and tools and lifecycle is not None and authorized_executions is not None: + if getattr(agent, "context_providers", None): + raise TypeError("Local approval execution with context providers requires a core Agent execution path.") client = getattr(agent, "client", None) config = normalize_function_invocation_configuration(getattr(client, "function_invocation_configuration", None)) tool_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"} @@ -1858,6 +1958,8 @@ async def execute_local_call(approval: Content = approval, call_id: str = call_i config=config, invocation_session=invocation_session, ) + except MiddlewareFailure: + raise except Exception as exc: logger.exception("Failed to execute approved tool call; injecting error result: %s", exc) return [Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.")] @@ -1897,7 +1999,15 @@ async def execute_local_call(approval: Content = approval, call_id: str = call_i replacement_groups.append(result_group) approved_results.extend(content for content in result_group if content.type == "function_result") - _replace_approval_contents_with_results(messages, fcc_todo, replacement_groups) + normalization_responses = dict(fcc_todo) + normalization_responses.update( + { + response_id: response + for response_id, response in _collect_approval_responses(messages).items() + if id(response) in deferred_response_content_ids + } + ) + _replace_approval_contents_with_results(messages, normalization_responses, replacement_groups) # Post-process: Convert user messages with function_result content to proper tool messages. # After _replace_approval_contents_with_results, approved tool calls have their results @@ -2574,6 +2684,34 @@ async def run_agent_stream( Yields: AG-UI events """ + state_store = approval_state_store if approval_state_store is not None else InMemoryAGUIApprovalStateStore() + authorized_executions: dict[ApprovalOccurrenceIdentity, AuthorizedExecution] = {} + stream = _run_agent_stream( + input_data, + agent, + config, + state_store, + authorized_executions=authorized_executions, + ) + try: + async for event in stream: + yield event + finally: + try: + await stream.aclose() + finally: + for intent in authorized_executions.values(): + state_store.lifecycle.recover_unfinished(intent) + + +async def _run_agent_stream( + input_data: dict[str, Any], + agent: SupportsAgentRun, + config: AgentConfig, + approval_state_store: InMemoryAGUIApprovalStateStore, + *, + authorized_executions: dict[ApprovalOccurrenceIdentity, AuthorizedExecution], +) -> AsyncGenerator[BaseEvent]: # Parse IDs supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId") supplied_run_id = input_data.get("run_id") or input_data.get("runId") @@ -2587,9 +2725,6 @@ async def run_agent_stream( ) approval_scope = cast(str | None, input_data.get(_APPROVAL_SCOPE_INPUT_KEY)) approval_thread_id = approval_state_thread_id(scope=approval_scope, thread_id=thread_id) - if approval_state_store is None: - approval_state_store = InMemoryAGUIApprovalStateStore() - state_schema = cast(dict[str, Any], getattr(config, "state_schema", {}) or {}) predict_state_config = cast(dict[str, dict[str, str]], getattr(config, "predict_state_config", {}) or {}) @@ -2680,7 +2815,6 @@ async def run_agent_stream( current_state=flow.current_state, ) - authorized_executions: dict[ApprovalOccurrenceIdentity, AuthorizedExecution] = {} forwarded_executions: dict[str, list[tuple[ForwardedPendingToolTransitionOwner, AuthorizedExecution, Content]]] = {} retained_approval_results: list[Content] = [] approval_snapshot_reconciliations: list[ApprovalSnapshotReconciliation] = [] @@ -2893,7 +3027,7 @@ async def run_agent_stream( } ) _restore_tool_approval_state(session, approval_state_store, approval_thread_id) - approval_middleware_pipeline = _approval_resolution_middleware_pipeline(agent, session) + approval_middleware_pipeline = _approval_observer_middleware_pipeline(agent, session) authenticated_cancellations = [ _approval_observer_response(occurrence, cancelled=True) @@ -2979,16 +3113,26 @@ async def run_agent_stream( # Resolve approval responses (execute approved tools, replace approvals with results) # This must happen before running the agent so it sees the tool results tools_for_execution = tools if tools is not None else server_tools - messages.extend( - _pop_collected_tool_approval_response_messages( - session, - approval_thread_id, - tools_for_execution, - lifecycle=approval_state_store.lifecycle, - authorized_executions=authorized_executions, - approval_scope=approval_scope, - ) + core_agent = agent + if a2ui_active: + from ._a2ui._agent import A2UIAgent + + while isinstance(core_agent, A2UIAgent): + core_agent = core_agent.inner_agent + use_native_execution = isinstance(core_agent, RawAgent) and isinstance(core_agent.client, FunctionInvocationLayer) + collected_messages = _pop_collected_tool_approval_response_messages( + session, + approval_thread_id, + tools_for_execution, + lifecycle=approval_state_store.lifecycle, + authorized_executions=authorized_executions, + approval_scope=approval_scope, + consume=not use_native_execution, ) + if not use_native_execution: + messages.extend(collected_messages) + elif collected_messages: + _save_tool_approval_state(session, approval_state_store, approval_thread_id) execution_tool_map = _get_tool_map(tools_for_execution) if tools_for_execution else {} local_intents = [ intent for intent in authorized_executions.values() if intent.owner is ApprovalExecutionOwner.LOCAL @@ -3008,6 +3152,53 @@ async def run_agent_stream( code="APPROVAL_TOOL_UNAVAILABLE", ) return + invocation_config = normalize_function_invocation_configuration( + getattr(getattr(agent, "client", None), "function_invocation_configuration", None) + ) + in_run_execution = None + if ( + authorized_executions + and isinstance(core_agent, RawAgent) + and isinstance(core_agent.client, FunctionInvocationLayer) + ): + in_run_execution = InRunApprovalExecution(approval_state_store.lifecycle, session, core_agent.client) + run_kwargs["middleware"] = [in_run_execution.function_middleware, in_run_execution.chat_middleware] + if local_intents and not invocation_config.get("enabled", True): + for intent in authorized_executions.values(): + approval_state_store.lifecycle.release_claim( + intent, + policy=ClaimRecoveryPolicy.PRESERVE_PENDING_RETENTION, + ) + authenticated_rejections = [ + _approval_observer_response(occurrence) + for interrupt_id in handled_resume_ids - cancelled_resume_ids + if ( + occurrence := approval_state_store.lifecycle.occurrence_for_alias( + thread_id=approval_thread_id, + interrupt_id=interrupt_id, + ) + ) + is not None + and occurrence.status is ApprovalStatus.REJECTED + ] + if authenticated_rejections: + approval_middleware_pipeline._notify_approval_responses( # pyright: ignore[reportPrivateUsage] + authenticated_rejections, + session=session, + ) + retired_interrupt_ids = { + reconciliation.interrupt_id + for reconciliation in approval_snapshot_reconciliations + if reconciliation.retire_interrupt + } + if retired_interrupt_ids: + await snapshot_session.clear_interrupts(interrupt_ids=retired_interrupt_ids) + yield RunStartedEvent(run_id=run_id, thread_id=thread_id) + yield RunErrorEvent( + message="Function invocation is disabled; the approved tool remains pending for an explicit retry.", + code="APPROVAL_INVOCATION_DISABLED", + ) + return validated_approved_responses: list[Content] = [] replacement_approval_requests: list[Content] = [] newly_resolved_approval_results = await _resolve_approval_responses( @@ -3021,6 +3212,8 @@ async def run_agent_stream( replacement_approval_requests, lifecycle=approval_state_store.lifecycle, middleware_pipeline=approval_middleware_pipeline, + in_run_execution=in_run_execution, + additional_approval_messages=collected_messages if use_native_execution else (), authorized_executions=authorized_executions, forwarded_executions=forwarded_executions, ) @@ -3121,8 +3314,14 @@ async def run_agent_stream( telemetry_conversation_id = str(supplied_thread_id) if supplied_thread_id is not None else None telemetry_context = partial(_use_telemetry_conversation_id, telemetry_conversation_id) stream_completed = False + native_approval_flow_result_ids: set[int] = set() try: with telemetry_context(): + for queued_executions in forwarded_executions.values(): + for owner, intent, _ in queued_executions: + forwarded = await owner.forward(intent, lifecycle=approval_state_store.lifecycle) + if len(forwarded) != 1: + raise RuntimeError("Transition owner did not forward exactly one approval decision.") response_stream = (a2ui_runner or agent).run(messages, stream=True, **run_kwargs) stream = await _normalize_response_stream(response_stream) @@ -3176,6 +3375,8 @@ async def run_agent_stream( content_type = getattr(content, "type", None) logger.debug(f"Processing content type={content_type}, message_id={flow.message_id}") forwarded_reapproval_handled = False + native_approval_result = False + suppress_approval_call_end = False if ( content_type == "function_approval_request" @@ -3200,8 +3401,25 @@ async def run_agent_stream( forwarded = forwarded_queue.pop(0) if not forwarded_queue: forwarded_executions.pop(content.call_id, None) - owner, intent, _ = forwarded + owner, intent, approval = forwarded owner.record_outcome(intent, [content], lifecycle=approval_state_store.lifecycle) + if ( + isinstance(owner, InRunPendingToolTransitionOwner) + and intent.owner is not ApprovalExecutionOwner.HOSTED + ): + native_approval_result = True + suppress_approval_call_end = intent.owner is ApprovalExecutionOwner.LOCAL + _replace_approval_contents_with_results( + messages, _collect_approval_responses(messages), [[content]] + ) + _convert_approval_results_to_tool_messages(messages) + state_updates = _extract_approved_state_updates( + [Message(role="user", contents=[approval])], predictive_handler + ) + if state_updates: + flow.current_state.update(state_updates) + latest_state_snapshot = cast(dict[str, Any], make_json_safe(flow.current_state)) + yield StateSnapshotEvent(snapshot=flow.current_state) # Register pending approval requests so we can validate responses later if content_type == "function_approval_request": @@ -3248,6 +3466,7 @@ async def run_agent_stream( getattr(content, "function_call", None), ) + result_offset = len(flow.tool_results) for event in _emit_content( content, flow, @@ -3255,22 +3474,42 @@ async def run_agent_stream( skip_text, config.require_confirmation, ): + if suppress_approval_call_end and isinstance(event, ToolCallEndEvent): + continue if isinstance(event, StateSnapshotEvent): latest_state_snapshot = cast(dict[str, Any], make_json_safe(event.snapshot)) yield event + if native_approval_result: + native_approval_flow_result_ids.update(id(result) for result in flow.tool_results[result_offset:]) # Stop if waiting for approval if flow.waiting_for_approval: break stream_completed = True + except ApprovalInvocationDisabledError: + if not run_started_emitted: + yield RunStartedEvent(run_id=run_id, thread_id=thread_id) + yield RunErrorEvent( + message="Function invocation is disabled; the approved tool remains pending for an explicit retry.", + code="APPROVAL_INVOCATION_DISABLED", + ) + return finally: + if in_run_execution is not None and stream_completed: + in_run_execution.retain_collected_decisions() if approval_state_store is not None: for queued_executions in forwarded_executions.values(): for owner, intent, forwarded_approval in queued_executions: - if stream_completed: + if stream_completed and ( + not isinstance(owner, InRunPendingToolTransitionOwner) + or ( + intent.owner is ApprovalExecutionOwner.HOSTED + and approval_state_store.lifecycle.get(intent.identity).status is ApprovalStatus.EXECUTING + ) + ): owner.record_outcome(intent, [forwarded_approval], lifecycle=approval_state_store.lifecycle) else: - approval_state_store.lifecycle.recover_execution(intent, owner=intent.owner) + approval_state_store.lifecycle.recover_unfinished(intent) forwarded_executions.clear() if flow.waiting_for_approval and isinstance(stream, ResponseStream): @@ -3431,6 +3670,12 @@ async def run_agent_stream( # Emit MessagesSnapshotEvent if we have tool calls or results # Feature #5: Suppress intermediate snapshots for predictive tools without confirmation + _clean_resolved_approvals_from_snapshot(snapshot_messages, messages) + if native_approval_flow_result_ids: + _merge_resolved_approval_results_into_snapshot(snapshot_messages, messages) + flow.tool_results[:] = [ + result for result in flow.tool_results if id(result) not in native_approval_flow_result_ids + ] should_emit_snapshot = ( flow.pending_tool_calls or flow.tool_results or flow.accumulated_text or flow.reasoning_messages ) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_approval_execution.py b/python/packages/ag-ui/agent_framework_ag_ui/_approval_execution.py new file mode 100644 index 00000000000..2f7b906ee65 --- /dev/null +++ b/python/packages/ag-ui/agent_framework_ag_ui/_approval_execution.py @@ -0,0 +1,189 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Track AG-UI approval execution through the agent's normal middleware interfaces.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Mapping +from typing import Any + +from agent_framework import ( + AgentSession, + ChatContext, + ChatMiddleware, + Content, + FunctionInvocationContext, + FunctionMiddleware, + MiddlewareFailure, +) +from agent_framework._tools import ( + _APPROVAL_REQUEST_ID_KEY, + _TOOL_APPROVAL_STATE_KEY, + _load_pending_approval_requests, + _save_pending_approval_requests, + normalize_function_invocation_configuration, +) +from agent_framework.exceptions import UserInputRequiredException + +from ._approval_lifecycle import ( + ApprovalExecutionOwner, + ApprovalLifecycle, + ApprovalOutcome, + ApprovalStatus, + AuthorizedExecution, + ForwardedPendingToolTransitionOwner, +) + +COLLECTED_APPROVAL_OCCURRENCE_KEY = "_agui_collected_approval_occurrence" + + +class ApprovalInvocationDisabledError(Exception): + """The current invocation configuration prevents an unstarted local approval.""" + + +class InRunPendingToolTransitionOwner(ForwardedPendingToolTransitionOwner): + """Observe core-owned execution without starting it while preparing transport messages.""" + + async def forward(self, intent: AuthorizedExecution, *, lifecycle: ApprovalLifecycle) -> list[Content]: + return await self._forwarder() + + def record_outcome( + self, intent: AuthorizedExecution, results: list[Content], *, lifecycle: ApprovalLifecycle + ) -> ApprovalOutcome: + if lifecycle.get(intent.identity).status is ApprovalStatus.CLAIMED: + lifecycle.begin_execution(intent, owner=intent.owner) + if intent.owner is ApprovalExecutionOwner.LOCAL: + return lifecycle.settle(intent, results) + return super().record_outcome(intent, results, lifecycle=lifecycle) + + def record_reapproval(self, intent: AuthorizedExecution, request: Content, *, lifecycle: ApprovalLifecycle) -> None: + if lifecycle.get(intent.identity).status is ApprovalStatus.CLAIMED: + lifecycle.begin_execution(intent, owner=intent.owner) + super().record_reapproval(intent, request, lifecycle=lifecycle) + + +class InRunApprovalExecution: + """Bind validated transport approvals to existing function and chat middleware seams.""" + + def __init__(self, lifecycle: ApprovalLifecycle, session: AgentSession, client: Any) -> None: + self.lifecycle = lifecycle + self.session = session + self.client = client + self._local: dict[str, AuthorizedExecution] = {} + self._hosted: dict[str, AuthorizedExecution] = {} + self.function_middleware = _ApprovalFunctionMiddleware(self) + self.chat_middleware = _ApprovalChatMiddleware(self) + + def register(self, intent: AuthorizedExecution, response: Content) -> None: + occurrence = self.lifecycle.get(intent.identity) + if intent.owner is ApprovalExecutionOwner.HOSTED: + if response.id is None: + raise ValueError("A hosted approval must carry its authoritative request id.") + self._hosted[response.id] = intent + return + if response.function_call is None: + raise ValueError("A local approval must carry its authoritative function call.") + if response.approved is True: + self._local[occurrence.function_call_id] = intent + request_id = occurrence.response_id or occurrence.function_call_id + pending = _load_pending_approval_requests(self.session) + previous = pending.get(request_id) + properties = dict(previous.additional_properties) if previous is not None else {} + if request_id != occurrence.function_call_id: + properties["_replacement_approval_request"] = True + pending[request_id] = Content.from_function_approval_request( + id=request_id, + function_call=Content.from_dict(response.function_call.to_dict()), + additional_properties=properties, + ) + _save_pending_approval_requests(self.session, pending) + response.id = request_id + response.additional_properties[_APPROVAL_REQUEST_ID_KEY] = request_id + + def ensure_enabled(self) -> None: + if not any( + self.lifecycle.get(intent.identity).status is ApprovalStatus.CLAIMED for intent in self._local.values() + ): + return + config = normalize_function_invocation_configuration( + getattr(self.client, "function_invocation_configuration", None) + ) + if not config.get("enabled", True): + raise ApprovalInvocationDisabledError("Function invocation is disabled.") + + def begin_function(self, context: FunctionInvocationContext) -> AuthorizedExecution | None: + response = context.metadata.get("approval_response") + if not isinstance(response, Content) or response.approved is not True: + return None + occurrence_id = context.metadata.get("function_call_occurrence_id") + intent = self._local.get(occurrence_id) if isinstance(occurrence_id, str) else None + if intent is None: + return None + if context.session is not self.session: + raise MiddlewareFailure("Approval execution belongs to a different session.") + self.ensure_enabled() + self.lifecycle.begin_execution(intent, owner=intent.owner) + return intent + + def begin_hosted(self, context: ChatContext) -> None: + self.ensure_enabled() + for message in context.messages: + for content in message.contents: + if content.type != "function_approval_response" or content.approved is not True: + continue + intent = self._hosted.get(content.id) if content.id is not None else None + if intent is not None and self.lifecycle.get(intent.identity).status is ApprovalStatus.CLAIMED: + self.lifecycle.begin_execution(intent, owner=intent.owner) + + def retain_collected_decisions(self) -> None: + """Preserve grants retained by the wrapped Agent's approval middleware.""" + state = self.session.state.get(_TOOL_APPROVAL_STATE_KEY) + if not isinstance(state, Mapping): + return + responses = state.get("collected_approval_responses", []) + updated_responses = list(responses) + for index, value in enumerate(responses): + response = value if isinstance(value, Content) else Content.from_dict(value) + if response.approved is not True or response.function_call is None: + continue + occurrence_id = response.function_call.id + intent = self._local.get(occurrence_id) if occurrence_id is not None else None + if intent is None and response.id is not None: + intent = self._hosted.get(response.id) + if intent is None: + continue + occurrence = self.lifecycle.get(intent.identity) + request_id = response.additional_properties.get(_APPROVAL_REQUEST_ID_KEY) or response.id + if occurrence.status is ApprovalStatus.CLAIMED and request_id == ( + occurrence.response_id or occurrence.function_call_id + ): + self.lifecycle.retain_collected_decision(intent) + response.additional_properties[COLLECTED_APPROVAL_OCCURRENCE_KEY] = intent.identity.occurrence_id + updated_responses[index] = response.to_dict() + self.session.state[_TOOL_APPROVAL_STATE_KEY] = { + **state, + "collected_approval_responses": updated_responses, + } + + +class _ApprovalFunctionMiddleware(FunctionMiddleware): + def __init__(self, execution: InRunApprovalExecution) -> None: + self._execution = execution + + async def process(self, context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None: + intent = self._execution.begin_function(context) + try: + await call_next() + except UserInputRequiredException as exc: + if intent is not None and not any(content.type == "function_result" for content in exc.contents): + self._execution.lifecycle.defer(intent, list(exc.contents), owner=intent.owner) + raise + + +class _ApprovalChatMiddleware(ChatMiddleware): + def __init__(self, execution: InRunApprovalExecution) -> None: + self._execution = execution + + async def process(self, context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + self._execution.begin_hosted(context) + await call_next() diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py b/python/packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py index 08c65725c65..c380acc4a9d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_approval_lifecycle.py @@ -132,6 +132,7 @@ class ClaimRecoveryPolicy(str, Enum): """Proof required to release authority before execution begins.""" SAFE_TO_RETRY = "safe_to_retry" + PRESERVE_PENDING_RETENTION = "preserve_pending_retention" @dataclass(frozen=True) @@ -187,6 +188,7 @@ class ApprovalOccurrence: already_approved_requests: tuple[dict[str, Any], ...] = () server_label: str | None = None idempotency_key: str | None = None + requires_client_resume: bool = True status: ApprovalStatus = ApprovalStatus.PENDING pending_since: float = 0 replayable_results: list[ReplayableToolResult] = field(default_factory=list) @@ -284,6 +286,7 @@ def register( already_approved_requests: list[dict[str, Any]] | None = None, server_label: str | None = None, idempotency_key: str | None = None, + requires_client_resume: bool = True, ) -> ApprovalOccurrence: """Register one approval occurrence with its pending transition owner.""" if (thread_ids is None) == (thread_id is None): @@ -303,6 +306,7 @@ def register( already_approved_requests=already_approved_requests, server_label=server_label, idempotency_key=idempotency_key, + requires_client_resume=requires_client_resume, ) @_serialized_registration @@ -322,6 +326,7 @@ def _register_aliases( already_approved_requests: list[dict[str, Any]] | None = None, server_label: str | None = None, idempotency_key: str | None = None, + requires_client_resume: bool = True, ) -> ApprovalOccurrence: self._purge_expired_terminal() if scope == "": @@ -356,6 +361,7 @@ def _register_aliases( or occurrence.idempotency_key != idempotency_key or occurrence.already_approved_requests != tuple(already_approved_requests or ()) or occurrence.server_label != server_label + or occurrence.requires_client_resume is not requires_client_resume ): raise ValueError("Approval alias conflicts with an existing pending occurrence.") occurrence.thread_ids = tuple(dict.fromkeys((*occurrence.thread_ids, *unique_thread_ids))) @@ -388,6 +394,7 @@ def _register_aliases( already_approved_requests=tuple(already_approved_requests or ()), server_label=server_label, idempotency_key=idempotency_key, + requires_client_resume=requires_client_resume, pending_since=self._clock(), ) self._occurrences[identity] = occurrence @@ -444,6 +451,18 @@ def pending_interrupt_ids(self, *, thread_id: str) -> set[str]: if thread_id in occurrence.thread_ids and occurrence.status is ApprovalStatus.PENDING } + def pending_client_resume_interrupt_ids(self, *, thread_id: str) -> set[str]: + """Return pending interrupt identities that require another client decision.""" + with self._index_lock: + self._purge_expired_terminal() + return { + occurrence.active_interrupt_id + for occurrence in self._occurrences.values() + if thread_id in occurrence.thread_ids + and occurrence.status is ApprovalStatus.PENDING + and occurrence.requires_client_resume + } + def reconcile_snapshot( self, *, @@ -845,6 +864,7 @@ def _rotate_request_generation(self, occurrence: ApprovalOccurrence, *, request_ occurrence.aliases = (request_id,) occurrence.active_interrupt_id = request_id occurrence.response_id = request_id + occurrence.requires_client_resume = True occurrence.decision = None occurrence.pending_since = self._clock() for thread_id in occurrence.thread_ids: @@ -872,12 +892,16 @@ def begin_execution(self, intent: AuthorizedExecution, *, owner: ApprovalExecuti def release_claim(self, intent: AuthorizedExecution, *, policy: ClaimRecoveryPolicy) -> None: """Release reserved authority when execution is known not to have begun.""" occurrence = self._occurrences[intent.identity] - if policy is not ClaimRecoveryPolicy.SAFE_TO_RETRY: + if policy not in { + ClaimRecoveryPolicy.SAFE_TO_RETRY, + ClaimRecoveryPolicy.PRESERVE_PENDING_RETENTION, + }: raise ValueError("Claim recovery policy does not permit retry.") if occurrence.status is not ApprovalStatus.CLAIMED: raise ValueError(f"Approval occurrence is not claimed: {occurrence.status}.") occurrence.status = ApprovalStatus.PENDING - occurrence.pending_since = self._clock() + if policy is ClaimRecoveryPolicy.SAFE_TO_RETRY: + occurrence.pending_since = self._clock() @_serialized_by_occurrence def mark_indeterminate( @@ -925,6 +949,31 @@ def recover_execution( self.mark_indeterminate(intent, owner=owner) return None + @_serialized_by_occurrence + def retain_collected_decision(self, intent: AuthorizedExecution) -> None: + """Keep a server-collected grant pending without asking the client to submit it again.""" + occurrence = self._occurrences[intent.identity] + if ( + occurrence.status is not ApprovalStatus.CLAIMED + or occurrence.decision is None + or not occurrence.decision.accepted + ): + raise ValueError("Only an unstarted accepted claim can be retained as a collected decision.") + occurrence.requires_client_resume = False + self.release_claim(intent, policy=ClaimRecoveryPolicy.PRESERVE_PENDING_RETENTION) + + @_serialized_by_occurrence + def recover_unfinished(self, intent: AuthorizedExecution) -> None: + """Release unstarted authority or recover an execution without replaying uncertain work.""" + occurrence = self._occurrences.get(intent.identity) + if occurrence is None: + # A terminal outcome may have expired before the enclosing stream closes. + return + if occurrence.status is ApprovalStatus.EXECUTING: + self.recover_execution(intent, owner=intent.owner) + if occurrence.status is ApprovalStatus.CLAIMED: + self.release_claim(intent, policy=ClaimRecoveryPolicy.PRESERVE_PENDING_RETENTION) + @_serialized_by_occurrence def settle(self, intent: AuthorizedExecution, results: list[Content]) -> ApprovalOutcome: """Settle an executing occurrence with results under its original call identity.""" @@ -1011,11 +1060,21 @@ def settle_forwarded( return outcome @_serialized_by_occurrence - def defer(self, intent: AuthorizedExecution, results: list[Content]) -> ApprovalOutcome: + def defer( + self, + intent: AuthorizedExecution, + results: list[Content], + *, + owner: ApprovalExecutionOwner = ApprovalExecutionOwner.LOCAL, + ) -> ApprovalOutcome: """Return an execution that yielded only follow-up requests to pending.""" occurrence = self._occurrences[intent.identity] - if intent.owner is not ApprovalExecutionOwner.LOCAL or occurrence.owner is not ApprovalExecutionOwner.LOCAL: - raise ValueError("Only the local transition owner can defer a local execution.") + if ( + owner not in {ApprovalExecutionOwner.LOCAL, ApprovalExecutionOwner.DEFERRED} + or intent.owner is not owner + or occurrence.owner is not owner + ): + raise ValueError("Only the owning local or deferred transition owner can defer execution.") if occurrence.status is not ApprovalStatus.EXECUTING: raise ValueError(f"Approval occurrence is not executing: {occurrence.status}.") occurrence.status = ApprovalStatus.PENDING diff --git a/python/packages/ag-ui/tests/ag_ui/test_a2ui.py b/python/packages/ag-ui/tests/ag_ui/test_a2ui.py index d89b222f009..dfff1f114cb 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_a2ui.py +++ b/python/packages/ag-ui/tests/ag_ui/test_a2ui.py @@ -19,6 +19,7 @@ pytest.importorskip("ag_ui_a2ui_toolkit") from agent_framework import AgentResponseUpdate, ChatResponse, ChatResponseUpdate, Content, Message # noqa: E402 +from agent_framework._tools import FunctionInvocationLayer # noqa: E402 from agent_framework_ag_ui._a2ui import ( # noqa: E402 A2UI_SCHEMA_CONTEXT_DESCRIPTION, @@ -93,7 +94,7 @@ async def gen(): return gen() -async def _drive(agent, tools=None): +async def _drive(agent, tools=None, **kwargs): """Run a streaming A2UIAgent and classify the yielded content. The forwarded AG-UI context (when any) is a constructor arg of ``A2UIAgent`` now, @@ -101,7 +102,9 @@ async def _drive(agent, tools=None): tools for the mixed-batch (ordinary tool + generate_a2ui) path. """ kinds: list[tuple[Any, ...]] = [] - run_kwargs = {"tools": tools} if tools is not None else {} + run_kwargs = dict(kwargs) + if tools is not None: + run_kwargs["tools"] = tools async for update in agent.run("make a card", stream=True, **run_kwargs): for c in update.contents: t = getattr(c, "type", None) @@ -974,6 +977,910 @@ class _InnerWithClient(_SearchThenGenerateInner): assert any(k[0] == "result" and k[1] == "s1" and "Ritz" in k[2] for k in kinds) +async def test_mixed_batch_preserves_effective_middleware_order_session_and_invocation_kwargs( + streaming_chat_client_stub, +) -> None: + from collections.abc import AsyncIterator + + from agent_framework import ( + Agent, + AgentSession, + ContextProvider, + FunctionInvocationContext, + FunctionTool, + MiddlewareBundle, + SessionContext, + SupportsAgentRun, + ) + from agent_framework._middleware import FunctionMiddleware + + observations: list[tuple[str, str, str | None, str | None]] = [] + executed: list[str] = [] + + class _RecordingMiddleware(FunctionMiddleware): + def __init__(self, name: str): + self.name = name + + async def process(self, context, call_next): + observations.append( + ( + self.name, + context.function.name, + context.session.session_id if context.session is not None else None, + context.kwargs.get("trusted_scope"), + ) + ) + await call_next() + + client_middleware = _RecordingMiddleware("client") + agent_middleware = _RecordingMiddleware("agent") + bundle_middleware = _RecordingMiddleware("bundle") + repeated_run_middleware = _RecordingMiddleware("run") + provider_middleware = _RecordingMiddleware("provider") + from agent_framework._feature_stage import ExperimentalWarning + + with pytest.warns(ExperimentalWarning, match="AGENT_HOOKS"): + agent_bundle = MiddlewareBundle([bundle_middleware]) + + class _Provider(ContextProvider): + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + context.extend_middleware(self.source_id, [provider_middleware]) + + first_turn = True + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + nonlocal first_turn + if first_turn: + first_turn = False + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call(call_id="s1", name="search", arguments={"query": "hotels"}), + Content.from_function_call(call_id="g1", name="generate_a2ui", arguments={"intent": "create"}), + ], + ) + else: + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + + client = streaming_chat_client_stub(stream_fn) + inner = Agent( + client=client, + middleware=[agent_middleware, agent_bundle], + context_providers=[_Provider("policy")], + ) + assert isinstance(client, FunctionInvocationLayer) + client.function_middleware.append(client_middleware) + + def search(query: str, context: FunctionInvocationContext) -> str: + executed.append(f"{context.kwargs.get('trusted_scope')}:{query}") + return json.dumps({"results": ["Ritz"]}) + + search_tool = FunctionTool(name="search", description="search", func=search) + kinds = await _drive( + enable_a2ui(inner, _RenderSub()), + tools=[search_tool], + session=AgentSession(session_id="mixed-session"), + middleware=[repeated_run_middleware, repeated_run_middleware], + function_invocation_kwargs={"trusted_scope": "tenant-a"}, + ) + + assert observations == [ + ("client", "search", "mixed-session", "tenant-a"), + ("agent", "search", "mixed-session", "tenant-a"), + ("bundle", "search", "mixed-session", "tenant-a"), + ("run", "search", "mixed-session", "tenant-a"), + ("run", "search", "mixed-session", "tenant-a"), + ("provider", "search", "mixed-session", "tenant-a"), + ("client", "generate_a2ui", "mixed-session", "tenant-a"), + ("agent", "generate_a2ui", "mixed-session", "tenant-a"), + ("bundle", "generate_a2ui", "mixed-session", "tenant-a"), + ("run", "generate_a2ui", "mixed-session", "tenant-a"), + ("run", "generate_a2ui", "mixed-session", "tenant-a"), + ("provider", "generate_a2ui", "mixed-session", "tenant-a"), + ] + assert executed == ["tenant-a:hotels"] + assert any(k[0] == "result" and k[1] == "s1" and "Ritz" in k[2] for k in kinds) + + +@pytest.mark.parametrize( + ("max_calls", "expected_actions", "expected_surfaces"), + [(1, ["search"], 0), (3, ["search", "search"], 1)], +) +async def test_core_a2ui_shares_call_budget_with_surface_generation( + streaming_chat_client_stub, max_calls: int, expected_actions: list[str], expected_surfaces: int +) -> None: + from collections.abc import AsyncIterator + + from agent_framework import Agent, FunctionTool + + executed: list[str] = [] + round_number = 0 + + def search() -> str: + executed.append("search") + return "Search completed" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + nonlocal round_number + if options.get("tool_choice") == "none": + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + return + round_number += 1 + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call(call_id=f"search-{round_number}", name="search", arguments={}), + Content.from_function_call( + call_id=f"generate-{round_number}", name="generate_a2ui", arguments={"intent": "create"} + ), + ], + ) + + client = streaming_chat_client_stub(stream_fn) + agent = Agent(client=client, tools=[FunctionTool(name="search", description="Search", func=search)]) + assert isinstance(client, FunctionInvocationLayer) + client.function_invocation_configuration["max_function_calls"] = max_calls + kinds = await _drive(enable_a2ui(agent, _RenderSub())) + + assert executed == expected_actions + assert len([kind for kind in kinds if kind[0] == "result" and kind[1].startswith("generate-")]) == expected_surfaces + assert len([kind for kind in kinds if kind[0] == "result" and kind[1].startswith("search-")]) == len( + expected_actions + ) + + +async def test_core_a2ui_preserves_inner_turn_boundaries_for_continuation(streaming_chat_client_stub) -> None: + from collections.abc import AsyncIterator + + from agent_framework import Agent, FunctionTool + + executed: list[str] = [] + provider_inputs: list[list[tuple[str, list[tuple[str, str | None]]]]] = [] + provider_round = 0 + + def search(query: str) -> str: + executed.append(query) + return f"{query} result" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + nonlocal provider_round + provider_round += 1 + provider_inputs.append( + [ + ( + str(message.role), + [(content.type, content.call_id) for content in message.contents], + ) + for message in messages + ] + ) + if provider_round == 1: + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_text_reasoning(text="Find source data first.", id="reasoning-1"), + Content.from_function_call(call_id="search-1", name="search", arguments={"query": "first"}), + ], + ) + elif provider_round == 2: + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_text_reasoning(text="Use the first result for the surface.", id="reasoning-2"), + Content.from_function_call(call_id="search-2", name="search", arguments={"query": "second"}), + Content.from_function_call( + call_id="generate-1", name="generate_a2ui", arguments={"intent": "create"} + ), + ], + ) + else: + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text(text="Done.")]) + + client = streaming_chat_client_stub(stream_fn) + agent = Agent(client=client, tools=[FunctionTool(name="search", description="Search", func=search)]) + + kinds = await _drive(enable_a2ui(agent, _RenderSub())) + + assert executed == ["first", "second"] + assert len([kind for kind in kinds if kind[0] == "result" and kind[1] == "search-1"]) == 1 + assert len([kind for kind in kinds if kind[0] == "result" and kind[1] == "search-2"]) == 1 + assert len([kind for kind in kinds if kind[0] == "result" and kind[1] == "generate-1"]) == 1 + assert provider_inputs[2] == [ + ("user", [("text", None)]), + ("assistant", [("text_reasoning", None), ("function_call", "search-1")]), + ("tool", [("function_result", "search-1")]), + ( + "assistant", + [ + ("text_reasoning", None), + ("function_call", "search-2"), + ("function_call", "generate-1"), + ], + ), + ("tool", [("function_result", "search-2"), ("function_result", "generate-1")]), + ] + + +async def test_a2ui_renders_middleware_adjusted_handoff_arguments(streaming_chat_client_stub: Any) -> None: + from collections.abc import AsyncIterator, Awaitable, Callable, Mapping + + from agent_framework import Agent, FunctionInvocationContext, FunctionMiddleware + + prompts: list[str] = [] + model_turn = 0 + surface_ids = ["model-forbidden-surface", "policy-allowed-surface", "policy-allowed-surface"] + + class RestrictSurface(FunctionMiddleware): + async def process(self, context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None: + arguments = ( + dict(context.arguments) if isinstance(context.arguments, Mapping) else context.arguments.model_dump() + ) + if context.function.name == "generate_a2ui" and arguments.get("intent") == "update": + context.arguments = { + "intent": "update", + "target_surface_id": "policy-allowed-surface", + "changes": "sanitized", + } + await call_next() + + class RenderClient: + def get_response( + self, messages: list[Message], *, stream: bool = False, options: Any = None + ) -> AsyncIterator[ChatResponseUpdate]: + prompts.append(messages[0].text) + surface_id = surface_ids[len(prompts) - 1] + + async def updates() -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call( + call_id=f"render-{len(prompts)}", + name="render_a2ui", + arguments={"surfaceId": surface_id, "components": [{"id": "root", "component": "Card"}]}, + ) + ], + ) + + return updates() + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + nonlocal model_turn + model_turn += 1 + if model_turn in (1, 2): + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call( + id=f"create-{model_turn}", + call_id=f"create-{model_turn}", + name="generate_a2ui", + arguments={"intent": "create", "target_surface_id": surface_ids[model_turn - 1]}, + ) + ], + ) + elif model_turn == 3: + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call( + id="update-occurrence", + call_id="g1", + name="generate_a2ui", + arguments={ + "intent": "update", + "target_surface_id": "model-forbidden-surface", + "changes": "unsanitized", + }, + ) + ], + ) + else: + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + + agent = Agent(client=streaming_chat_client_stub(stream_fn), middleware=[RestrictSurface()]) + kinds = await _drive(enable_a2ui(agent, RenderClient())) + + assert len(prompts) == 3 + assert "policy-allowed-surface" in prompts[2] + assert "sanitized" in prompts[2] + assert "model-forbidden-surface" not in prompts[2] + assert "unsanitized" not in prompts[2] + assert len([kind for kind in kinds if kind[0] == "result" and kind[1] == "g1"]) == 1 + + +@pytest.mark.parametrize("approved", [False, True]) +async def test_a2ui_preserves_application_approval_requests(streaming_chat_client_stub: Any, approved: bool) -> None: + from collections.abc import AsyncIterator, Awaitable, Callable, Mapping + + from agent_framework import Agent, AgentSession, FunctionInvocationContext, FunctionMiddleware + + render_calls: list[str] = [] + phase = "request" + + class RequireApproval(FunctionMiddleware): + async def process(self, context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None: + response = context.metadata.get("approval_response") + if not isinstance(response, Content) or response.approved is not True: + arguments = ( + dict(context.arguments) + if isinstance(context.arguments, Mapping) + else context.arguments.model_dump() + ) + call = Content.from_function_call( + id=context.metadata["function_call_occurrence_id"], + call_id=context.metadata["call_id"], + name=context.function.name, + arguments=arguments, + ) + context.result = Content.from_function_approval_request( + id=context.metadata["function_call_occurrence_id"], function_call=call + ) + return + await call_next() + + class RenderClient(_RenderSub): + def get_response(self, messages: list[Message], *, stream: bool = False, options: Any = None) -> Any: + render_calls.append("render") + return super().get_response(messages, stream=stream, options=options) + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if phase == "request": + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call( + id="application-ui-approval", + call_id="g1", + name="generate_a2ui", + arguments={"intent": "create"}, + ) + ], + ) + else: + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + + agent = Agent(client=streaming_chat_client_stub(stream_fn), middleware=[RequireApproval()]) + wrapped = enable_a2ui(agent, RenderClient()) + session = AgentSession() + paused = [update async for update in wrapped.run("Make a card", stream=True, session=session)] + requests = [ + content for update in paused for content in update.contents if content.type == "function_approval_request" + ] + assert len(requests) == 1 + assert requests[0].id == "application-ui-approval" + assert requests[0].function_call is not None + assert requests[0].function_call.call_id == "g1" + assert render_calls == [] + assert not any(content.type == "function_result" for update in paused for content in update.contents) + + phase = "complete" + resumed = [ + update + async for update in wrapped.run( + Message(role="user", contents=[requests[0].to_function_approval_response(approved=approved)]), + stream=True, + session=session, + ) + ] + assert not any(content.type == "function_approval_request" for update in resumed for content in update.contents) + assert render_calls == (["render"] if approved else []) + results = [ + content + for update in resumed + for content in update.contents + if content.type == "function_result" and content.call_id == "g1" + ] + assert len(results) == 1 + if not approved: + assert "rejected" in str(results[0].result) + + +async def test_a2ui_opaque_agent_approval_stops_rendering() -> None: + from collections.abc import AsyncIterator + + class ApprovalAgent: + id = name = description = "approval-agent" + + def run(self, messages: Any, **kwargs: Any) -> AsyncIterator[AgentResponseUpdate]: + call = Content.from_function_call( + id="application-ui", call_id="g1", name="generate_a2ui", arguments={"intent": "create"} + ) + + async def updates() -> AsyncIterator[AgentResponseUpdate]: + yield AgentResponseUpdate( + role="assistant", + contents=[call, Content.from_function_approval_request(id="application-ui", function_call=call)], + ) + + return updates() + + class RenderClient(_RenderSub): + def get_response(self, messages: Any, *, stream: bool = False, options: Any = None) -> Any: + pytest.fail("Application approval must be resolved before rendering.") + + contents = [ + content + async for update in enable_a2ui(ApprovalAgent(), RenderClient()).run("Make a card", stream=True) + for content in update.contents + ] + assert [content.id for content in contents if content.type == "function_approval_request"] == ["application-ui"] + assert not any(content.type == "function_result" for content in contents) + + +async def test_core_a2ui_middleware_failure_aborts_before_render( + streaming_chat_client_stub, +) -> None: + from collections.abc import AsyncIterator + + from agent_framework import Agent, FunctionTool + from agent_framework._middleware import FunctionMiddleware, MiddlewareFailure + + executed: list[str] = [] + + class _DenySearch(FunctionMiddleware): + async def process(self, context, call_next): + if context.function.name == "search": + raise MiddlewareFailure("denied by policy") + await call_next() + + class _TrackingRender(_RenderSub): + def __init__(self): + super().__init__() + self.calls = 0 + + def get_response(self, messages, *, stream=False, options=None): + self.calls += 1 + return super().get_response(messages, stream=stream, options=options) + + def search() -> str: + executed.append("search") + return "should not run" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call(call_id="search-1", name="search", arguments={}), + Content.from_function_call(call_id="generate-1", name="generate_a2ui", arguments={"intent": "create"}), + ], + ) + + client = streaming_chat_client_stub(stream_fn) + agent = Agent(client=client, tools=[FunctionTool(name="search", description="Search", func=search)]) + assert isinstance(client, FunctionInvocationLayer) + client.function_middleware.append(_DenySearch()) + render = _TrackingRender() + + with pytest.raises(MiddlewareFailure, match="denied by policy"): + await _drive(enable_a2ui(agent, render)) + + assert executed == [] + assert render.calls == 0 + + +@pytest.mark.parametrize("middleware_source", ["client", "agent", "provider"]) +async def test_core_a2ui_middleware_termination_stops_before_render_and_next_round( + streaming_chat_client_stub, + middleware_source: str, +) -> None: + from collections.abc import AsyncIterator + + from agent_framework import Agent, AgentSession, ContextProvider, FunctionTool, SessionContext, SupportsAgentRun + from agent_framework._middleware import FunctionMiddleware, MiddlewareTermination + + planner_rounds = 0 + + class _TerminateSearch(FunctionMiddleware): + async def process(self, context, call_next): + if context.function.name == "search": + raise MiddlewareTermination("stopped") + await call_next() + + class _Provider(ContextProvider): + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + context.extend_middleware(self.source_id, [_TerminateSearch()]) + + class _TrackingRender(_RenderSub): + def __init__(self): + super().__init__() + self.calls = 0 + + def get_response(self, messages, *, stream=False, options=None): + self.calls += 1 + return super().get_response(messages, stream=stream, options=options) + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + nonlocal planner_rounds + planner_rounds += 1 + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call(call_id="search-1", name="search", arguments={}), + Content.from_function_call(call_id="generate-1", name="generate_a2ui", arguments={"intent": "create"}), + ], + ) + + client = streaming_chat_client_stub(stream_fn) + middleware = [_TerminateSearch()] if middleware_source == "agent" else None + providers = [_Provider("terminator")] if middleware_source == "provider" else None + agent = Agent( + client=client, + middleware=middleware, + context_providers=providers, + tools=[FunctionTool(name="search", description="Search", func=lambda: "unused")], + ) + assert isinstance(client, FunctionInvocationLayer) + if middleware_source == "client": + client.function_middleware.append(_TerminateSearch()) + render = _TrackingRender() + + kinds = await _drive(enable_a2ui(agent, render), session=AgentSession()) + + assert planner_rounds == 1 + assert render.calls == 0 + assert _generate_envelope(kinds) is None + + +async def test_core_a2ui_termination_state_isolated_across_shared_client_runs( + streaming_chat_client_stub, +) -> None: + from collections.abc import AsyncIterator + + from agent_framework import Agent, FunctionTool + from agent_framework._middleware import FunctionMiddleware, MiddlewareTermination + + class _TerminateSearch(FunctionMiddleware): + async def process(self, context, call_next): + if context.function.name == "search": + raise MiddlewareTermination("stopped") + await call_next() + + class _TrackingRender(_RenderSub): + def __init__(self): + super().__init__() + self.calls = 0 + + def get_response(self, messages, *, stream=False, options=None): + self.calls += 1 + return super().get_response(messages, stream=stream, options=options) + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if any(content.type == "function_result" for message in messages for content in message.contents): + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + return + prompt = next( + ( + content.text + for message in reversed(messages) + for content in message.contents + if content.type == "text" and message.role == "user" + ), + "", + ) + calls = [Content.from_function_call(call_id=f"generate-{prompt}", name="generate_a2ui", arguments={})] + if prompt == "stop": + calls.insert(0, Content.from_function_call(call_id="search-stop", name="search", arguments={})) + yield ChatResponseUpdate(role="assistant", contents=calls) + + client = streaming_chat_client_stub(stream_fn) + agent = Agent( + client=client, + tools=[FunctionTool(name="search", description="Search", func=lambda: "unused")], + ) + assert isinstance(client, FunctionInvocationLayer) + client.function_middleware.append(_TerminateSearch()) + render = _TrackingRender() + runner = enable_a2ui(agent, render) + + async def collect(prompt: str) -> list[Content]: + return [content async for update in runner.run(prompt, stream=True) for content in update.contents] + + stopped_contents, rendered_contents = await asyncio.gather(collect("stop"), collect("render")) + + assert render.calls == 1 + assert not any( + content.type == "function_result" and content.call_id == "generate-stop" for content in stopped_contents + ) + assert any( + content.type == "function_result" and content.call_id == "generate-render" for content in rendered_contents + ) + + +async def test_core_a2ui_termination_scope_does_not_leak_during_outward_yield( + streaming_chat_client_stub, +) -> None: + from collections.abc import AsyncIterator + + from agent_framework import Agent, FunctionTool + from agent_framework._middleware import FunctionMiddleware, MiddlewareTermination + + class _TerminateSearch(FunctionMiddleware): + async def process(self, context, call_next): + if context.function.name == "search": + raise MiddlewareTermination("direct run stopped") + await call_next() + + class _TrackingRender(_RenderSub): + def __init__(self): + super().__init__() + self.calls = 0 + + def get_response(self, messages, *, stream=False, options=None): + self.calls += 1 + return super().get_response(messages, stream=stream, options=options) + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if any(content.type == "function_result" for message in messages for content in message.contents): + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + return + prompt = next( + ( + content.text + for message in reversed(messages) + for content in message.contents + if content.type == "text" and message.role == "user" + ), + "", + ) + if prompt == "direct": + yield ChatResponseUpdate( + role="assistant", + contents=[Content.from_function_call(call_id="search-direct", name="search", arguments={})], + ) + return + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("planner progress")]) + await asyncio.sleep(0) + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call( + call_id="generate-a2ui", name="generate_a2ui", arguments={"intent": "create"} + ) + ], + ) + + client = streaming_chat_client_stub(stream_fn) + agent = Agent( + client=client, + tools=[FunctionTool(name="search", description="Search", func=lambda: "unused")], + ) + assert isinstance(client, FunctionInvocationLayer) + client.function_middleware.append(_TerminateSearch()) + render = _TrackingRender() + runner = enable_a2ui(agent, render) + a2ui_contents: list[Content] = [] + direct_contents: list[Content] = [] + direct_ran = False + + async for update in runner.run("a2ui", stream=True): + a2ui_contents.extend(update.contents) + if not direct_ran and any( + content.type == "text" and content.text == "planner progress" for content in update.contents + ): + direct_ran = True + async for direct_update in agent.run("direct", stream=True): + direct_contents.extend(direct_update.contents) + + assert direct_ran + assert any(content.type == "function_result" and content.call_id == "search-direct" for content in direct_contents) + assert render.calls == 1 + assert any(content.type == "function_result" and content.call_id == "generate-a2ui" for content in a2ui_contents) + + +@pytest.mark.parametrize("with_session", [False, True]) +async def test_core_a2ui_approval_pause_preserves_required_requests( + streaming_chat_client_stub, + with_session: bool, +) -> None: + from collections.abc import AsyncIterator + + from agent_framework import Agent, AgentSession, FunctionTool + + executed: list[str] = [] + + class _TrackingRender(_RenderSub): + def __init__(self): + super().__init__() + self.calls = 0 + + def get_response(self, messages, *, stream=False, options=None): + self.calls += 1 + return super().get_response(messages, stream=stream, options=options) + + def wire_money() -> str: + executed.append("wire_money") + return "sent" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call(call_id="wire-1", name="wire_money", arguments={}), + Content.from_function_call(call_id="generate-1", name="generate_a2ui", arguments={"intent": "create"}), + ], + ) + + client = streaming_chat_client_stub(stream_fn) + agent = Agent( + client=client, + tools=[ + FunctionTool( + name="wire_money", + description="Wire money", + func=wire_money, + approval_mode="always_require", + ) + ], + ) + render = _TrackingRender() + contents = [ + content + async for update in enable_a2ui(agent, render).run( + "make a card", stream=True, session=AgentSession() if with_session else None + ) + for content in update.contents + ] + + approval_names = [ + content.function_call.name + for content in contents + if content.type == "function_approval_request" and content.function_call is not None + ] + assert approval_names == (["wire_money"] if with_session else ["wire_money", "generate_a2ui"]) + assert executed == [] + assert render.calls == 0 + assert not any(content.type == "function_result" and content.call_id == "generate-1" for content in contents) + direct_contents = [ + content + async for update in agent.run( + "make a card", + stream=True, + session=AgentSession() if with_session else None, + tools=[FunctionTool(name="generate_a2ui", description="Request rendering", func=lambda: None)], + ) + for content in update.contents + ] + assert [ + content.function_call.name + for content in direct_contents + if content.type == "function_approval_request" and content.function_call is not None + ] == approval_names + + +async def test_core_a2ui_approval_resume_completes_retained_generate_handoff( + streaming_chat_client_stub, +) -> None: + from collections.abc import AsyncIterator + + from agent_framework import Agent, AgentSession, FunctionTool + + executed: list[str] = [] + provider_rounds = 0 + + class _NoRenderCall: + def __init__(self): + self.calls = 0 + + def get_response(self, messages, *, stream=False, options=None): + self.calls += 1 + + async def gen(): + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("No render call")]) + + return gen() + + def wire_money() -> str: + executed.append("wire_money") + return "sent" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + nonlocal provider_rounds + provider_rounds += 1 + if provider_rounds == 1: + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call(call_id="wire-1", name="wire_money", arguments={}), + Content.from_function_call( + call_id="generate-1", + name="generate_a2ui", + arguments={"intent": "create", "target_surface_id": "approval-surface"}, + ), + ], + ) + else: + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + + client = streaming_chat_client_stub(stream_fn) + agent = Agent( + client=client, + tools=[ + FunctionTool( + name="wire_money", + description="Wire money", + func=wire_money, + approval_mode="always_require", + ) + ], + ) + render = _NoRenderCall() + runner = enable_a2ui(agent, render) + session = AgentSession() + + first_contents = [ + content + async for update in runner.run("make a card", stream=True, session=session) + for content in update.contents + ] + approval_request = next( + content + for content in first_contents + if content.type == "function_approval_request" + and content.function_call is not None + and content.function_call.name == "wire_money" + ) + + assert executed == [] + assert render.calls == 0 + assert not any( + content.type == "function_approval_request" + and content.function_call is not None + and content.function_call.name == "generate_a2ui" + for content in first_contents + ) + + resume = Message(role="user", contents=[approval_request.to_function_approval_response(approved=True)]) + resumed_contents = [ + content async for update in runner.run([resume], stream=True, session=session) for content in update.contents + ] + + assert executed == ["wire_money"] + assert render.calls == 3 + assert provider_rounds == 2 + generate_results = [ + content for content in resumed_contents if content.type == "function_result" and content.call_id == "generate-1" + ] + assert len(generate_results) == 1 + assert json.loads(generate_results[0].result)["code"] == "a2ui_recovery_exhausted" + + def test_mixed_batch_middleware_failure_aborts_without_rendering(): # A fail-closed authorization/guardrail abort (MiddlewareFailure) raised while executing a # server tool alongside generate_a2ui must propagate and stop the run, exactly as the core @@ -1269,8 +2176,9 @@ async def create_conversation(*, session_id: str) -> str: assert not hasattr(A2UIAgent(type("_NoConversation", (), {})(), _RenderSub()), "create_conversation") -def test_a2ui_agent_uses_per_request_context_over_constructor(): - # A reused runner must serve the CURRENT request's catalog, not a stale constructor one. +def test_a2ui_agent_uses_per_request_context_over_constructor_and_honors_empty_override(): + # A reused runner must serve the CURRENT request's catalog, not a stale constructor one, + # while an explicit empty context disables the constructor fallback. old = build_ag_ui_context_slice( [{"description": A2UI_SCHEMA_CONTEXT_DESCRIPTION, "value": '{"components":{"OldCard":{}}}'}] ) @@ -1303,6 +2211,13 @@ async def go(): sys_text = inner.seen[0].text # prepended system message assert "NewCard" in sys_text and "OldCard" not in sys_text + async def go_without_context(): + async for _ in runner.run("hi", stream=True, a2ui_context={}): + pass + + asyncio.run(go_without_context()) + assert [_role(message) for message in inner.seen] == ["user"] + def test_facade_no_longer_advertises_removed_context_agent(): import agent_framework_ag_ui as pkg diff --git a/python/packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py b/python/packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py index feeee694205..b80abf1a6f1 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py +++ b/python/packages/ag-ui/tests/ag_ui/test_approval_lifecycle.py @@ -235,6 +235,31 @@ def test_safe_claim_release_restarts_pending_retention_window() -> None: assert lifecycle.pending_occurrence(thread_id="thread-1", interrupt_id="approval-1") is occurrence +def test_claim_release_can_preserve_original_pending_retention_window() -> None: + """A blocked executor does not extend the caller's original approval authority.""" + now = 100.0 + lifecycle = ApprovalLifecycle(max_entries=1, pending_retention_seconds=20, clock=lambda: now) + occurrence = lifecycle.register( + owner=ApprovalExecutionOwner.LOCAL, + thread_id="thread-1", + interrupt_id="approval-1", + call_id="call-1", + name="write_record", + arguments="{}", + ) + intent = lifecycle.claim( + thread_id="thread-1", + decision=ResumeDecision(interrupt_id="approval-1", accepted=True, arguments="{}"), + ) + + now = 119.0 + lifecycle.release_claim(intent, policy=ClaimRecoveryPolicy.PRESERVE_PENDING_RETENTION) + now = 121.0 + + assert lifecycle.pending_occurrence(thread_id="thread-1", interrupt_id="approval-1") is None + assert occurrence.status is ApprovalStatus.EXPIRED + + def test_capacity_is_enforced_per_trusted_scope() -> None: """One trusted application scope cannot exhaust another scope's approval quota.""" lifecycle = ApprovalLifecycle(max_entries=1) diff --git a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py index b3d6581a6a2..69bf104ec8a 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_endpoint.py +++ b/python/packages/ag-ui/tests/ag_ui/test_endpoint.py @@ -8,7 +8,7 @@ import subprocess import sys from collections import Counter -from collections.abc import AsyncIterator, Callable, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from contextvars import ContextVar from dataclasses import dataclass from inspect import signature @@ -29,12 +29,14 @@ Executor, FileMemoryProvider, FileSystemAgentFileStore, + FunctionMiddleware, FunctionTool, HistoryProvider, InMemoryAgentFileStore, InMemoryCheckpointStorage, InMemoryHistoryProvider, Message, + MiddlewareFailure, SessionContext, SupportsAgentRun, ToolApprovalMiddleware, @@ -47,6 +49,7 @@ handler, response_handler, ) +from agent_framework._tools import FunctionInvocationLayer from agent_framework.orchestrations import SequentialBuilder from agent_framework.security import SecureAgentConfig from conftest import StubAgent # pyrefly: ignore[missing-import] # pyright: ignore[reportMissingImports] @@ -63,7 +66,12 @@ add_agent_framework_fastapi_endpoint, ) from agent_framework_ag_ui._agent import AgentFrameworkAgent -from agent_framework_ag_ui._approval_lifecycle import ApprovalExecutionOwner, ApprovalLifecycle, ApprovalStatus +from agent_framework_ag_ui._approval_lifecycle import ( + ApprovalExecutionOwner, + ApprovalLifecycle, + ApprovalStatus, + ResumeDecision, +) from agent_framework_ag_ui._approval_state import InMemoryAGUIApprovalStateStore, approval_state_thread_id from agent_framework_ag_ui._workflow import ( _CHECKPOINT_REQUEST_OWNER_KEY, @@ -2959,491 +2967,631 @@ def get_weather(city: str) -> str: return client, agent, executed_cities -async def test_endpoint_agent_approval_batch_keeps_distinct_occurrences_for_reused_call_id() -> None: - """Unique interrupts sharing a provider call ID each execute and settle exactly once.""" - executed: list[str] = [] - - def first_tool() -> str: - executed.append("first") - return "first result" - - def second_tool() -> str: - executed.append("second") - return "second result" - - tools = [ - FunctionTool(name="first_tool", description="First", func=first_tool), - FunctionTool(name="second_tool", description="Second", func=second_tool), - ] - agent = StubAgent( - updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], - default_options={"tools": tools}, - ) - wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) - lifecycle = wrapped_agent._approval_state_store.lifecycle - first = lifecycle.register( - owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-shared-call", - interrupt_id="approval-first", - call_id="call-shared", - name="first_tool", - arguments="{}", - ) - second = lifecycle.register( - owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-shared-call", - interrupt_id="approval-second", - call_id="call-shared", - name="second_tool", - arguments="{}", - ) - app = FastAPI() - add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") - - response = TestClient(app).post( - "/approval", - json={ - "runId": "run-shared-call", - "threadId": "thread-shared-call", - "messages": [], - "resume": [ - {"interruptId": "approval-first", "status": "resolved", "payload": {"approved": True}}, - {"interruptId": "approval-second", "status": "resolved", "payload": {"approved": True}}, - ], - }, - ) - - assert response.status_code == 200 - events = _decode_sse_events(response) - assert not [event for event in events if event.get("type") == "RUN_ERROR"] - assert lifecycle.get(first.identity).status is ApprovalStatus.SETTLED - assert lifecycle.get(second.identity).status is ApprovalStatus.SETTLED - assert executed == ["first", "second"] - assert [event["content"] for event in events if event.get("type") == "TOOL_CALL_RESULT"] == [ - "first result", - "second result", - ] - - -async def test_endpoint_agent_unavailable_local_executor_releases_every_unstarted_batch_intent() -> None: - """A local executor failure leaves local and hosted siblings retryable.""" - agent = StubAgent( - updates=[AgentResponseUpdate(contents=[Content.from_text(text="Should not run.")], role="assistant")], - default_options={"tools": []}, - ) - wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) - lifecycle = wrapped_agent._approval_state_store.lifecycle - local = lifecycle.register( - owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-unavailable-batch", - interrupt_id="approval-local", - call_id="call-local", - name="missing_local_tool", - arguments="{}", - ) - hosted = lifecycle.register( - owner=ApprovalExecutionOwner.HOSTED, - thread_id="thread-unavailable-batch", - interrupt_id="approval-hosted", - call_id="call-hosted", - name="hosted_tool", - arguments="{}", - server_label="hosted-server", - ) - app = FastAPI() - add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") - - response = TestClient(app).post( - "/approval", - json={ - "runId": "run-unavailable-batch", - "threadId": "thread-unavailable-batch", - "messages": [], - "resume": [ - {"interruptId": "approval-local", "status": "resolved", "payload": {"approved": True}}, - {"interruptId": "approval-hosted", "status": "resolved", "payload": {"approved": True}}, - ], - }, - ) - - assert response.status_code == 200 - errors = [event for event in _decode_sse_events(response) if event.get("type") == "RUN_ERROR"] - assert [error["code"] for error in errors] == ["APPROVAL_TOOL_UNAVAILABLE"] - assert lifecycle.get(local.identity).status is ApprovalStatus.PENDING - assert lifecycle.get(hosted.identity).status is ApprovalStatus.PENDING - - -def _build_mixed_approval_batch_endpoint( +@pytest.mark.parametrize("a2ui_mode", ["none", "manual", "automatic"]) +@pytest.mark.parametrize("policy_source", ["agent", "context_provider"]) +async def test_endpoint_agent_approval_resume_preserves_agent_function_policy( streaming_chat_client_stub: Any, - *, - snapshot_store: InMemoryAGUIThreadSnapshotStore | None = None, -) -> tuple[TestClient, list[str], list[Message], dict[str, str]]: + a2ui_mode: str, + policy_source: str, +) -> None: + """Approval grants consent without bypassing Agent-level authorization.""" executed: list[str] = [] - messages_received: list[Message] = [] + policy_observations: list[tuple[str | None, str | None]] = [] state = {"phase": "pause"} - def sensitive_action(city: str) -> str: - executed.append(f"sensitive:{city}") - return f"Sensitive action in {city}" + class DenyProtectedTool(FunctionMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + del call_next + policy_observations.append( + ( + context.session.session_id if context.session is not None else None, + context.kwargs.get("principal"), + ) + ) + raise MiddlewareFailure("principal is not authorized") - def lookup_weather(city: str) -> str: - executed.append(f"weather:{city}") - return f"Weather in {city}" + class AuthorizationProvider(ContextProvider): + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + context.extend_middleware(self.source_id, [DenyProtectedTool()]) - gated_tool = FunctionTool( - name="sensitive_action", - description="Run a sensitive city action", - func=sensitive_action, + def protected_action(value: str, principal: str | None = None) -> str: + executed.append(f"{principal}:{value}") + return "protected action completed" + + protected_tool = FunctionTool( + name="protected_action", + description="Perform a protected action.", + func=protected_action, approval_mode="always_require", ) - sibling_tool = FunctionTool( - name="lookup_weather", - description="Look up weather", - func=lookup_weather, - ) async def stream_fn( messages: list[Message], options: dict[str, Any], **kwargs: Any, ) -> AsyncIterator[ChatResponseUpdate]: - del options, kwargs + del messages, options, kwargs if state["phase"] == "pause": yield ChatResponseUpdate( contents=[ Content.from_function_call( - call_id="call_sensitive", - name="sensitive_action", - arguments={"city": "Seattle"}, - ), - Content.from_function_call( - call_id="call_weather", - name="lookup_weather", - arguments={"city": "Seattle"}, - ), + call_id="provider-protected", + name="protected_action", + arguments={"value": "classified"}, + ) ], role="assistant", ) return - messages_received[:] = list(messages) - yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") + yield ChatResponseUpdate(contents=[Content.from_text(text="must not continue")], role="assistant") agent = Agent( name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn), - tools=[gated_tool, sibling_tool], + tools=[protected_tool], + middleware=[DenyProtectedTool()] if policy_source == "agent" else None, + context_providers=[AuthorizationProvider("authorization")] if policy_source == "context_provider" else None, ) + endpoint_agent: Any = agent + if a2ui_mode != "none": + pytest.importorskip("ag_ui_a2ui_toolkit") + if a2ui_mode == "manual": + from agent_framework_ag_ui._a2ui import enable_a2ui + + endpoint_agent = enable_a2ui(agent, object()) + + wrapped_agent = AgentFrameworkAgent(agent=cast(SupportsAgentRun, endpoint_agent), require_confirmation=False) app = FastAPI() - wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) - add_agent_framework_fastapi_endpoint( - app, - wrapped_agent, - path="/approval", - snapshot_store=snapshot_store, - snapshot_scope_resolver=(lambda _request: "tenant-a") if snapshot_store is not None else None, + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + client = TestClient(app) + pause_payload: dict[str, Any] = { + "runId": "run-pause", + "threadId": f"thread-policy-{a2ui_mode}", + "messages": [{"role": "user", "content": "Perform the protected action"}], + } + if a2ui_mode == "automatic": + pause_payload["forwardedProps"] = {"injectA2UITool": True} + + pause_response = client.post("/approval", json=pause_payload) + assert pause_response.status_code == 200 + pause_finished = [event for event in _decode_sse_events(pause_response) if event.get("type") == "RUN_FINISHED"] + interrupts = _run_finished_interrupts(pause_finished[-1]) + assert len(interrupts) == 1 + assert executed == [] + + state["phase"] = "resume" + resume_response = client.post( + "/approval", + json={ + "runId": "run-resume", + "threadId": f"thread-policy-{a2ui_mode}", + "messages": [], + "resume": [ + { + "interruptId": interrupts[0]["id"], + "status": "resolved", + "payload": {"accepted": True}, + } + ], + "forwardedProps": {"injectA2UITool": True} if a2ui_mode == "automatic" else {}, + }, ) - return TestClient(app), executed, messages_received, state + + assert resume_response.status_code == 200 + resume_events = _decode_sse_events(resume_response) + assert [event["code"] for event in resume_events if event.get("type") == "RUN_ERROR"] == ["MiddlewareFailure"] + assert not [event for event in resume_events if event.get("type") == "TOOL_CALL_RESULT"] + assert executed == [] + assert policy_observations == [(f"thread-policy-{a2ui_mode}", None)] + + state["phase"] = "pause" + direct_session = AgentSession(session_id=f"direct-policy-{a2ui_mode}") + direct_pause = agent.run( + "Perform the protected action", + stream=True, + session=direct_session, + function_invocation_kwargs={"principal": None}, + ) + direct_updates = [update async for update in direct_pause] + direct_request = next( + content + for update in direct_updates + for content in update.contents + if content.type == "function_approval_request" + ) + assert direct_request.function_call is not None + assert direct_request.id is not None + direct_response = Content.from_function_approval_response( + approved=True, + id=direct_request.id, + function_call=direct_request.function_call, + ) + state["phase"] = "resume" + with pytest.raises(MiddlewareFailure): + direct_resume = agent.run( + Message(role="user", contents=[direct_response]), + stream=True, + session=direct_session, + function_invocation_kwargs={"principal": None}, + ) + _ = [update async for update in direct_resume] + assert executed == [] + assert policy_observations[-1] == (f"direct-policy-{a2ui_mode}", None) -def _build_tool_approval_queue_endpoint( +@pytest.mark.parametrize("a2ui_mode", ["manual", "automatic"]) +@pytest.mark.parametrize("policy_source", ["agent", "context_provider"]) +async def test_endpoint_a2ui_mixed_batch_preserves_agent_function_policy( streaming_chat_client_stub: Any, -) -> tuple[TestClient, list[str], list[Message], dict[str, str], AgentFrameworkAgent]: + a2ui_mode: str, + policy_source: str, +) -> None: + """A declaration-only UI call cannot bypass Agent-level policy for its server-tool sibling.""" executed: list[str] = [] - messages_received: list[Message] = [] - state = {"phase": "pause"} + policy_sessions: list[str | None] = [] + render_calls: list[str] = [] + state = {"direct": False} + + class DenyProtectedTool(FunctionMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + if context.function.name != "protected_action": + await call_next() + return + policy_sessions.append(context.session.session_id if context.session is not None else None) + raise MiddlewareFailure("principal is not authorized") - def first_tool() -> str: - executed.append("first") - return "first result" + class RenderClient: + def get_response(self, *args: Any, **kwargs: Any) -> Any: + del args, kwargs + render_calls.append("rendered") + raise AssertionError("rendering must not start after policy denial") - def second_tool() -> str: - executed.append("second") - return "second result" + class AuthorizationProvider(ContextProvider): + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + context.extend_middleware(self.source_id, [DenyProtectedTool()]) + + def protected_action(value: str) -> str: + executed.append(value) + return "protected action completed" + + protected_tool = FunctionTool( + name="protected_action", + description="Perform a protected action.", + func=protected_action, + ) async def stream_fn( messages: list[Message], options: dict[str, Any], **kwargs: Any, ) -> AsyncIterator[ChatResponseUpdate]: - del options, kwargs - if state["phase"] == "pause": - yield ChatResponseUpdate( - contents=[ - Content.from_function_call(call_id="call_first", name="first_tool", arguments="{}"), - Content.from_function_call(call_id="call_second", name="second_tool", arguments="{}"), - ], - role="assistant", + del messages, options, kwargs + calls = [ + Content.from_function_call( + call_id="provider-protected", + name="protected_action", + arguments={"value": "classified"}, ) - return - messages_received[:] = list(messages) - yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") + ] + if not state["direct"]: + calls.append( + Content.from_function_call( + call_id="provider-generate", + name="generate_a2ui", + arguments={"intent": "create"}, + ) + ) + yield ChatResponseUpdate(contents=calls, role="assistant") agent = Agent( name="test_agent", instructions="Test", client=streaming_chat_client_stub(stream_fn), - tools=[ - FunctionTool(name="first_tool", description="First tool", func=first_tool, approval_mode="always_require"), - FunctionTool( - name="second_tool", description="Second tool", func=second_tool, approval_mode="always_require" - ), - ], - middleware=[ToolApprovalMiddleware()], + tools=[protected_tool], + middleware=[DenyProtectedTool()] if policy_source == "agent" else None, + context_providers=[AuthorizationProvider("authorization")] if policy_source == "context_provider" else None, ) + pytest.importorskip("ag_ui_a2ui_toolkit") + endpoint_agent: Any = agent + if a2ui_mode == "manual": + from agent_framework_ag_ui._a2ui import enable_a2ui + + endpoint_agent = enable_a2ui(agent, RenderClient()) + + wrapped_agent = AgentFrameworkAgent(agent=cast(SupportsAgentRun, endpoint_agent), require_confirmation=False) app = FastAPI() - wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) - add_agent_framework_fastapi_endpoint( - app, - wrapped_agent, - path="/approval", - ) - return TestClient(app), executed, messages_received, state, wrapped_agent + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/a2ui") + client = TestClient(app) + payload: dict[str, Any] = { + "runId": "run-mixed", + "threadId": f"thread-mixed-policy-{a2ui_mode}", + "messages": [{"role": "user", "content": "Perform and render"}], + } + if a2ui_mode == "automatic": + payload["forwardedProps"] = {"injectA2UITool": True} + response = client.post("/a2ui", json=payload) -def _build_tool_approval_auto_endpoint( - streaming_chat_client_stub: Any, -) -> tuple[TestClient, list[str], list[Message], dict[str, str]]: + assert response.status_code == 200 + events = _decode_sse_events(response) + assert [event["code"] for event in events if event.get("type") == "RUN_ERROR"] == ["MiddlewareFailure"] + assert not [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] + assert executed == [] + assert render_calls == [] + assert policy_sessions == [f"thread-mixed-policy-{a2ui_mode}"] + + state["direct"] = True + with pytest.raises(MiddlewareFailure): + direct_run = agent.run( + "Perform the protected action", + stream=True, + session=AgentSession(session_id=f"direct-mixed-policy-{a2ui_mode}"), + ) + _ = [update async for update in direct_run] + assert executed == [] + assert policy_sessions[-1] == f"direct-mixed-policy-{a2ui_mode}" + + +@pytest.mark.parametrize("a2ui_mode", ["manual", "automatic"]) +async def test_endpoint_a2ui_approval_prepares_policy_from_effective_input( + streaming_chat_client_stub, a2ui_mode: str +) -> None: + """Approval preparation must see the same A2UI context prompt as the normal wrapped run.""" + pytest.importorskip("ag_ui_a2ui_toolkit") + from agent_framework_ag_ui._a2ui import enable_a2ui + + phase = "pause" executed: list[str] = [] - messages_received: list[Message] = [] - state = {"phase": "pause"} - def auto_tool() -> str: - executed.append("auto") - return "auto result" + class DenyWrites(FunctionMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + raise MiddlewareFailure("Workspace is read-only") - def manual_tool() -> str: - executed.append("manual") - return "manual result" + class WorkspacePolicy(ContextProvider): + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + if any("Read-only workspace" in message.text for message in context.input_messages): + context.extend_middleware(self.source_id, [DenyWrites()]) - def auto_approve_auto_tool(function_call: Content) -> bool: - return function_call.name == "auto_tool" + def save() -> str: + executed.append("saved") + return "Saved" async def stream_fn( - messages: list[Message], - options: dict[str, Any], - **kwargs: Any, + messages: list[Message], options: dict[str, Any], **kwargs: Any ) -> AsyncIterator[ChatResponseUpdate]: - del options, kwargs - if state["phase"] == "pause": + if phase == "pause": yield ChatResponseUpdate( - contents=[ - Content.from_function_call(call_id="call_auto", name="auto_tool", arguments="{}"), - Content.from_function_call(call_id="call_manual", name="manual_tool", arguments="{}"), - ], role="assistant", + contents=[Content.from_function_call(id="save", call_id="save-call", name="save", arguments={})], ) - return - messages_received[:] = list(messages) - yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") + else: + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) agent = Agent( - name="test_agent", - instructions="Test", client=streaming_chat_client_stub(stream_fn), - tools=[ - FunctionTool(name="auto_tool", description="Auto tool", func=auto_tool, approval_mode="always_require"), - FunctionTool( - name="manual_tool", description="Manual tool", func=manual_tool, approval_mode="always_require" - ), - ], - middleware=[ToolApprovalMiddleware(auto_approval_rules=[auto_approve_auto_tool])], + tools=[FunctionTool(name="save", description="Save", func=save, approval_mode="always_require")], + context_providers=[WorkspacePolicy("workspace")], ) + endpoint_agent = cast(SupportsAgentRun, enable_a2ui(agent, object())) if a2ui_mode == "manual" else agent app = FastAPI() - add_agent_framework_fastapi_endpoint( - app, - AgentFrameworkAgent(agent=agent, require_confirmation=False), - path="/approval", + add_agent_framework_fastapi_endpoint(app, AgentFrameworkAgent(agent=endpoint_agent), path="/approval") + client = TestClient(app) + context = [{"description": "Application mode", "value": "Read-only workspace"}] + forwarded = {"injectA2UITool": True} if a2ui_mode == "automatic" else {} + pause = client.post( + "/approval", + json={ + "runId": "pause", + "threadId": "context-policy", + "messages": [{"role": "user", "content": "Save"}], + "context": context, + "forwardedProps": forwarded, + }, ) - return TestClient(app), executed, messages_received, state - - -async def test_endpoint_agent_approval_resume_entry_executes_approved_tool(): - """A resolved canonical approval resume should execute the pending approved tool.""" - client, _, executed_cities = _build_weather_approval_endpoint() - + finished = [event for event in _decode_sse_events(pause) if event["type"] == "RUN_FINISHED"] + interrupt = _run_finished_interrupts(finished[-1])[0] + phase = "resume" response = client.post( "/approval", json={ - "runId": "run-resume", - "threadId": "thread-weather", + "runId": "resume", + "threadId": "context-policy", "messages": [], - "resume": [ - { - "interruptId": "call_get_weather", - "status": "resolved", - "payload": {"accepted": True}, - } - ], + "context": context, + "forwardedProps": forwarded, + "resume": [{"interruptId": interrupt["id"], "status": "resolved", "payload": {"approved": True}}], }, ) - - assert response.status_code == 200 events = _decode_sse_events(response) - tool_results = [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] - assert len(tool_results) == 1 - assert tool_results[0]["toolCallId"] == "call_get_weather" - assert tool_results[0]["content"] == "Sunny in Seattle" - assert executed_cities == ["Seattle"] - assert "outcome" not in [event for event in events if event.get("type") == "RUN_FINISHED"][-1] + assert [event["code"] for event in events if event["type"] == "RUN_ERROR"] == ["MiddlewareFailure"] + assert executed == [] + assert not [event for event in events if event["type"] == "TOOL_CALL_RESULT"] -async def test_endpoint_agent_legacy_tool_message_uses_unique_pending_call_id( - caplog: pytest.LogCaptureFixture, -) -> None: - """Legacy tool messages remain usable only while their provider call id is unambiguous.""" +async def test_endpoint_approval_honors_agent_middleware_before_execution(streaming_chat_client_stub) -> None: + """The adapter must not execute a tool before the wrapped Agent admits the resumed run.""" + from agent_framework import AgentMiddleware + + phase = "pause" executed: list[str] = [] - def get_weather(city: str) -> str: - executed.append(city) - return f"Sunny in {city}" + class AdmitRun(AgentMiddleware): + async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + if phase == "resume": + raise MiddlewareFailure("Run denied") + await call_next() - weather_tool = FunctionTool( - name="get_weather", - description="Get the weather for a city", - func=get_weather, - approval_mode="always_require", - ) - function_call = Content.from_function_call( - call_id="provider-weather", - name="get_weather", - arguments={"city": "Seattle"}, - id="af-call-weather", - ) - approval_request = Content.from_function_approval_request( - id="af-call-weather", - function_call=function_call, - ) - agent = StubAgent( - updates=[AgentResponseUpdate(contents=[approval_request], role="assistant")], - default_options={"tools": [weather_tool]}, + def action() -> str: + executed.append("ran") + return "Done" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate( + role="assistant", + contents=[Content.from_function_call(id="action", call_id="action-call", name="action", arguments={})], + ) + + agent = Agent( + client=streaming_chat_client_stub(stream_fn), + tools=[FunctionTool(name="action", description="Action", func=action, approval_mode="always_require")], + middleware=[AdmitRun()], ) - wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) app = FastAPI() - add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + add_agent_framework_fastapi_endpoint(app, agent, path="/approval") client = TestClient(app) pause = client.post( + "/approval", + json={"runId": "pause", "threadId": "admit-run", "messages": [{"role": "user", "content": "Act"}]}, + ) + finished = [event for event in _decode_sse_events(pause) if event["type"] == "RUN_FINISHED"] + interrupt = _run_finished_interrupts(finished[-1])[0] + phase = "resume" + resumed = client.post( "/approval", json={ - "runId": "run-pause", - "threadId": "thread-legacy-message", - "messages": [{"role": "user", "content": "Weather?"}], + "runId": "resume", + "threadId": "admit-run", + "messages": [], + "resume": [{"interruptId": interrupt["id"], "status": "resolved", "payload": {"approved": True}}], }, ) - assert pause.status_code == 200 - agent.updates = [AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")] + events = _decode_sse_events(resumed) + assert [event["code"] for event in events if event["type"] == "RUN_ERROR"] == ["MiddlewareFailure"] + assert executed == [] + assert not [event for event in events if event["type"] == "TOOL_CALL_RESULT"] - with caplog.at_level(logging.WARNING, logger="agent_framework"): - response = client.post( - "/approval", - json={ - "runId": "run-resume", - "threadId": "thread-legacy-message", - "messages": [ - { - "role": "assistant", - "toolCalls": [ - { - "id": "provider-weather", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"city":"Seattle"}', - }, - } - ], - }, - { - "role": "tool", - "toolCallId": "provider-weather", - "content": '{"accepted":true}', - }, - ], - }, - ) - assert response.status_code == 200 - events = _decode_sse_events(response) - assert executed == ["Seattle"], events - assert "Translated a legacy AG-UI tool-message approval" in caplog.text +async def test_endpoint_a2ui_approval_completes_internal_render_handoff(streaming_chat_client_stub) -> None: + """A resumed mixed batch settles its internal UI handoff even when rendering returns an error envelope.""" + pytest.importorskip("ag_ui_a2ui_toolkit") + from agent_framework import ChatResponse, ResponseStream + from agent_framework_ag_ui._a2ui import enable_a2ui -async def test_endpoint_agent_legacy_tool_message_reuses_historical_confirm_changes_call_id() -> None: - """A sole pending local call may reuse an older synthetic confirmation call id.""" + phase = "pause" executed: list[str] = [] + render_requests: list[bool] = [] - def guarded_tool(value: str) -> str: - executed.append(value) - return value + class RenderClient: + def get_response(self, *args: Any, stream: bool = False, **kwargs: Any) -> Any: + render_requests.append(stream) + if stream: - tool = FunctionTool(name="guarded_tool", description="Guarded", func=guarded_tool) - agent = StubAgent( - updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], - default_options={"tools": [tool]}, - ) - wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) - wrapped_agent._approval_state_store.lifecycle.register( - owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-reused-confirm-id", - interrupt_id="af-call-current", - call_id="provider-reused", - name="guarded_tool", - arguments='{"value":"current"}', + async def updates() -> AsyncIterator[ChatResponseUpdate]: + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Cannot render")]) + + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + + async def response() -> ChatResponse: + return ChatResponse(messages=[Message(role="assistant", contents=[Content.from_text("Cannot render")])]) + + return response() + + def save() -> str: + executed.append("saved") + return "Saved" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if phase == "pause": + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call(id="save", call_id="save-call", name="save", arguments={}), + Content.from_function_call( + id="generate", call_id="generate-call", name="generate_a2ui", arguments={"intent": "create"} + ), + ], + ) + else: + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + + agent = Agent( + client=streaming_chat_client_stub(stream_fn), + tools=[FunctionTool(name="save", description="Save", func=save, approval_mode="always_require")], ) + wrapped = AgentFrameworkAgent(agent=cast(SupportsAgentRun, enable_a2ui(agent, RenderClient()))) app = FastAPI() - add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") - - response = TestClient(app).post( + add_agent_framework_fastapi_endpoint(app, wrapped, path="/approval") + client = TestClient(app) + pause = client.post( "/approval", json={ - "runId": "run-reused-confirm-id", - "threadId": "thread-reused-confirm-id", - "messages": [ - { - "role": "assistant", - "toolCalls": [ - { - "id": "provider-reused", - "type": "function", - "function": {"name": "confirm_changes", "arguments": "{}"}, - } - ], - }, - {"role": "tool", "toolCallId": "provider-reused", "content": '{"accepted":true}'}, - { - "role": "assistant", - "toolCalls": [ - { - "id": "provider-reused", - "type": "function", - "function": {"name": "guarded_tool", "arguments": '{"value":"current"}'}, - } - ], - }, - {"role": "tool", "toolCallId": "provider-reused", "content": '{"accepted":true}'}, - ], + "runId": "pause", + "threadId": "render-handoff", + "messages": [{"role": "user", "content": "Save and render"}], }, ) - - assert response.status_code == 200 - events = _decode_sse_events(response) - assert executed == ["current"], events - assert not [event for event in events if event.get("type") == "RUN_ERROR"] + finished = [event for event in _decode_sse_events(pause) if event["type"] == "RUN_FINISHED"] + interrupts = _run_finished_interrupts(finished[-1]) + assert len(interrupts) == 1 + assert executed == [] + assert render_requests == [] + phase = "resume" + resumed = client.post( + "/approval", + json={ + "runId": "resume", + "threadId": "render-handoff", + "messages": [], + "resume": [{"interruptId": interrupts[0]["id"], "status": "resolved", "payload": {"approved": True}}], + }, + ) + events = _decode_sse_events(resumed) + assert not [event for event in events if event["type"] == "RUN_ERROR"] + assert Counter(event["toolCallId"] for event in events if event["type"] == "TOOL_CALL_RESULT") == { + "save-call": 1, + "generate-call": 1, + } + assert executed == ["saved"] + assert render_requests + completed_render_count = len(render_requests) + replayed = client.post( + "/approval", + json={ + "runId": "replay", + "threadId": "render-handoff", + "messages": [], + "resume": [{"interruptId": interrupts[0]["id"], "status": "resolved", "payload": {"approved": True}}], + }, + ) + replayed_events = _decode_sse_events(replayed) + assert not [event for event in replayed_events if event["type"] == "RUN_ERROR"] + assert Counter(event["toolCallId"] for event in replayed_events if event["type"] == "TOOL_CALL_RESULT") == { + "save-call": 1, + "generate-call": 1, + } + assert executed == ["saved"] + assert len(render_requests) == completed_render_count -async def test_endpoint_agent_legacy_tool_message_rejects_reused_call_id( - caplog: pytest.LogCaptureFixture, -) -> None: - """A provider call id shared by retained occurrences cannot authorize either one.""" +async def test_endpoint_approved_tool_and_continuation_share_provider_preparation(streaming_chat_client_stub) -> None: + """An approved tool and its model continuation use one prepared provider context.""" + from agent_framework import FunctionInvocationContext + + class BindPreparation(FunctionMiddleware): + def __init__(self, generation: int) -> None: + self.generation = generation + + async def process(self, context: FunctionInvocationContext, call_next: Any) -> None: + context.kwargs["preparation"] = self.generation + await call_next() + + class PreparationProvider(ContextProvider): + generation = 0 + + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + self.generation += 1 + context.extend_middleware(self.source_id, [BindPreparation(self.generation)]) + context.extend_messages( + self.source_id, + [Message(role="system", contents=[Content.from_text(f"Prepared context {self.generation}")])], + ) + + def approved_action(context: FunctionInvocationContext) -> str: + return f"Executed with preparation {context.kwargs['preparation']}" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + result = next( + ( + content.result + for message in messages + for content in message.contents + if content.type == "function_result" + ), + None, + ) + if result is None: + yield ChatResponseUpdate( + role="assistant", + contents=[Content.from_function_call(call_id="approved-action", name="approved_action", arguments={})], + ) + return + prepared_context = next(message.text for message in messages if message.text.startswith("Prepared context ")) + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text(f"{prepared_context}; {result}")]) + + agent = Agent( + client=streaming_chat_client_stub(stream_fn), + context_providers=[PreparationProvider("preparation")], + tools=[ + FunctionTool( + name="approved_action", + description="Approved action", + func=approved_action, + approval_mode="always_require", + ) + ], + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, agent, path="/approval") + client = TestClient(app) + pause = client.post( + "/approval", + json={"runId": "pause", "threadId": "prepared-run", "messages": [{"role": "user", "content": "Act"}]}, + ) + finished = [event for event in _decode_sse_events(pause) if event["type"] == "RUN_FINISHED"] + interrupt = _run_finished_interrupts(finished[-1])[0] + resumed = client.post( + "/approval", + json={ + "runId": "resume", + "threadId": "prepared-run", + "messages": [], + "resume": [{"interruptId": interrupt["id"], "status": "resolved", "payload": {"approved": True}}], + }, + ) + events = _decode_sse_events(resumed) + assert not [event for event in events if event["type"] == "RUN_ERROR"] + assert "".join(event["delta"] for event in events if event["type"] == "TEXT_MESSAGE_CONTENT") == ( + "Prepared context 2; Executed with preparation 2" + ) + + +async def test_endpoint_agent_approval_batch_keeps_distinct_occurrences_for_reused_call_id() -> None: + """Unique interrupts sharing a provider call ID each execute and settle exactly once.""" executed: list[str] = [] def first_tool() -> str: executed.append("first") - return "first" + return "first result" def second_tool() -> str: executed.append("second") - return "second" + return "second result" tools = [ FunctionTool(name="first_tool", description="First", func=first_tool), @@ -3455,79 +3603,74 @@ def second_tool() -> str: ) wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) lifecycle = wrapped_agent._approval_state_store.lifecycle - lifecycle.register( + first = lifecycle.register( owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-legacy-reused", + thread_id="thread-shared-call", interrupt_id="approval-first", - call_id="provider-reused", + call_id="call-shared", name="first_tool", arguments="{}", ) - lifecycle.register( + second = lifecycle.register( owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-legacy-reused", + thread_id="thread-shared-call", interrupt_id="approval-second", - call_id="provider-reused", + call_id="call-shared", name="second_tool", arguments="{}", ) app = FastAPI() add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") - with caplog.at_level(logging.WARNING, logger="agent_framework"): - response = TestClient(app).post( - "/approval", - json={ - "runId": "run-legacy-reused", - "threadId": "thread-legacy-reused", - "messages": [ - { - "role": "assistant", - "toolCalls": [ - { - "id": "provider-reused", - "type": "function", - "function": {"name": "first_tool", "arguments": "{}"}, - } - ], - }, - { - "role": "tool", - "toolCallId": "provider-reused", - "content": '{"accepted":true}', - }, - ], - }, - ) + response = TestClient(app).post( + "/approval", + json={ + "runId": "run-shared-call", + "threadId": "thread-shared-call", + "messages": [], + "resume": [ + {"interruptId": "approval-first", "status": "resolved", "payload": {"approved": True}}, + {"interruptId": "approval-second", "status": "resolved", "payload": {"approved": True}}, + ], + }, + ) assert response.status_code == 200 events = _decode_sse_events(response) - assert executed == [] - assert [event for event in events if event.get("type") == "RUN_ERROR"] - assert "does not identify exactly one retained pending local occurrence" in events[-1]["message"] - - -async def test_endpoint_agent_legacy_tool_message_cannot_collide_with_interrupt_id() -> None: - """An unknown provider call id cannot be reinterpreted as a canonical interrupt id.""" - executed: list[str] = [] + assert not [event for event in events if event.get("type") == "RUN_ERROR"] + assert lifecycle.get(first.identity).status is ApprovalStatus.SETTLED + assert lifecycle.get(second.identity).status is ApprovalStatus.SETTLED + assert executed == ["first", "second"] + assert [event["content"] for event in events if event.get("type") == "TOOL_CALL_RESULT"] == [ + "first result", + "second result", + ] - def guarded_tool() -> str: - executed.append("ran") - return "done" - tool = FunctionTool(name="guarded_tool", description="Guarded", func=guarded_tool) +async def test_endpoint_agent_unavailable_local_executor_releases_every_unstarted_batch_intent() -> None: + """A local executor failure leaves local and hosted siblings retryable.""" agent = StubAgent( - updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], - default_options={"tools": [tool]}, + updates=[AgentResponseUpdate(contents=[Content.from_text(text="Should not run.")], role="assistant")], + default_options={"tools": []}, ) wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) - wrapped_agent._approval_state_store.lifecycle.register( + lifecycle = wrapped_agent._approval_state_store.lifecycle + local = lifecycle.register( owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-legacy-collision", - interrupt_id="af-call-secret", - call_id="provider-real", - name="guarded_tool", + thread_id="thread-unavailable-batch", + interrupt_id="approval-local", + call_id="call-local", + name="missing_local_tool", + arguments="{}", + ) + hosted = lifecycle.register( + owner=ApprovalExecutionOwner.HOSTED, + thread_id="thread-unavailable-batch", + interrupt_id="approval-hosted", + call_id="call-hosted", + name="hosted_tool", arguments="{}", + server_label="hosted-server", ) app = FastAPI() add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") @@ -3535,187 +3678,1409 @@ def guarded_tool() -> str: response = TestClient(app).post( "/approval", json={ - "runId": "run-legacy-collision", - "threadId": "thread-legacy-collision", - "messages": [ - { - "role": "assistant", - "toolCalls": [ - { - "id": "af-call-secret", - "type": "function", - "function": {"name": "guarded_tool", "arguments": "{}"}, - } - ], - }, - { - "role": "tool", - "actionExecutionId": "af-call-secret", - "content": None, - "result": {"accepted": True}, - }, + "runId": "run-unavailable-batch", + "threadId": "thread-unavailable-batch", + "messages": [], + "resume": [ + {"interruptId": "approval-local", "status": "resolved", "payload": {"approved": True}}, + {"interruptId": "approval-hosted", "status": "resolved", "payload": {"approved": True}}, ], }, ) - assert response.status_code == 200 - assert executed == [] - events = _decode_sse_events(response) - assert events[-1]["code"] == "APPROVAL_RESUME_REQUIRED" + assert response.status_code == 200 + errors = [event for event in _decode_sse_events(response) if event.get("type") == "RUN_ERROR"] + assert [error["code"] for error in errors] == ["APPROVAL_TOOL_UNAVAILABLE"] + assert lifecycle.get(local.identity).status is ApprovalStatus.PENDING + assert lifecycle.get(hosted.identity).status is ApprovalStatus.PENDING + + +def _build_mixed_approval_batch_endpoint( + streaming_chat_client_stub: Any, + *, + snapshot_store: InMemoryAGUIThreadSnapshotStore | None = None, +) -> tuple[TestClient, list[str], list[Message], dict[str, Any]]: + executed: list[str] = [] + messages_received: list[Message] = [] + state: dict[str, Any] = {"phase": "pause"} + + def sensitive_action(city: str) -> str: + executed.append(f"sensitive:{city}") + return f"Sensitive action in {city}" + + def lookup_weather(city: str) -> str: + executed.append(f"weather:{city}") + return f"Weather in {city}" + + gated_tool = FunctionTool( + name="sensitive_action", + description="Run a sensitive city action", + func=sensitive_action, + approval_mode="always_require", + ) + sibling_tool = FunctionTool( + name="lookup_weather", + description="Look up weather", + func=lookup_weather, + ) + + async def stream_fn( + messages: list[Message], + options: dict[str, Any], + **kwargs: Any, + ) -> AsyncIterator[ChatResponseUpdate]: + del options, kwargs + if state["phase"] == "pause": + yield ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_sensitive", + name="sensitive_action", + arguments={"city": "Seattle"}, + ), + Content.from_function_call( + call_id="call_weather", + name="lookup_weather", + arguments={"city": "Seattle"}, + ), + ], + role="assistant", + ) + return + messages_received[:] = list(messages) + yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") + + chat_client = streaming_chat_client_stub(stream_fn) + state["chat_client"] = chat_client + agent = Agent( + name="test_agent", + instructions="Test", + client=chat_client, + tools=[gated_tool, sibling_tool], + ) + app = FastAPI() + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + add_agent_framework_fastapi_endpoint( + app, + wrapped_agent, + path="/approval", + snapshot_store=snapshot_store, + snapshot_scope_resolver=(lambda _request: "tenant-a") if snapshot_store is not None else None, + ) + return TestClient(app), executed, messages_received, state + + +def _build_tool_approval_queue_endpoint( + streaming_chat_client_stub: Any, + *, + lifecycle: ApprovalLifecycle | None = None, +) -> tuple[TestClient, list[str], list[Message], dict[str, str], AgentFrameworkAgent]: + executed: list[str] = [] + messages_received: list[Message] = [] + state = {"phase": "pause"} + + def first_tool() -> str: + executed.append("first") + return "first result" + + def second_tool() -> str: + executed.append("second") + return "second result" + + async def stream_fn( + messages: list[Message], + options: dict[str, Any], + **kwargs: Any, + ) -> AsyncIterator[ChatResponseUpdate]: + del options, kwargs + if state["phase"] == "pause": + yield ChatResponseUpdate( + contents=[ + Content.from_function_call(call_id="call_first", name="first_tool", arguments="{}"), + Content.from_function_call(call_id="call_second", name="second_tool", arguments="{}"), + ], + role="assistant", + ) + return + messages_received[:] = list(messages) + yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") + + agent = Agent( + name="test_agent", + instructions="Test", + client=streaming_chat_client_stub(stream_fn), + tools=[ + FunctionTool(name="first_tool", description="First tool", func=first_tool, approval_mode="always_require"), + FunctionTool( + name="second_tool", description="Second tool", func=second_tool, approval_mode="always_require" + ), + ], + middleware=[ToolApprovalMiddleware()], + ) + app = FastAPI() + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + if lifecycle is not None: + wrapped_agent._approval_state_store.lifecycle = lifecycle + add_agent_framework_fastapi_endpoint( + app, + wrapped_agent, + path="/approval", + ) + return TestClient(app), executed, messages_received, state, wrapped_agent + + +def _build_tool_approval_auto_endpoint( + streaming_chat_client_stub: Any, +) -> tuple[TestClient, list[str], list[Message], dict[str, str]]: + executed: list[str] = [] + messages_received: list[Message] = [] + state = {"phase": "pause"} + + def auto_tool() -> str: + executed.append("auto") + return "auto result" + + def manual_tool() -> str: + executed.append("manual") + return "manual result" + + def auto_approve_auto_tool(function_call: Content) -> bool: + return function_call.name == "auto_tool" + + async def stream_fn( + messages: list[Message], + options: dict[str, Any], + **kwargs: Any, + ) -> AsyncIterator[ChatResponseUpdate]: + del options, kwargs + if state["phase"] == "pause": + yield ChatResponseUpdate( + contents=[ + Content.from_function_call(call_id="call_auto", name="auto_tool", arguments="{}"), + Content.from_function_call(call_id="call_manual", name="manual_tool", arguments="{}"), + ], + role="assistant", + ) + return + messages_received[:] = list(messages) + yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") + + agent = Agent( + name="test_agent", + instructions="Test", + client=streaming_chat_client_stub(stream_fn), + tools=[ + FunctionTool(name="auto_tool", description="Auto tool", func=auto_tool, approval_mode="always_require"), + FunctionTool( + name="manual_tool", description="Manual tool", func=manual_tool, approval_mode="always_require" + ), + ], + middleware=[ToolApprovalMiddleware(auto_approval_rules=[auto_approve_auto_tool])], + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint( + app, + AgentFrameworkAgent(agent=agent, require_confirmation=False), + path="/approval", + ) + return TestClient(app), executed, messages_received, state + + +async def test_endpoint_agent_approval_resume_entry_executes_approved_tool(): + """A resolved canonical approval resume should execute the pending approved tool.""" + client, _, executed_cities = _build_weather_approval_endpoint() + + response = client.post( + "/approval", + json={ + "runId": "run-resume", + "threadId": "thread-weather", + "messages": [], + "resume": [ + { + "interruptId": "call_get_weather", + "status": "resolved", + "payload": {"accepted": True}, + } + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + tool_results = [event for event in events if event.get("type") == "TOOL_CALL_RESULT"] + assert len(tool_results) == 1 + assert tool_results[0]["toolCallId"] == "call_get_weather" + assert tool_results[0]["content"] == "Sunny in Seattle" + assert executed_cities == ["Seattle"] + assert "outcome" not in [event for event in events if event.get("type") == "RUN_FINISHED"][-1] + + +async def test_endpoint_agent_legacy_tool_message_uses_unique_pending_call_id( + caplog: pytest.LogCaptureFixture, +) -> None: + """Legacy tool messages remain usable only while their provider call id is unambiguous.""" + executed: list[str] = [] + + def get_weather(city: str) -> str: + executed.append(city) + return f"Sunny in {city}" + + weather_tool = FunctionTool( + name="get_weather", + description="Get the weather for a city", + func=get_weather, + approval_mode="always_require", + ) + function_call = Content.from_function_call( + call_id="provider-weather", + name="get_weather", + arguments={"city": "Seattle"}, + id="af-call-weather", + ) + approval_request = Content.from_function_approval_request( + id="af-call-weather", + function_call=function_call, + ) + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[approval_request], role="assistant")], + default_options={"tools": [weather_tool]}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + client = TestClient(app) + pause = client.post( + "/approval", + json={ + "runId": "run-pause", + "threadId": "thread-legacy-message", + "messages": [{"role": "user", "content": "Weather?"}], + }, + ) + assert pause.status_code == 200 + agent.updates = [AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")] + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + response = client.post( + "/approval", + json={ + "runId": "run-resume", + "threadId": "thread-legacy-message", + "messages": [ + { + "role": "assistant", + "toolCalls": [ + { + "id": "provider-weather", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city":"Seattle"}', + }, + } + ], + }, + { + "role": "tool", + "toolCallId": "provider-weather", + "content": '{"accepted":true}', + }, + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + assert executed == ["Seattle"], events + assert "Translated a legacy AG-UI tool-message approval" in caplog.text + + +async def test_endpoint_agent_legacy_tool_message_reuses_historical_confirm_changes_call_id() -> None: + """A sole pending local call may reuse an older synthetic confirmation call id.""" + executed: list[str] = [] + + def guarded_tool(value: str) -> str: + executed.append(value) + return value + + tool = FunctionTool(name="guarded_tool", description="Guarded", func=guarded_tool) + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], + default_options={"tools": [tool]}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + wrapped_agent._approval_state_store.lifecycle.register( + owner=ApprovalExecutionOwner.LOCAL, + thread_id="thread-reused-confirm-id", + interrupt_id="af-call-current", + call_id="provider-reused", + name="guarded_tool", + arguments='{"value":"current"}', + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + + response = TestClient(app).post( + "/approval", + json={ + "runId": "run-reused-confirm-id", + "threadId": "thread-reused-confirm-id", + "messages": [ + { + "role": "assistant", + "toolCalls": [ + { + "id": "provider-reused", + "type": "function", + "function": {"name": "confirm_changes", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "toolCallId": "provider-reused", "content": '{"accepted":true}'}, + { + "role": "assistant", + "toolCalls": [ + { + "id": "provider-reused", + "type": "function", + "function": {"name": "guarded_tool", "arguments": '{"value":"current"}'}, + } + ], + }, + {"role": "tool", "toolCallId": "provider-reused", "content": '{"accepted":true}'}, + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + assert executed == ["current"], events + assert not [event for event in events if event.get("type") == "RUN_ERROR"] + + +async def test_endpoint_agent_legacy_tool_message_rejects_reused_call_id( + caplog: pytest.LogCaptureFixture, +) -> None: + """A provider call id shared by retained occurrences cannot authorize either one.""" + executed: list[str] = [] + + def first_tool() -> str: + executed.append("first") + return "first" + + def second_tool() -> str: + executed.append("second") + return "second" + + tools = [ + FunctionTool(name="first_tool", description="First", func=first_tool), + FunctionTool(name="second_tool", description="Second", func=second_tool), + ] + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], + default_options={"tools": tools}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + lifecycle = wrapped_agent._approval_state_store.lifecycle + lifecycle.register( + owner=ApprovalExecutionOwner.LOCAL, + thread_id="thread-legacy-reused", + interrupt_id="approval-first", + call_id="provider-reused", + name="first_tool", + arguments="{}", + ) + lifecycle.register( + owner=ApprovalExecutionOwner.LOCAL, + thread_id="thread-legacy-reused", + interrupt_id="approval-second", + call_id="provider-reused", + name="second_tool", + arguments="{}", + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + + with caplog.at_level(logging.WARNING, logger="agent_framework"): + response = TestClient(app).post( + "/approval", + json={ + "runId": "run-legacy-reused", + "threadId": "thread-legacy-reused", + "messages": [ + { + "role": "assistant", + "toolCalls": [ + { + "id": "provider-reused", + "type": "function", + "function": {"name": "first_tool", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "toolCallId": "provider-reused", + "content": '{"accepted":true}', + }, + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + assert executed == [] + assert [event for event in events if event.get("type") == "RUN_ERROR"] + assert "does not identify exactly one retained pending local occurrence" in events[-1]["message"] + + +async def test_endpoint_agent_legacy_tool_message_cannot_collide_with_interrupt_id() -> None: + """An unknown provider call id cannot be reinterpreted as a canonical interrupt id.""" + executed: list[str] = [] + + def guarded_tool() -> str: + executed.append("ran") + return "done" + + tool = FunctionTool(name="guarded_tool", description="Guarded", func=guarded_tool) + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], + default_options={"tools": [tool]}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + wrapped_agent._approval_state_store.lifecycle.register( + owner=ApprovalExecutionOwner.LOCAL, + thread_id="thread-legacy-collision", + interrupt_id="af-call-secret", + call_id="provider-real", + name="guarded_tool", + arguments="{}", + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + + response = TestClient(app).post( + "/approval", + json={ + "runId": "run-legacy-collision", + "threadId": "thread-legacy-collision", + "messages": [ + { + "role": "assistant", + "toolCalls": [ + { + "id": "af-call-secret", + "type": "function", + "function": {"name": "guarded_tool", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "actionExecutionId": "af-call-secret", + "content": None, + "result": {"accepted": True}, + }, + ], + }, + ) + + assert response.status_code == 200 + assert executed == [] + events = _decode_sse_events(response) + assert events[-1]["code"] == "APPROVAL_RESUME_REQUIRED" + + +async def test_endpoint_agent_legacy_tool_message_rejects_duplicate_decisions() -> None: + """Conflicting legacy decisions cannot select an earlier approval.""" + executed: list[str] = [] + + def guarded_tool() -> str: + executed.append("ran") + return "done" + + tool = FunctionTool(name="guarded_tool", description="Guarded", func=guarded_tool) + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], + default_options={"tools": [tool]}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + wrapped_agent._approval_state_store.lifecycle.register( + owner=ApprovalExecutionOwner.LOCAL, + thread_id="thread-legacy-duplicate", + interrupt_id="af-call-current", + call_id="provider-call", + name="guarded_tool", + arguments="{}", + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + + response = TestClient(app).post( + "/approval", + json={ + "runId": "run-legacy-duplicate", + "threadId": "thread-legacy-duplicate", + "messages": [ + { + "role": "assistant", + "toolCalls": [ + { + "id": "provider-call", + "type": "function", + "function": {"name": "guarded_tool", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "toolCallId": "provider-call", "content": '{"accepted":true}'}, + {"role": "tool", "toolCallId": "provider-call", "content": '{"accepted":false}'}, + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + assert executed == [] + assert events[-1]["code"] == "APPROVAL_RESUME_INVALID" + assert "repeats call_id" in events[-1]["message"] + + +async def test_endpoint_agent_historical_legacy_approval_cannot_authorize_newer_reused_call() -> None: + """A historical approval outside the submitted turn suffix remains inert.""" + executed: list[str] = [] + + def dangerous_tool(value: str) -> str: + executed.append(value) + return value + + tool = FunctionTool(name="dangerous_tool", description="Dangerous", func=dangerous_tool) + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], + default_options={"tools": [tool]}, + ) + wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) + wrapped_agent._approval_state_store.lifecycle.register( + owner=ApprovalExecutionOwner.LOCAL, + thread_id="thread-historical-legacy", + interrupt_id="af-call-current", + call_id="provider-reused", + name="dangerous_tool", + arguments='{"value":"new"}', + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") + + response = TestClient(app).post( + "/approval", + json={ + "runId": "run-historical-legacy", + "threadId": "thread-historical-legacy", + "messages": [ + { + "role": "assistant", + "toolCalls": [ + { + "id": "provider-reused", + "type": "function", + "function": { + "name": "old_tool", + "arguments": '{"value":"old"}', + }, + } + ], + }, + {"role": "tool", "toolCallId": "provider-reused", "content": '{"accepted":true}'}, + {"role": "user", "content": "Continue without approving anything."}, + ], + }, + ) + + assert response.status_code == 200 + events = _decode_sse_events(response) + assert executed == [] + assert events[-1]["code"] == "APPROVAL_RESUME_REQUIRED" + + +async def test_endpoint_agent_approval_resume_remains_retryable_when_local_tool_is_temporarily_unavailable(): + """A local approval can be retried after its executor disappears before resume.""" + client, agent, executed_cities = _build_weather_approval_endpoint(snapshot_store=InMemoryAGUIThreadSnapshotStore()) + weather_tool = agent.default_options["tools"][0] + agent.default_options["tools"] = [] + + unavailable_response = client.post( + "/approval", + json={ + "runId": "run-unavailable", + "threadId": "thread-weather", + "messages": [], + "resume": [{"interruptId": "call_get_weather", "status": "resolved", "payload": {"accepted": True}}], + }, + ) + + assert unavailable_response.status_code == 200 + unavailable_events = _decode_sse_events(unavailable_response) + run_errors = [event for event in unavailable_events if event.get("type") == "RUN_ERROR"] + assert len(run_errors) == 1 + assert run_errors[0]["code"] == "APPROVAL_TOOL_UNAVAILABLE" + assert "temporarily unavailable" in run_errors[0]["message"] + assert executed_cities == [] + + agent.default_options["tools"] = [weather_tool] + retry_response = client.post( + "/approval", + json={ + "runId": "run-retry", + "threadId": "thread-weather", + "messages": [], + "resume": [{"interruptId": "call_get_weather", "status": "resolved", "payload": {"accepted": True}}], + }, + ) + + assert retry_response.status_code == 200 + retry_events = _decode_sse_events(retry_response) + assert not [event for event in retry_events if event.get("type") == "RUN_ERROR"] + assert [ + (event["toolCallId"], event["content"]) for event in retry_events if event.get("type") == "TOOL_CALL_RESULT" + ] == [("call_get_weather", "Sunny in Seattle")] + assert executed_cities == ["Seattle"] + + +@pytest.mark.parametrize("a2ui_mode", ["none", "manual", "automatic"]) +async def test_endpoint_agent_approval_resume_remains_pending_when_invocation_is_disabled( + streaming_chat_client_stub, + a2ui_mode: str, +) -> None: + """A disabled local executor blocks an approved call without consuming its authority.""" + call_id = "call_local_action" + state = {"phase": "pause"} + local_executions: list[str] = [] + provider_resume_calls: list[str] = [] + function_call = Content.from_function_call( + call_id=call_id, + name="local_action", + arguments={"document": "Approved draft"}, + ) + + def local_action(document: str) -> str: + local_executions.append(document) + return "Draft applied" + + local_tool = FunctionTool( + name="local_action", + description="Apply an approved draft.", + func=local_action, + approval_mode="always_require", + ) + + async def stream_fn( + messages: list[Message], + options: dict[str, Any], + **kwargs: Any, + ) -> AsyncIterator[ChatResponseUpdate]: + del messages, options, kwargs + if state["phase"] == "pause": + yield ChatResponseUpdate( + contents=[Content.from_function_approval_request(id=call_id, function_call=function_call)], + role="assistant", + ) + return + provider_resume_calls.append("resumed") + yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") + + chat_client = cast(Any, streaming_chat_client_stub(stream_fn)) + agent = Agent(name="test_agent", instructions="Test", client=chat_client, tools=[local_tool]) + endpoint_agent: SupportsAgentRun = agent + if a2ui_mode != "none": + pytest.importorskip("ag_ui_a2ui_toolkit") + if a2ui_mode == "manual": + from agent_framework_ag_ui._a2ui import enable_a2ui + + endpoint_agent = cast(SupportsAgentRun, enable_a2ui(agent, object())) + wrapped_agent = AgentFrameworkAgent(agent=endpoint_agent, require_confirmation=False) + app = FastAPI() + add_agent_framework_fastapi_endpoint( + app, + wrapped_agent, + path="/approval", + snapshot_store=InMemoryAGUIThreadSnapshotStore(), + snapshot_scope_resolver=lambda _request: "tenant-a", + ) + client = TestClient(app) + thread_id = f"thread-disabled-resume-{a2ui_mode}" + forwarded_props = {"injectA2UITool": True} if a2ui_mode == "automatic" else {} + + pause_response = client.post( + "/approval", + json={ + "runId": "run-pause", + "threadId": thread_id, + "messages": [{"role": "user", "content": "Apply the draft"}], + "forwardedProps": forwarded_props, + }, + ) + pause_finished = [event for event in _decode_sse_events(pause_response) if event.get("type") == "RUN_FINISHED"] + assert _run_finished_interrupts(pause_finished[-1])[0]["id"] == call_id + + state["phase"] = "resume" + chat_client.function_invocation_configuration["enabled"] = False + resume = [{"interruptId": call_id, "status": "resolved", "payload": {"accepted": True}}] + blocked_response = client.post( + "/approval", + json={ + "runId": "run-blocked", + "threadId": thread_id, + "messages": [], + "resume": resume, + "forwardedProps": forwarded_props, + }, + ) + + blocked_events = _decode_sse_events(blocked_response) + blocked_errors = [event for event in blocked_events if event.get("type") == "RUN_ERROR"] + assert [(event["code"], event["message"]) for event in blocked_errors] == [ + ( + "APPROVAL_INVOCATION_DISABLED", + "Function invocation is disabled; the approved tool remains pending for an explicit retry.", + ) + ] + assert not [event for event in blocked_events if event.get("type") == "TOOL_CALL_RESULT"] + assert local_executions == [] + assert provider_resume_calls == [] + + repeated_blocked_response = client.post( + "/approval", + json={ + "runId": "run-blocked-again", + "threadId": thread_id, + "messages": [], + "resume": resume, + "forwardedProps": forwarded_props, + }, + ) + repeated_blocked_events = _decode_sse_events(repeated_blocked_response) + assert [event["code"] for event in repeated_blocked_events if event.get("type") == "RUN_ERROR"] == [ + "APPROVAL_INVOCATION_DISABLED" + ] + assert not [event for event in repeated_blocked_events if event.get("type") == "TOOL_CALL_RESULT"] + assert local_executions == [] + assert provider_resume_calls == [] + + chat_client.function_invocation_configuration["enabled"] = True + retry_response = client.post( + "/approval", + json={ + "runId": "run-retry", + "threadId": thread_id, + "messages": [], + "resume": resume, + "forwardedProps": forwarded_props, + }, + ) + + retry_events = _decode_sse_events(retry_response) + assert not [event for event in retry_events if event.get("type") == "RUN_ERROR"] + assert [ + (event["toolCallId"], event["content"]) for event in retry_events if event.get("type") == "TOOL_CALL_RESULT" + ] == [(call_id, "Draft applied")] + assert local_executions == ["Approved draft"] + assert provider_resume_calls == ["resumed"] + + +async def test_endpoint_agent_disabled_resume_releases_all_unstarted_local_grants( + streaming_chat_client_stub, +) -> None: + """A disabled mixed batch leaves every unstarted local grant retryable.""" + client, executed, _, state = _build_mixed_approval_batch_endpoint(streaming_chat_client_stub) + pause_response = client.post( + "/approval", + json={ + "runId": "run-pause", + "threadId": "thread-disabled-mixed-grants", + "messages": [{"role": "user", "content": "Run both tools"}], + }, + ) + pause_finished = [event for event in _decode_sse_events(pause_response) if event.get("type") == "RUN_FINISHED"] + approval_id = _run_finished_interrupts(pause_finished[-1])[0]["id"] + resume = [{"interruptId": approval_id, "status": "resolved", "payload": {"accepted": True}}] + + state["phase"] = "resume" + chat_client = state["chat_client"] + chat_client.function_invocation_configuration["enabled"] = False + blocked_response = client.post( + "/approval", + json={ + "runId": "run-blocked", + "threadId": "thread-disabled-mixed-grants", + "messages": [], + "resume": resume, + }, + ) + + blocked_events = _decode_sse_events(blocked_response) + assert [event["code"] for event in blocked_events if event.get("type") == "RUN_ERROR"] == [ + "APPROVAL_INVOCATION_DISABLED" + ] + assert not [event for event in blocked_events if event.get("type") == "TOOL_CALL_RESULT"] + assert executed == [] + + chat_client.function_invocation_configuration["enabled"] = True + retry_response = client.post( + "/approval", + json={ + "runId": "run-retry", + "threadId": "thread-disabled-mixed-grants", + "messages": [], + "resume": resume, + }, + ) + + retry_events = _decode_sse_events(retry_response) + assert not [event for event in retry_events if event.get("type") == "RUN_ERROR"] + assert [ + (event["toolCallId"], event["content"]) for event in retry_events if event.get("type") == "TOOL_CALL_RESULT" + ] == [ + ("call_sensitive", "Sensitive action in Seattle"), + ("call_weather", "Weather in Seattle"), + ] + assert executed == ["sensitive:Seattle", "weather:Seattle"] + + +@pytest.mark.parametrize("blocking_reason", ["disabled", "provider_failure", "provider_disable"]) +async def test_endpoint_disabled_resume_keeps_local_and_hosted_grants_retryable( + streaming_chat_client_stub, blocking_reason: str +) -> None: + """Blocking preparation must not strand any unstarted approval owner.""" + executed: list[str] = [] + phase = "pause" + local_call = Content.from_function_call( + id="local-occurrence", call_id="local-call", name="local_action", arguments={} + ) + hosted_call = Content.from_function_call( + call_id="hosted-call", + name="hosted_action", + arguments={}, + additional_properties={"server_label": "remote"}, + ) + + class PreparationProvider(ContextProvider): + async def before_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + if phase == "blocked" and blocking_reason == "provider_failure": + raise RuntimeError("Provider is temporarily unavailable") + if phase == "blocked" and blocking_reason == "provider_disable": + invocation = getattr(agent, "client") + assert isinstance(invocation, FunctionInvocationLayer) + invocation.function_invocation_configuration["enabled"] = False + + def local_action() -> str: + executed.append("local") + return "Local completed" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if phase == "pause": + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_approval_request(id="local-occurrence", function_call=local_call), + Content.from_function_approval_request(id="hosted-approval", function_call=hosted_call), + ], + ) + return + for message in messages: + for content in message.contents: + if content.type == "function_approval_response" and content.id == "hosted-approval": + assert content.approved is True + executed.append("hosted") + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + + chat_client = streaming_chat_client_stub(stream_fn) + agent = Agent( + client=chat_client, + context_providers=[PreparationProvider("preparation")], + tools=[ + FunctionTool( + name="local_action", description="Local action", func=local_action, approval_mode="always_require" + ) + ], + ) + assert isinstance(chat_client, FunctionInvocationLayer) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, agent, path="/approval") + client = TestClient(app) + pause = client.post( + "/approval", + json={ + "runId": "pause", + "threadId": "cross-owner", + "messages": [{"role": "user", "content": "Run both actions"}], + }, + ) + finished = [event for event in _decode_sse_events(pause) if event["type"] == "RUN_FINISHED"] + interrupts = _run_finished_interrupts(finished[-1]) + assert {interrupt["id"] for interrupt in interrupts} == {"local-occurrence", "hosted-approval"} + resume = [ + {"interruptId": interrupt["id"], "status": "resolved", "payload": {"approved": True}} + for interrupt in interrupts + ] + phase = "blocked" + chat_client.function_invocation_configuration["enabled"] = blocking_reason != "disabled" + for run_id in ("blocked", "blocked-again"): + response = client.post( + "/approval", + json={"runId": run_id, "threadId": "cross-owner", "messages": [], "resume": resume}, + ) + assert [event["code"] for event in _decode_sse_events(response) if event["type"] == "RUN_ERROR"] == [ + "RuntimeError" if blocking_reason == "provider_failure" else "APPROVAL_INVOCATION_DISABLED" + ] + assert executed == [] + + phase = "resume" + chat_client.function_invocation_configuration["enabled"] = True + for run_id in ("retry", "completed-replay"): + response = client.post( + "/approval", + json={"runId": run_id, "threadId": "cross-owner", "messages": [], "resume": resume}, + ) + assert not [event for event in _decode_sse_events(response) if event["type"] == "RUN_ERROR"] + assert executed == ["local", "hosted"] + + +@pytest.mark.parametrize("failure_point", ["middleware", "tool"]) +async def test_endpoint_fatal_approval_preserves_unstarted_siblings( + streaming_chat_client_stub, failure_point: str +) -> None: + """A denied local approval must not strand untouched local or hosted siblings.""" + executed: list[str] = [] + phase = "pause" + + class DenyFirst(FunctionMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + if context.function.name == "denied_action" and failure_point == "middleware": + raise MiddlewareFailure("Action denied") + if context.function.name == "allowed_action" and phase == "resume": + # Keep this sibling outside invocation while core cancels the failed parallel batch. + await asyncio.Event().wait() + await call_next() + + def denied_action() -> str: + executed.append("denied") + raise MiddlewareFailure("Action failed after starting") + + def allowed_action() -> str: + executed.append("allowed") + return "Allowed action completed" + + calls = [ + Content.from_function_call(id="denied", call_id="denied-call", name="denied_action", arguments={}), + Content.from_function_call(id="allowed", call_id="allowed-call", name="allowed_action", arguments={}), + Content.from_function_call( + id="hosted", + call_id="hosted-call", + name="hosted_action", + arguments={}, + additional_properties={"server_label": "remote"}, + ), + ] + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if phase == "pause": + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_approval_request(id=identity, function_call=call) + for identity, call in zip(("denied", "allowed", "hosted"), calls, strict=True) + ], + ) + return + for message in messages: + for content in message.contents: + if content.type == "function_approval_response" and content.id == "hosted": + assert content.approved is True + executed.append("hosted") + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + + agent = Agent( + client=streaming_chat_client_stub(stream_fn), + tools=[ + FunctionTool( + name="denied_action", description="Denied action", func=denied_action, approval_mode="always_require" + ), + FunctionTool( + name="allowed_action", description="Allowed action", func=allowed_action, approval_mode="always_require" + ), + ], + middleware=[DenyFirst()], + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, agent, path="/approval") + client = TestClient(app) + pause = client.post( + "/approval", + json={"runId": "pause", "threadId": "fatal-batch", "messages": [{"role": "user", "content": "Act"}]}, + ) + finished = [event for event in _decode_sse_events(pause) if event["type"] == "RUN_FINISHED"] + assert {item["id"] for item in _run_finished_interrupts(finished[-1])} == {"denied", "allowed", "hosted"} + phase = "resume" + failed = client.post( + "/approval", + json={ + "runId": "denied", + "threadId": "fatal-batch", + "messages": [], + "resume": [ + {"interruptId": identity, "status": "resolved", "payload": {"approved": True}} + for identity in ("denied", "allowed", "hosted") + ], + }, + ) + assert [event["code"] for event in _decode_sse_events(failed) if event["type"] == "RUN_ERROR"] == [ + "MiddlewareFailure" + ] + failed_effects = ["denied"] if failure_point == "tool" else [] + assert executed == failed_effects + + denied_retry = client.post( + "/approval", + json={ + "runId": "unsafe-retry", + "threadId": "fatal-batch", + "messages": [], + "resume": [ + {"interruptId": identity, "status": "resolved", "payload": {"approved": True}} + for identity in ("denied", "allowed", "hosted") + ], + }, + ) + assert [event for event in _decode_sse_events(denied_retry) if event["type"] == "RUN_ERROR"] + assert executed == failed_effects + + phase = "retry" + for run_id in ("retry-siblings", "replay-completed"): + response = client.post( + "/approval", + json={ + "runId": run_id, + "threadId": "fatal-batch", + "messages": [], + "resume": [ + *([{"interruptId": "denied", "status": "cancelled"}] if failure_point == "middleware" else []), + *[ + {"interruptId": identity, "status": "resolved", "payload": {"approved": True}} + for identity in ("allowed", "hosted") + ], + ], + }, + ) + assert not [event for event in _decode_sse_events(response) if event["type"] == "RUN_ERROR"] + assert executed == [*failed_effects, "allowed", "hosted"] + + +@pytest.mark.parametrize("reuse_call_id", [False, True]) +async def test_endpoint_retained_and_pending_occurrences_resume_independently( + streaming_chat_client_stub, reuse_call_id: bool +) -> None: + """A retained result cannot invalidate a distinct pending approval with the same provider id.""" + phase = "first" + executed: list[str] = [] + + def record(value: str) -> str: + executed.append(value) + return f"Recorded {value}" + + async def stream_fn( + messages: list[Message], options: dict[str, Any], **kwargs: Any + ) -> AsyncIterator[ChatResponseUpdate]: + if phase == "complete": + yield ChatResponseUpdate(role="assistant", contents=[Content.from_text("Done.")]) + return + call = Content.from_function_call( + id=phase, + call_id="shared" if reuse_call_id else f"provider-{phase}", + name="record", + arguments={"value": phase}, + ) + yield ChatResponseUpdate( + role="assistant", contents=[Content.from_function_approval_request(id=phase, function_call=call)] + ) + + agent = Agent( + client=streaming_chat_client_stub(stream_fn), + tools=[FunctionTool(name="record", description="Record", func=record, approval_mode="always_require")], + ) + app = FastAPI() + add_agent_framework_fastapi_endpoint(app, agent, path="/approval") + client = TestClient(app) + for occurrence_id in ("first", "second"): + phase = occurrence_id + pause = client.post( + "/approval", + json={ + "runId": f"pause-{occurrence_id}", + "threadId": "retained-occurrences", + "messages": [{"role": "user", "content": f"Record {occurrence_id}"}], + }, + ) + finished = [event for event in _decode_sse_events(pause) if event["type"] == "RUN_FINISHED"] + assert [item["id"] for item in _run_finished_interrupts(finished[-1])] == [occurrence_id] + if occurrence_id == "first": + phase = "complete" + completed = client.post( + "/approval", + json={ + "runId": "complete-first", + "threadId": "retained-occurrences", + "messages": [], + "resume": [{"interruptId": "first", "status": "resolved", "payload": {"approved": True}}], + }, + ) + assert not [event for event in _decode_sse_events(completed) if event["type"] == "RUN_ERROR"] + assert executed == ["first"] + + phase = "complete" + for run_id in ("complete-both", "repeat-completed"): + response = client.post( + "/approval", + json={ + "runId": run_id, + "threadId": "retained-occurrences", + "messages": [], + "resume": [ + {"interruptId": identity, "status": "resolved", "payload": {"approved": True}} + for identity in ("first", "second") + ], + }, + ) + events = _decode_sse_events(response) + assert not [event for event in events if event["type"] == "RUN_ERROR"] + assert executed == ["first", "second"] + assert [event["content"] for event in events if event["type"] == "TOOL_CALL_RESULT"] == [ + "Recorded first", + "Recorded second", + ] -async def test_endpoint_agent_legacy_tool_message_rejects_duplicate_decisions() -> None: - """Conflicting legacy decisions cannot select an earlier approval.""" +async def test_endpoint_agent_disabled_mixed_resume_preserves_terminal_progress_for_retry() -> None: + """A blocked mixed resume keeps grants retryable without reviving terminal siblings.""" executed: list[str] = [] + observed_non_grants: list[tuple[str | None, bool]] = [] - def guarded_tool() -> str: - executed.append("ran") - return "done" + class ObserveNonGrants(FunctionMiddleware): + async def process(self, context: Any, call_next: Any) -> None: + del context + await call_next() - tool = FunctionTool(name="guarded_tool", description="Guarded", func=guarded_tool) - agent = StubAgent( - updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], + def _on_approval_responses( + self, + responses: Sequence[Content], + *, + session: AgentSession | None, + ) -> None: + del session + observed_non_grants.extend( + ( + response.function_call.call_id if response.function_call else None, + response.additional_properties.get("cancelled") is True, + ) + for response in responses + ) + + def record_city(city: str) -> str: + executed.append(city) + return f"Recorded {city}" + + tool = FunctionTool( + name="record_city", + description="Record a city", + func=record_city, + approval_mode="always_require", + ) + approval_requests = [ + Content.from_function_approval_request( + id=f"approval-{city.lower()}", + function_call=Content.from_function_call( + call_id=f"call-{city.lower()}", + name="record_city", + arguments={"city": city}, + ), + ) + for city in ("Seattle", "Portland", "Tokyo") + ] + client_config = Mock(function_invocation_configuration={"enabled": True}) + + class ObservedAgent(StubAgent): + middleware = [ObserveNonGrants()] + + agent = ObservedAgent( + updates=[AgentResponseUpdate(contents=approval_requests, role="assistant")], default_options={"tools": [tool]}, + client=client_config, ) wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) - wrapped_agent._approval_state_store.lifecycle.register( - owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-legacy-duplicate", - interrupt_id="af-call-current", - call_id="provider-call", - name="guarded_tool", - arguments="{}", - ) + snapshot_store = InMemoryAGUIThreadSnapshotStore() app = FastAPI() - add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") - - response = TestClient(app).post( + add_agent_framework_fastapi_endpoint( + app, + wrapped_agent, + path="/approval", + snapshot_store=snapshot_store, + snapshot_scope_resolver=lambda _request: "tenant-a", + ) + client = TestClient(app) + thread_id = "thread-disabled-mixed-decisions" + pause = client.post( "/approval", json={ - "runId": "run-legacy-duplicate", - "threadId": "thread-legacy-duplicate", - "messages": [ - { - "role": "assistant", - "toolCalls": [ - { - "id": "provider-call", - "type": "function", - "function": {"name": "guarded_tool", "arguments": "{}"}, - } - ], - }, - {"role": "tool", "toolCallId": "provider-call", "content": '{"accepted":true}'}, - {"role": "tool", "toolCallId": "provider-call", "content": '{"accepted":false}'}, - ], + "runId": "run-pause", + "threadId": thread_id, + "messages": [{"role": "user", "content": "Record three cities"}], }, ) + pause_finished = [event for event in _decode_sse_events(pause) if event.get("type") == "RUN_FINISHED"] + assert {interrupt["id"] for interrupt in _run_finished_interrupts(pause_finished[-1])} == { + "call-seattle", + "call-portland", + "call-tokyo", + } - assert response.status_code == 200 - events = _decode_sse_events(response) - assert executed == [] - assert events[-1]["code"] == "APPROVAL_RESUME_INVALID" - assert "repeats call_id" in events[-1]["message"] + agent.updates = [AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")] + mixed_resume = [ + { + "interruptId": "call-seattle", + "status": "resolved", + "payload": {"approved": True}, + }, + { + "interruptId": "call-portland", + "status": "resolved", + "payload": {"approved": False}, + }, + {"interruptId": "call-tokyo", "status": "cancelled"}, + ] + client_config.function_invocation_configuration["enabled"] = False + for run_id in ("run-blocked", "run-blocked-again"): + blocked = client.post( + "/approval", + json={"runId": run_id, "threadId": thread_id, "messages": [], "resume": mixed_resume}, + ) + blocked_events = _decode_sse_events(blocked) + assert [event["code"] for event in blocked_events if event.get("type") == "RUN_ERROR"] == [ + "APPROVAL_INVOCATION_DISABLED" + ] + assert not [event for event in blocked_events if event.get("type") == "TOOL_CALL_RESULT"] + assert executed == [] -async def test_endpoint_agent_historical_legacy_approval_cannot_authorize_newer_reused_call() -> None: - """A historical approval outside the submitted turn suffix remains inert.""" + assert set(observed_non_grants) == { + ("call-portland", False), + ("call-tokyo", True), + } + snapshot = await snapshot_store.get(scope="tenant-a", thread_id=thread_id) + assert snapshot is not None and snapshot.interrupt is not None + assert {interrupt["id"] for interrupt in snapshot.interrupt} == {"call-seattle"} + + client_config.function_invocation_configuration["enabled"] = True + retry = client.post( + "/approval", + json={"runId": "run-retry", "threadId": thread_id, "messages": [], "resume": mixed_resume}, + ) + retry_events = _decode_sse_events(retry) + assert not [event for event in retry_events if event.get("type") == "RUN_ERROR"] + assert [ + (event["toolCallId"], event["content"]) for event in retry_events if event.get("type") == "TOOL_CALL_RESULT" + ] == [("call-seattle", "Recorded Seattle")] + assert executed == ["Seattle"] + + +async def test_endpoint_agent_mixed_retry_reprojects_completed_sibling_without_reexecution() -> None: + """A completed sibling is replayed while only the unfinished approval executes.""" executed: list[str] = [] - def dangerous_tool(value: str) -> str: - executed.append(value) - return value + def record_city(city: str) -> str: + executed.append(city) + return f"Recorded {city}" - tool = FunctionTool(name="dangerous_tool", description="Dangerous", func=dangerous_tool) + tool = FunctionTool(name="record_city", description="Record a city", func=record_city) agent = StubAgent( updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], default_options={"tools": [tool]}, ) wrapped_agent = AgentFrameworkAgent(agent=agent, require_confirmation=False) - wrapped_agent._approval_state_store.lifecycle.register( + lifecycle = wrapped_agent._approval_state_store.lifecycle + completed = lifecycle.register( owner=ApprovalExecutionOwner.LOCAL, - thread_id="thread-historical-legacy", - interrupt_id="af-call-current", - call_id="provider-reused", - name="dangerous_tool", - arguments='{"value":"new"}', + thread_id="thread-completed-sibling", + interrupt_id="approval-seattle", + call_id="call-seattle", + name="record_city", + arguments='{"city":"Seattle"}', + ) + completed_intent = lifecycle.claim( + thread_id="thread-completed-sibling", + decision=ResumeDecision( + interrupt_id="approval-seattle", + accepted=True, + arguments='{"city":"Seattle"}', + name="record_city", + original_arguments='{"city":"Seattle"}', + ), + ) + lifecycle.begin_execution(completed_intent, owner=ApprovalExecutionOwner.LOCAL) + lifecycle.settle( + completed_intent, + [Content.from_function_result(call_id=completed.identity.call_id, result="Recorded Seattle")], + ) + lifecycle.register( + owner=ApprovalExecutionOwner.LOCAL, + thread_id="thread-completed-sibling", + interrupt_id="approval-portland", + call_id="call-portland", + name="record_city", + arguments='{"city":"Portland"}', ) app = FastAPI() add_agent_framework_fastapi_endpoint(app, wrapped_agent, path="/approval") - - response = TestClient(app).post( - "/approval", - json={ - "runId": "run-historical-legacy", - "threadId": "thread-historical-legacy", - "messages": [ - { - "role": "assistant", - "toolCalls": [ - { - "id": "provider-reused", - "type": "function", - "function": { - "name": "old_tool", - "arguments": '{"value":"old"}', - }, - } - ], - }, - {"role": "tool", "toolCallId": "provider-reused", "content": '{"accepted":true}'}, - {"role": "user", "content": "Continue without approving anything."}, - ], + client = TestClient(app) + resume = [ + { + "interruptId": "approval-seattle", + "status": "resolved", + "payload": {"approved": True}, }, - ) - - assert response.status_code == 200 - events = _decode_sse_events(response) - assert executed == [] - assert events[-1]["code"] == "APPROVAL_RESUME_REQUIRED" - - -async def test_endpoint_agent_approval_resume_remains_retryable_when_local_tool_is_temporarily_unavailable(): - """A local approval can be retried after its executor disappears before resume.""" - client, agent, executed_cities = _build_weather_approval_endpoint(snapshot_store=InMemoryAGUIThreadSnapshotStore()) - weather_tool = agent.default_options["tools"][0] - agent.default_options["tools"] = [] - - unavailable_response = client.post( - "/approval", - json={ - "runId": "run-unavailable", - "threadId": "thread-weather", - "messages": [], - "resume": [{"interruptId": "call_get_weather", "status": "resolved", "payload": {"accepted": True}}], + { + "interruptId": "approval-portland", + "status": "resolved", + "payload": {"approved": True}, }, - ) - - assert unavailable_response.status_code == 200 - unavailable_events = _decode_sse_events(unavailable_response) - run_errors = [event for event in unavailable_events if event.get("type") == "RUN_ERROR"] - assert len(run_errors) == 1 - assert run_errors[0]["code"] == "APPROVAL_TOOL_UNAVAILABLE" - assert "temporarily unavailable" in run_errors[0]["message"] - assert executed_cities == [] + ] - agent.default_options["tools"] = [weather_tool] - retry_response = client.post( + response = client.post( "/approval", json={ - "runId": "run-retry", - "threadId": "thread-weather", + "runId": "run-completed-sibling", + "threadId": "thread-completed-sibling", "messages": [], - "resume": [{"interruptId": "call_get_weather", "status": "resolved", "payload": {"accepted": True}}], + "resume": resume, }, ) - assert retry_response.status_code == 200 - retry_events = _decode_sse_events(retry_response) - assert not [event for event in retry_events if event.get("type") == "RUN_ERROR"] - assert [ - (event["toolCallId"], event["content"]) for event in retry_events if event.get("type") == "TOOL_CALL_RESULT" - ] == [("call_get_weather", "Sunny in Seattle")] - assert executed_cities == ["Seattle"] + events = _decode_sse_events(response) + assert not [event for event in events if event.get("type") == "RUN_ERROR"] + assert [(event["toolCallId"], event["content"]) for event in events if event.get("type") == "TOOL_CALL_RESULT"] == [ + ("call-seattle", "Recorded Seattle"), + ("call-portland", "Recorded Portland"), + ] + assert executed == ["Portland"] async def test_endpoint_agent_approval_resume_releases_already_approved_sibling(streaming_chat_client_stub): @@ -4375,7 +5740,7 @@ async def test_endpoint_agent_approval_resume_persists_replayable_tool_results(s async def test_endpoint_agent_approval_resume_surfaces_queued_tool_approval(streaming_chat_client_stub): - """Queued harness approval requests should survive and surface one at a time across AG-UI resumes.""" + """Queued approvals retain decisions until the wrapped Agent's middleware releases the batch.""" client, executed, messages_received, state, _ = _build_tool_approval_queue_endpoint(streaming_chat_client_stub) pause_response = client.post( "/approval", @@ -4408,7 +5773,7 @@ async def test_endpoint_agent_approval_resume_surfaces_queued_tool_approval(stre assert first_resume.status_code == 200 first_resume_events = _decode_sse_events(first_resume) tool_results = [event for event in first_resume_events if event.get("type") == "TOOL_CALL_RESULT"] - assert [(event["toolCallId"], event["content"]) for event in tool_results] == [("call_first", "first result")] + assert tool_results == [] first_resume_finished = [event for event in first_resume_events if event.get("type") == "RUN_FINISHED"] second_interrupts = _run_finished_interrupts(first_resume_finished[-1]) assert len(second_interrupts) == 1 @@ -4420,7 +5785,7 @@ async def test_endpoint_agent_approval_resume_surfaces_queued_tool_approval(stre for event in first_resume_events if event.get("type") == "TOOL_CALL_END" and event.get("toolCallId") == "call_first" ] - assert executed == ["first"] + assert executed == [] assert messages_received == [] final_resume = client.post( @@ -4437,13 +5802,65 @@ async def test_endpoint_agent_approval_resume_surfaces_queued_tool_approval(stre final_events = _decode_sse_events(final_resume) final_tool_results = [event for event in final_events if event.get("type") == "TOOL_CALL_RESULT"] assert [(event["toolCallId"], event["content"]) for event in final_tool_results] == [ - ("call_second", "second result") + ("call_first", "first result"), + ("call_second", "second result"), ] assert executed == ["first", "second"] replayed_results = [ content for message in messages_received for content in message.contents if content.type == "function_result" ] - assert [content.call_id for content in replayed_results] == ["call_second"] + assert [content.call_id for content in replayed_results] == ["call_first", "call_second"] + + +@pytest.mark.parametrize("retry_time", [25.0, 33.0]) +async def test_endpoint_collected_approval_preserves_original_expiration( + streaming_chat_client_stub, retry_time: float +) -> None: + """A collected user grant cannot be recreated after its original authority expires.""" + now = 0.0 + lifecycle = ApprovalLifecycle( + pending_retention_seconds=30, + terminal_retention_seconds=1, + clock=lambda: now, + ) + client, executed, _, state, _ = _build_tool_approval_queue_endpoint(streaming_chat_client_stub, lifecycle=lifecycle) + pause = client.post( + "/approval", + json={"runId": "pause", "threadId": "queue-expiration", "messages": [{"role": "user", "content": "Act"}]}, + ) + first_finished = [event for event in _decode_sse_events(pause) if event["type"] == "RUN_FINISHED"] + first = _run_finished_interrupts(first_finished[-1])[0]["id"] + now = 20.0 + state["phase"] = "resume" + collected = client.post( + "/approval", + json={ + "runId": "collect", + "threadId": "queue-expiration", + "messages": [], + "resume": [{"interruptId": first, "status": "resolved", "payload": {"approved": True}}], + }, + ) + second_finished = [event for event in _decode_sse_events(collected) if event["type"] == "RUN_FINISHED"] + second = _run_finished_interrupts(second_finished[-1])[0]["id"] + assert executed == [] + now = retry_time + resumed = client.post( + "/approval", + json={ + "runId": "complete", + "threadId": "queue-expiration", + "messages": [], + "resume": [{"interruptId": second, "status": "resolved", "payload": {"approved": True}}], + }, + ) + errors = [event for event in _decode_sse_events(resumed) if event["type"] == "RUN_ERROR"] + if retry_time == 25.0: + assert errors == [] + assert executed == ["first", "second"] + else: + assert errors + assert executed == [] async def test_endpoint_agent_approval_cancel_discards_queued_tool_approval(streaming_chat_client_stub): @@ -4608,10 +6025,10 @@ async def test_endpoint_agent_approval_resume_processes_collected_auto_approved_ resume_events = _decode_sse_events(resume_response) tool_results = [event for event in resume_events if event.get("type") == "TOOL_CALL_RESULT"] assert [(event["toolCallId"], event["content"]) for event in tool_results] == [ - ("call_manual", "manual result"), ("call_auto", "auto result"), + ("call_manual", "manual result"), ] - assert executed == ["manual", "auto"] + assert executed == ["auto", "manual"] replayed_results = [ content for message in messages_received for content in message.contents if content.type == "function_result" ] @@ -5075,8 +6492,9 @@ async def test_endpoint_agent_approval_client_fields_do_not_mutate_stored_approv async def test_endpoint_agent_approval_resume_entry_denial_does_not_execute_tool(): - """A resolved canonical denial resume should not execute the pending tool.""" - client, _, executed_cities = _build_weather_approval_endpoint() + """A resolved canonical denial remains valid while function invocation is disabled.""" + client, agent, executed_cities = _build_weather_approval_endpoint() + agent.client.function_invocation_configuration = {"enabled": False} response = client.post( "/approval", @@ -5191,8 +6609,9 @@ async def test_endpoint_agent_approval_replayed_standard_edited_resume_is_idempo async def test_endpoint_agent_approval_cancelled_resume_entry_completes_without_execution(): - """A cancelled canonical approval resume should complete without executing the pending tool.""" - client, _, executed_cities = _build_weather_approval_endpoint() + """A cancelled canonical approval remains valid while function invocation is disabled.""" + client, agent, executed_cities = _build_weather_approval_endpoint() + agent.client.function_invocation_configuration = {"enabled": False} response = client.post( "/approval", @@ -9718,7 +11137,6 @@ async def stream_fn( yield ChatResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant") chat_client = cast(Any, streaming_chat_client_stub(stream_fn)) - chat_client.function_invocation_configuration["enabled"] = False agent = Agent( name="test_agent", instructions="Test", From 7a82595cbd9e2efb06356ea81f6a6e4640e537b8 Mon Sep 17 00:00:00 2001 From: Yashvant Mahadev Hange <97538055+YashvantHange@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:36:00 +0000 Subject: [PATCH 051/108] .NET: expose OpenTelemetryAgent.DefaultSourceName (#7815) * .NET: expose OpenTelemetryAgent.DefaultSourceName The default ActivitySource name lives on the internal OpenTelemetryConsts class, so consumers cannot reference it when configuring a TracerProvider and must hardcode the literal "Experimental.Microsoft.Agents.AI" instead. ADR 0003 documents AgentOpenTelemetryConsts.DefaultSourceName for this, a type that no longer exists after the rename in #356. Expose the source name as a public const on OpenTelemetryAgent, the type that emits the spans, rather than making the whole constants class public. That keeps the gen_ai.* attribute keys internal, matching the Microsoft.Extensions.AI precedent, while making the documented AddSource call compile. Update the ADR snippet to the constant that now exists. * .NET: read DefaultSourceName at run time instead of inlining it A public const is baked into each consumer assembly at compile time, so a consumer built against one package version would keep subscribing to the old literal after upgrading to a version that changed the source name, and would silently stop receiving spans. That is the exact failure this API is meant to remove, so expose the value as a static property that is read at run time. Rename the test to describe what it actually asserts and point at the test that verifies emitted activities use the same source name. * .NET: document that AddSource and the agent source name must match The tracing pipeline only receives agent spans when the name registered with TracerProviderBuilder.AddSource matches the source name the agent emits under; a mismatch fails silently. Spell that pairing out in the sourceName XML docs on UseOpenTelemetry and the OpenTelemetryAgent constructors, and in the ADR usage example, which now also shows the custom-source case and uses the AsBuilder().UseOpenTelemetry() API that actually exists. * .NET: mark OpenTelemetryAgent.DefaultSourceName experimental DefaultSourceName is a new public API, so gate it behind MAAI001 and graduate it later if it proves useful. The ADR usage section notes that the diagnostic has to be suppressed to use the property. * .NET: declare DefaultSourceName in the public API baseline The public API analyzers added in #7935 require every public member of a released package to be listed in that package's PublicAPI baseline for each target framework. DefaultSourceName was added before those analyzers landed, so this branch built clean on its own but failed RS0016 once merged with main. Add the entry to all five Microsoft.Agents.AI baselines. Unshipped rather than Shipped, since the member has not been released yet; the promotion workflow moves it across after a release. --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> --- ...003-agent-opentelemetry-instrumentation.md | 26 +++++++++++++++++-- .../Microsoft.Agents.AI/OpenTelemetryAgent.cs | 23 +++++++++++++--- .../OpenTelemetryAgentBuilderExtensions.cs | 5 ++-- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 1 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 1 + .../netstandard2.0/PublicAPI.Unshipped.txt | 1 + .../OpenTelemetryAgentTests.cs | 11 ++++++++ 9 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docs/decisions/0003-agent-opentelemetry-instrumentation.md b/docs/decisions/0003-agent-opentelemetry-instrumentation.md index 863387b5cbd..527f364792a 100644 --- a/docs/decisions/0003-agent-opentelemetry-instrumentation.md +++ b/docs/decisions/0003-agent-opentelemetry-instrumentation.md @@ -124,21 +124,43 @@ The implementation is validated through: ### Usage Example +The name passed to `TracerProviderBuilder.AddSource` must match the source name the agent emits under, otherwise the +provider silently receives no agent spans. When no source name is supplied to `UseOpenTelemetry`, the agent emits under +`OpenTelemetryAgent.DefaultSourceName`, so that is the value to register. That property is marked +`[Experimental("MAAI001")]`; suppress that diagnostic in your csproj to use it. + ```csharp // Create TracerProvider using var tracerProvider = Sdk.CreateTracerProviderBuilder() - .AddSource(AgentOpenTelemetryConsts.DefaultSourceName) + .AddSource(OpenTelemetryAgent.DefaultSourceName) .AddConsoleExporter() .Build(); // Create and wrap agent with telemetry var baseAgent = new ChatClientAgent(chatClient, options); -using var telemetryAgent = baseAgent.WithOpenTelemetry(); +using var telemetryAgent = baseAgent.AsBuilder() + .UseOpenTelemetry() + .Build(); // Use agent normally - telemetry is captured automatically var response = await telemetryAgent.RunAsync(messages); ``` +To emit under a custom source name, pass the same value to both calls: + +```csharp +const string SourceName = "MyCompany.MyAgent"; + +using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource(SourceName) + .AddConsoleExporter() + .Build(); + +using var telemetryAgent = baseAgent.AsBuilder() + .UseOpenTelemetry(sourceName: SourceName) + .Build(); +``` + ### Relationship to Microsoft.Extensions.AI This implementation follows the exact patterns established by Microsoft.Extensions.AI's OpenTelemetry instrumentation, ensuring consistency across the AI ecosystem and leveraging proven patterns for telemetry integration. diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs index 90c0ec59ebb..bb94f4674a3 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgent.cs @@ -41,6 +41,19 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable // inner agent not directly but rather via OpenTelemetryChatClient, which wraps a ForwardingChatClient that in turn // calls back into the inner agent. + /// + /// Gets the default name used by when no source + /// name is supplied to the constructor. + /// + /// + /// Pass this value to the tracing pipeline (for example, TracerProviderBuilder.AddSource) to subscribe to + /// the spans emitted by agents that use the default source name, instead of hardcoding the literal name. This is + /// a property rather than a constant so that the value is read at run time: a consumer that upgrades the package + /// picks up the current source name without recompiling. + /// + [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] + public static string DefaultSourceName => OpenTelemetryConsts.DefaultSourceName; + /// The providing the bulk of the telemetry. private readonly OpenTelemetryChatClient _otelClient; /// The provider name extracted from . @@ -62,8 +75,9 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable /// Initializes a new instance of the class. /// The underlying to be augmented with telemetry capabilities. /// - /// An optional source name that will be used to identify telemetry data from this agent. - /// If not provided, a default source name will be used for telemetry identification. + /// An optional source name used to identify telemetry data from this agent. + /// When specified, register the same value with TracerProviderBuilder.AddSource so the tracing pipeline + /// subscribes to these spans. When omitted, is used. /// /// is . /// @@ -80,8 +94,9 @@ public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null) /// Initializes a new instance of the class. /// The underlying to be augmented with telemetry capabilities. /// - /// An optional source name that will be used to identify telemetry data from this agent. - /// If not provided, a default source name will be used for telemetry identification. + /// An optional source name used to identify telemetry data from this agent. + /// When specified, register the same value with TracerProviderBuilder.AddSource so the tracing pipeline + /// subscribes to these spans. When omitted, is used. /// /// /// When and the inner agent is a , the underlying diff --git a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs index 8f83a8dda1e..0d27ef8d178 100644 --- a/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI/OpenTelemetryAgentBuilderExtensions.cs @@ -15,8 +15,9 @@ public static class OpenTelemetryAgentBuilderExtensions /// /// The to which OpenTelemetry support will be added. /// - /// An optional source name that will be used to identify telemetry data from this agent. - /// If not specified, a default source name will be used. + /// An optional source name used to identify telemetry data from this agent. + /// When specified, register the same value with TracerProviderBuilder.AddSource so the tracing pipeline + /// subscribes to these spans. When omitted, is used. /// /// /// An optional callback that provides additional configuration of the instance. diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index 31d026ce287..b473d4bd163 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -7,5 +7,6 @@ [MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! [MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? [MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index 31d026ce287..b473d4bd163 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -7,5 +7,6 @@ [MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! [MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? [MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 31d026ce287..b473d4bd163 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -7,5 +7,6 @@ [MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! [MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? [MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index 31d026ce287..b473d4bd163 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -7,5 +7,6 @@ [MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! [MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? [MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 31d026ce287..b473d4bd163 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -7,5 +7,6 @@ [MAAI001]const Microsoft.Agents.AI.FileAccessProvider.ReadLinesToolName = "file_access_read_lines" -> string! [MAAI001]static Microsoft.Agents.AI.AgentFileStore.ScanContent(string! fileName, string! content, System.Text.RegularExpressions.Regex! regex) -> Microsoft.Agents.AI.FileSearchResult? [MAAI001]static Microsoft.Agents.AI.AgentFileStore.SplitLines(string! content) -> System.Collections.Generic.IReadOnlyList! +[MAAI001]static Microsoft.Agents.AI.OpenTelemetryAgent.DefaultSourceName.get -> string! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.FindMatchingFilesAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index a6b609e971e..9b0370fe958 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -873,6 +873,17 @@ public async Task AutoWireChatClient_UserFactoryAddsOwnOTel_CoexistsWithBelowFic Assert.Equal(2, activities.Count(a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal))); } + [Fact] + public void DefaultSourceName_ReturnsDocumentedSourceName() + { + // Callers pass this to TracerProviderBuilder.AddSource, so it must stay in sync with the source name the + // agent emits spans under, which Ctor_NullOrWhitespaceSourceName_AutoWiredChatClientUsesDefaultSource_Async + // pins to the same literal. Comparing against the literal here guards a rename of the internal constant. + + // Arrange & Act & Assert + Assert.Equal("Experimental.Microsoft.Agents.AI", OpenTelemetryAgent.DefaultSourceName); + } + [Theory] [InlineData(null)] [InlineData("")] From 0ff9e40cd41d5aae9514b76c20f86614a739a9e7 Mon Sep 17 00:00:00 2001 From: Sadok Barbouche Date: Mon, 14 Sep 2026 22:41:14 +0000 Subject: [PATCH 052/108] Python: Fix streamed parallel tool calls merging into the wrong call (#8337) * Python: fix streamed parallel tool calls merging into the wrong call _process_update only ever merged an incoming function_call chunk into message.contents[-1], so once two tool calls are in flight at the same time their interleaved argument deltas got mashed together or split into duplicate, unmergeable fragments instead of each call ending up complete. Match the merge target by call_id across all in-progress calls, falling back to the trailing item for continuation deltas that never repeat their call_id. Fixes #8336 * Python: don't let a tagged call absorb into an untagged trailing item Content.__add__ only rejects a function_call merge when both sides carry a call_id and they differ, so a chunk tagged with a brand-new call_id could still fall into the trailing-item fallback and get silently absorbed by an untagged trailing call, adopting its id and mashing two unrelated calls' arguments together. Reserve that fallback for chunks that carry no call_id at all; a tagged chunk with no matching in-progress call is always a new call and should be appended. * Python: don't merge a reused call_id into an already-identified call Content.__add__ only rejects a function_call merge on id mismatch when both sides carry an id. The Chat Completions client stamps a stable occurrence id on every chunk it emits, but an untagged chunk (id=None) that happens to share an already-identified call's call_id slipped past that guard and got merged anyway, silently corrupting a finished call's arguments if a provider ever reused the call_id. Skip candidates whose id is already set when the incoming chunk has none, and keep scanning instead of assuming a call_id match is proof enough. --- .../packages/core/agent_framework/_types.py | 49 +++++++- python/packages/core/tests/core/test_types.py | 108 ++++++++++++++++++ 2 files changed, 151 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index d8930e8f15c..b2ea47626e8 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2066,12 +2066,8 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse logger.warning(f"Skipping unknown content type or invalid content: {exc}") continue match content_type: - # mypy doesn't narrow type based on match/case, but we know these are FunctionCallContents - case "function_call" if message.contents and message.contents[-1].type == "function_call": - try: - message.contents[-1] += content - except (AdditionItemMismatch, ContentError): - message.contents.append(content) + case "function_call": + _merge_function_call_content(message, content) case "usage": if response.usage_details is None: response.usage_details = UsageDetails() @@ -2109,6 +2105,47 @@ def _process_update(response: ChatResponse | AgentResponse, update: ChatResponse response.continuation_token = update.continuation_token +def _merge_function_call_content(message: Message, content: Content) -> None: + """Merge a streamed function_call chunk into the in-progress call it belongs to. + + Providers can stream multiple tool calls in parallel, so the next function_call + chunk is not necessarily a continuation of the most recently appended one; a chunk + with a call_id is matched against existing contents by that id first. Chunks with + no call_id (continuation deltas some providers only stamp on the first chunk) fall + back to merging with the trailing function_call item, preserving prior behavior. + """ + call_id = getattr(content, "call_id", None) + content_id = getattr(content, "id", None) + if call_id: + for index in range(len(message.contents) - 1, -1, -1): + existing = message.contents[index] + if existing.type != "function_call" or getattr(existing, "call_id", None) != call_id: + continue + if existing.id is not None and content_id is None: + # existing already has a stable occurrence id from its client (e.g. the + # Chat Completions client stamps one on every chunk); an untagged chunk + # that merely happens to share its call_id isn't proof it's a continuation + # of that specific occurrence - a provider could reuse a call_id for a + # later, unrelated call. Keep scanning rather than merge on a hunch. + continue + try: + message.contents[index] = existing + content + except (AdditionItemMismatch, ContentError): + break + return + # A tagged chunk that matches no in-progress call is a new call, not a + # continuation - an untagged trailing item would silently absorb it otherwise. + message.contents.append(content) + return + if message.contents and message.contents[-1].type == "function_call": + try: + message.contents[-1] += content + return + except (AdditionItemMismatch, ContentError): + pass + message.contents.append(content) + + def _coalesce_text_content(contents: list[Content], type_str: Literal["text", "text_reasoning"]) -> None: """Take any subsequence Text or TextReasoningContent items and coalesce them into a single item.""" if not contents: diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index d0059318de2..b9810a98d6d 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -1913,6 +1913,114 @@ def test_function_call_incompatible_ids_are_not_merged(): assert len(fcs) == 2 +def test_function_call_interleaved_parallel_streaming_merges_by_call_id(): + """Argument deltas for two parallel tool calls can interleave; each must land on its own call. + + This mirrors how the OpenAI Responses API streams parallel tool calls: every + ``response.function_call_arguments.delta`` event is tagged with the call's real + call_id (tracked per output_index), but deltas for different calls are not + guaranteed to arrive grouped together. Before this fix, only the trailing + content item was ever considered a merge target, so an out-of-turn delta for an + earlier call_id was appended as a stray duplicate instead of being folded into + its call, leaving both calls with incomplete, unparsable arguments. + """ + updates = [ + ChatResponseUpdate(contents=[Content.from_function_call(call_id="call_1", name="get_weather", arguments="")]), + ChatResponseUpdate(contents=[Content.from_function_call(call_id="call_2", name="get_time", arguments="")]), + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"location":')] + ), + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="call_2", name="get_time", arguments='{"timezone":')] + ), + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="call_1", name="get_weather", arguments='"NYC"}')] + ), + ChatResponseUpdate( + contents=[Content.from_function_call(call_id="call_2", name="get_time", arguments='"EST"}')] + ), + ] + + resp = ChatResponse.from_updates(updates) + assert len(resp.messages) == 1 + fcs = [c for c in resp.messages[0].contents if c.type == "function_call"] + assert len(fcs) == 2 + + by_call_id = {c.call_id: c for c in fcs} + assert by_call_id["call_1"].arguments == '{"location":"NYC"}' + assert by_call_id["call_2"].arguments == '{"timezone":"EST"}' + + +def test_function_call_merge_falls_back_to_trailing_item_without_call_id(): + """Continuation deltas some providers never re-stamp with a call_id still merge. + + Not every provider repeats the call_id on every streamed chunk (e.g. the OpenAI + Chat Completions API only sends it on the first delta for a tool call), so the + fallback of merging into the trailing function_call item must still hold for the + single-call-in-flight case. + """ + updates = [ + ChatResponseUpdate(contents=[Content.from_function_call(call_id="call_1", name="f", arguments="{")]), + ChatResponseUpdate(contents=[Content.from_function_call(call_id="", name="", arguments="}")]), + ] + + resp = ChatResponse.from_updates(updates) + fcs = [c for c in resp.messages[0].contents if c.type == "function_call"] + assert len(fcs) == 1 + assert fcs[0].call_id == "call_1" + assert fcs[0].arguments == "{}" + + +def test_function_call_reused_call_id_does_not_absorb_untagged_chunk(): + """A reused call_id must not let an untagged chunk merge into an already-identified call. + + The Chat Completions client stamps a stable occurrence ``id`` on every function-call + chunk it emits, precisely so a provider reusing a ``call_id`` (or a client that only + tags the first chunk) can't be confused with an unrelated call. If a later, untagged + chunk happens to carry the same ``call_id`` as a call that already has an occurrence + id, it must not be assumed to be that call's continuation - it should be treated as + a new, separate call instead of corrupting the finished one's arguments. + """ + updates = [ + ChatResponseUpdate( + contents=[Content.from_function_call(id="af-call-1", call_id="call_1", name="get_weather", arguments="{}")] + ), + # No `id` and a reused call_id: an unrelated call, not a continuation. + ChatResponseUpdate(contents=[Content.from_function_call(call_id="call_1", name="get_time", arguments="{}")]), + ] + + resp = ChatResponse.from_updates(updates) + fcs = [c for c in resp.messages[0].contents if c.type == "function_call"] + assert len(fcs) == 2 + assert fcs[0].name == "get_weather" + assert fcs[0].arguments == "{}" + assert fcs[1].name == "get_time" + assert fcs[1].arguments == "{}" + + +def test_function_call_tagged_chunk_does_not_absorb_into_untagged_trailing_call(): + """A tagged chunk with no matching in-progress call must not merge into an untagged one. + + Content.__add__ only rejects a merge when *both* sides carry a call_id and they + differ, so an untagged trailing item (call_id falsy) would otherwise silently + accept a chunk tagged with a brand-new call_id, adopting that id and + concatenating unrelated arguments. The trailing-item fallback must be reserved + for chunks that carry no call_id at all. + """ + untagged = Content("function_call", call_id=None, name="a", arguments="partial-a") + resp = ChatResponse.from_updates([ + ChatResponseUpdate(contents=[untagged]), + ChatResponseUpdate(contents=[Content.from_function_call(call_id="call_new", name="b", arguments="{}")]), + ]) + + fcs = [c for c in resp.messages[0].contents if c.type == "function_call"] + assert len(fcs) == 2 + assert fcs[0].call_id is None + assert fcs[0].arguments == "partial-a" + assert fcs[1].call_id == "call_new" + assert fcs[1].arguments == "{}" + + # region Role & FinishReason basics From f25257f78229c76f49425897c56ea33fd1454c71 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:30:45 +0000 Subject: [PATCH 053/108] Python: stabilize Hyperlight output cleanup test (#8380) --- .../tests/hyperlight/test_hyperlight_codeact.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index 897d69d94d4..c9abad2af7b 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -1293,14 +1293,17 @@ async def test_execute_code_tool_clears_output_after_rejection( max_output_file_bytes=3, ) config = execute_code._build_run_config() - output_root = Path(cast(Any, execute_code._registry)._get_or_create_entry(config).output_dir.name) + registry = cast(Any, execute_code._registry) + output_root = Path(registry._get_or_create_entry(config).output_dir.name) try: contents = await execute_code.invoke(arguments={"code": "create-memory-output"}) _assert_bounded_output_error(contents, "per-file output limit") - remaining_outputs = await asyncio.to_thread(lambda: list(output_root.iterdir())) - assert remaining_outputs == [] + # Rejection retires the entry and removes its directory on the single cleanup worker. + # Wait behind that cleanup before checking the directory, which may already be gone. + await asyncio.wrap_future(registry._cleanup_executor.submit(lambda: None)) + assert not await asyncio.to_thread(output_root.exists) finally: _close_execute_code_registry(execute_code) From 61d6c2c23db5d435eaa8c70fa8e5d8770b70720d Mon Sep 17 00:00:00 2001 From: LI <2484593937@qq.com> Date: Mon, 14 Sep 2026 23:46:59 +0000 Subject: [PATCH 054/108] Python: fix(ag-ui): dedupe client-replayed transcripts on resume (#8149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ag-ui): dedupe client-replayed transcripts on resume (#8140) Route non-service-session (and workflow) resume seeding through _reconstruct_messages_from_thread_snapshot so a reference AG-UI client that replays its transcript is not double-persisted into the thread snapshot. * fix(ag-ui): keep resume seeding for empty messages Use the transcript reconstructor only when the client sends a non-empty messages list; empty approval/checkpoint resumes still go through resume_seeded_messages so stored history is preserved. Also add the required copyright header and document the empty-input reconstructor contract in tests. * fix(ag-ui): centralize resume message reconcile on ThreadSnapshotSession Add reconcile_resume_messages for empty vs replayed resume shapes, use it from agent and workflow runners, and mark generic reconstructed resumes as seeded so save-time prepend does not duplicate history. * fix(ag-ui): keep empty confirm_changes resumes unseeded Generic/predictive resumes with messages:[] must not prepend stored history into the turn input — confirm_changes synthesizes only the resume tool message and short-circuits without another model call. Seeding that history caused approval mismatch warnings and an extra stream invocation (test_agent_endpoint_confirm_changes_clears_persisted_interrupt). Only non-empty client-replayed generic resumes go through reconcile_resume_messages + seeded_resume_from_snapshot. Approval and workflow/checkpoint paths still seed empty via reconcile. * refactor(ag-ui): fold reconcile_resume_messages into resume_seeded_messages Address review: empty incoming still prepends stored history; non-empty uses the overlap reconstructor so client-replayed transcripts are not duplicated. * fix(ag-ui): restore resume_seeded prepend; fold #8140 tests Per review: keep resume_seeded_messages as blind prepend (save-time / empty seeds). Use reconstruct only for non-empty client-replayed transcripts. Remove dedicated small test file and fold coverage into test_snapshot_session. * test(ag-ui): type folded #8140 snapshot replay fixture for mypy Annotate stored/incoming as list[dict[str, Any]] so Test Typing Checks pass. * fix(ag-ui): collapse duplicate reconstruct on generic resume Apply eavanvalkenburg suggestion: always overlap-merge via _reconstruct_messages_from_thread_snapshot; only set seeded_resume_from_snapshot when resume carries a non-empty transcript. * fix(ag-ui): short-circuit retained approval retries with transcript When resume entries are fully handled via retained results, re-emit those results even if reconstruction refilled raw_messages, so a client-replayed approval retry does not invoke the agent again. --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 23 +++++++++++-- .../_snapshot_session.py | 8 ++++- .../ag-ui/agent_framework_ag_ui/_workflow.py | 14 +++++--- .../tests/ag_ui/test_snapshot_session.py | 34 +++++++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 77d900f5228..825a60ec621 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -2767,7 +2767,16 @@ async def _run_agent_stream( seeded_resume_from_snapshot = True if not config.use_service_session: - raw_messages = snapshot_session.resume_seeded_messages(raw_messages) + # Empty approval resumes prepend stored history; non-empty/replayed + # transcripts overlap-merge so clients do not double-write (#8140). + if raw_messages: + raw_messages = _reconstruct_messages_from_thread_snapshot( + stored_messages=stored_snapshot.messages, + incoming_messages=raw_messages, + stored_interrupt=stored_snapshot.interrupt, + ) + else: + raw_messages = snapshot_session.resume_seeded_messages(raw_messages) else: provider_suffix, snapshot_seed_messages = _split_service_session_input( stored_snapshot_messages=stored_snapshot.messages, @@ -2776,6 +2785,13 @@ async def _run_agent_stream( ) raw_messages = provider_suffix elif not config.use_service_session: + if resume_payload is not None and raw_messages: + # Client-replayed transcript on predictive/generic resume: overlap-merge + # and mark seeded so save-time resume_seeded_messages does not prepend + # again (#8140). Empty interrupt-only resumes (e.g. confirm_changes) + # must stay empty so synthesized resume tool messages are the only + # turn input; history is restored at save when this flag stays false. + seeded_resume_from_snapshot = True raw_messages = _reconstruct_messages_from_thread_snapshot( stored_messages=stored_snapshot.messages, incoming_messages=raw_messages, @@ -2899,7 +2915,10 @@ async def _run_agent_stream( raw_messages.extend(resume_messages) if snapshot_seed_messages is not None: snapshot_seed_messages.extend(copy.deepcopy(resume_messages)) - if retained_approval_results and not raw_messages: + if retained_approval_results and not approval_resume_messages and not resume_messages: + # Fully handled via retained results. Reconstruction may have refilled + # ``raw_messages`` with the stored transcript on a client-replayed retry; + # do not fall through to a fresh agent run (#8140 / eavan review). yield RunStartedEvent(run_id=run_id, thread_id=thread_id) for event in _make_approval_tool_result_events(retained_approval_results): yield event diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_snapshot_session.py b/python/packages/ag-ui/agent_framework_ag_ui/_snapshot_session.py index cb581d5d4ac..fa63dda39f1 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_snapshot_session.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_snapshot_session.py @@ -134,11 +134,17 @@ def effective_state( def resume_seeded_messages(self, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]: """Prepend copies of stored thread history to a resume request's messages. - Resume requests carry only the synthesized interrupt response; seeding + Resume requests often carry only the synthesized interrupt response; seeding with stored history keeps the persisted thread from being truncated. + + For non-empty client-replayed transcripts that already overlap stored + history, callers should use ``_reconstruct_messages_from_thread_snapshot`` + instead so messages are not double-persisted (#8140). """ if self._stored is None: return incoming + if not incoming: + return [copy.deepcopy(message) for message in self._stored.messages] return [copy.deepcopy(message) for message in self._stored.messages] + incoming async def save( diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py index 4a20e171225..9b1812a156d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py @@ -468,10 +468,16 @@ async def run(self, input_data: dict[str, Any]) -> AsyncGenerator[BaseEvent]: run_checkpoint_storage = _OwnedWorkflowCheckpointStorage(checkpoint_storage, request_owner) builder_seed_messages = raw_messages if resume_payload is not None or (checkpoint_id is not None and not raw_messages): - # Resume requests carry only the synthesized interrupt response, and a - # checkpoint-only resume carries no new messages at all; in both cases seed - # the builder with stored history to avoid persisting a truncated thread. - builder_seed_messages = snapshot_session.resume_seeded_messages(builder_seed_messages) + # Resume / checkpoint-only requests need stored history. Empty input prepends; + # non-empty/replayed transcripts overlap-merge (#8140). + if builder_seed_messages: + builder_seed_messages = _reconstruct_messages_from_thread_snapshot( + stored_messages=stored_snapshot.messages if stored_snapshot is not None else [], + incoming_messages=builder_seed_messages, + stored_interrupt=stored_snapshot.interrupt if stored_snapshot is not None else None, + ) + else: + builder_seed_messages = snapshot_session.resume_seeded_messages(builder_seed_messages) snapshot_builder = _WorkflowSnapshotBuilder(builder_seed_messages) if snapshot_session.enabled else None if snapshot_builder is not None and effective_state: # Seed builder state so a run that emits no StateSnapshotEvent still diff --git a/python/packages/ag-ui/tests/ag_ui/test_snapshot_session.py b/python/packages/ag-ui/tests/ag_ui/test_snapshot_session.py index bf6f2831638..ef72e585812 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_snapshot_session.py +++ b/python/packages/ag-ui/tests/ag_ui/test_snapshot_session.py @@ -8,6 +8,8 @@ that interface only; runner integration is covered by the existing suite. """ +from typing import Any + import pytest from ag_ui.core import ( EventType, @@ -241,6 +243,38 @@ async def test_seeded_copies_do_not_alias_stored_snapshot(self) -> None: assert session.stored is not None assert session.stored.messages[0]["content"] == "hi" + async def test_replayed_transcript_uses_reconstructor_not_blind_prepend(self) -> None: + """Client-replayed history must not be naively prepended again (#8140).""" + from agent_framework_ag_ui._run_common import _reconstruct_messages_from_thread_snapshot + + stored: list[dict[str, Any]] = [ + {"id": "u1", "role": "user", "content": "please run the tool"}, + { + "id": "a1", + "role": "assistant", + "content": "", + "toolCalls": [ + {"id": "c1", "type": "function", "function": {"name": "needs_approval", "arguments": "{}"}} + ], + }, + ] + incoming: list[dict[str, Any]] = [ + *stored, + {"id": "u2", "role": "user", "content": "approved"}, + ] + reconstructed = _reconstruct_messages_from_thread_snapshot( + stored_messages=stored, + incoming_messages=incoming, + stored_interrupt=[{"interruptId": "int-1"}], + ) + assert [message["id"] for message in reconstructed] == ["u1", "a1", "u2"] + # resume_seeded_messages remains blind prepend for save-time / empty seeds. + snapshot = AGUIThreadSnapshot(messages=stored, interrupt=[{"interruptId": "int-1"}]) + store = await make_store_with("user-1", "t-replay", snapshot) + session = await ThreadSnapshotSession.open(store=store, scope="user-1", thread_id="t-replay") + seeded = session.resume_seeded_messages([{"id": "u2", "role": "user", "content": "approved"}]) + assert [message["id"] for message in seeded] == ["u1", "a1", "u2"] + async def test_without_stored_snapshot_returns_incoming_unchanged(self) -> None: session = await ThreadSnapshotSession.open(store=None, scope=None, thread_id="t1") incoming = [{"id": "m2", "role": "user", "content": "hello"}] From 43e4b6a4ea905c80373f250299fdd8d7a4129123 Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan <35326118+manjunathshiva@users.noreply.github.com> Date: Tue, 15 Sep 2026 01:07:49 +0000 Subject: [PATCH 055/108] Python: fix: keep AG-UI workflow reasoning in thread snapshots (#8058) * Python: fix: keep AG-UI workflow reasoning in thread snapshots `_emit_text_reasoning` persists each workflow reasoning message into `flow.reasoning_messages`, and `_WorkflowSnapshotBuilder.observe` folded `TextMessage*` and `ToolCall*` events into the synthesized snapshot but had no `Reasoning*` branch. Reasoning events fell through silently, so intermediate output rendered live and then vanished when the thread was hydrated from a snapshot -- while the same run through the agent path kept it. Fold reasoning into the builder: accumulate deltas per message_id and emit entries in the shape the agent path already produces ({"id", "role": "reasoning", "content", ["encryptedValue"]}). Reasoning is flushed at build() and at text-message and tool-call starts so a block that streamed before other output replays in the position it streamed in. Reasoning deliberately does not close an open tool-call group: it is UI-only state that `agui_messages_to_agent_framework` drops, so it cannot break the tool_calls/result adjacency providers require. Fixes #8054 Co-Authored-By: Claude Opus 5 * Python: fix: keep encrypted-only AG-UI workflow reasoning on message end Address review: an encrypted-value-only reasoning message was dropped for the event order `_emit_text_reasoning` produces without a flow, where REASONING_MESSAGE_END precedes REASONING_ENCRYPTED_VALUE. The message carries no display text, so closing it at REASONING_MESSAGE_END discarded it and left the encrypted value with nothing to attach to. The underlying mistake was conflating two protocol levels. REASONING_MESSAGE_END closes the message; REASONING_END closes the block; and an encrypted value is block-scoped, so it legitimately trails the message end. Keep the message open past REASONING_MESSAGE_END and let REASONING_END, a new REASONING_START, intervening text/tool output, or build() finalize it. Handle REASONING_START so a new block also closes anything the previous one left open. Adds regression coverage for the reported order, for an empty block with neither text nor an encrypted value still being dropped, for a block closed by the next REASONING_START, for an encrypted value arriving after intervening text, and for end events naming a message that was never opened. Co-Authored-By: Claude Opus 5 * Python: replay AG-UI workflow reasoning in the order it streamed Review on #8058 found two ordering defects in `_WorkflowSnapshotBuilder`. An `output` event followed by a later `intermediate` event left both the open text message and the open reasoning message unflushed, so `build()`'s fixed flush order decided the sequence and hydration reversed what streamed. Nothing flushed open text when reasoning opened, even though `_observe_text_start` and `_observe_tool_call_start` already flush open reasoning. Reasoning is now flushed wherever other output is appended and text is flushed wherever reasoning opens, so the two slots can never both be open and `build()`'s order cannot matter. That covers the `_observe_text_content` resume path and `_observe_tool_call_result`, which had the same gap. Reasoning is UI-only and dropped before provider conversion, so a block that now lands between a tool call and its result still converts back with the two adjacent, which provider APIs require; a test pins that across the two modules. A block carrying only protected data has no content event at all, so output arriving before its encrypted value flushed an empty message that the late `ReasoningEncryptedValueEvent` could no longer find, and the protected value was lost. The empty message now stays addressable in the position it streamed and `build()` filters it out only while nothing has claimed it, so genuinely empty reasoning is still absent from the snapshot. Filtering rather than deleting is not observable through the one call path today, which builds once per run; it keeps `build()` a projection of accumulated state so the choice does not have to be revisited if the builder is ever driven incrementally. A live fan-out workflow on gpt-5-mini shows both preconditions for that loss. Reasoning arrives with an encrypted value and no visible text, the value is emitted twice for one item, and with a flow it lands between REASONING_MESSAGE_START and REASONING_MESSAGE_END rather than after it. Concurrent executors interleave their events, so a text message can start in the gap before the value arrives; replaying the captured order with that interleaving loses the encrypted reasoning before this change and keeps it after. That same interleaving exposed a second loss on the no-start text path: `_observe_text_content` opened a message over one already open under a different id, discarding its content, where `_observe_text_start` flushes first. It now flushes as well. Splitting an open text message on reasoning meant a resumed message reusing its id replayed twice under that id; the later fragment is now re-identified the way `_observe_tool_call_start` already re-identifies a split message, and only when a collision is real. A repeated start for the block already open is now a no-op rather than silently discarding the deltas folded into it. Detecting that collision by scanning the accumulated messages on every flush made snapshot building quadratic -- flat at about 8us per message before, rising to 124us per message by 4000 messages. Appends now go through one method that maintains an id index, which restores linear scaling, and a test pins the index against a future append that bypasses it. --------- Co-authored-by: Claude Opus 5 --- .../ag-ui/agent_framework_ag_ui/_workflow.py | 171 +++++- .../ag-ui/tests/ag_ui/test_snapshots.py | 580 ++++++++++++++++++ .../ag-ui/tests/ag_ui/test_workflow_agent.py | 61 ++ 3 files changed, 808 insertions(+), 4 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py index 9b1812a156d..539ddd21855 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow.py @@ -12,6 +12,12 @@ from ag_ui.core import ( BaseEvent, MessagesSnapshotEvent, + ReasoningEncryptedValueEvent, + ReasoningEndEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, RunErrorEvent, RunFinishedEvent, RunStartedEvent, @@ -128,10 +134,15 @@ class _WorkflowSnapshotBuilder: def __init__(self, raw_messages: list[dict[str, Any]]) -> None: self._synthesized_messages = agui_messages_to_snapshot_format(raw_messages) + self._synthesized_message_ids: set[Any] = { + message_id for message in self._synthesized_messages if (message_id := message.get("id")) + } self._emitted_messages: list[dict[str, Any]] | None = None self._open_text_message: dict[str, Any] | None = None + self._open_reasoning_message: dict[str, Any] | None = None self._tool_call_message: dict[str, Any] | None = None self._tool_calls_by_id: dict[str, dict[str, Any]] = {} + self._provisional_reasoning_messages: list[dict[str, Any]] = [] self.state: dict[str, Any] | None = None self.interrupt: list[dict[str, Any]] | None = None @@ -173,11 +184,25 @@ def observe(self, event: BaseEvent) -> None: self._observe_tool_call_args(event) elif isinstance(event, ToolCallResultEvent): self._observe_tool_call_result(event) + elif isinstance(event, ReasoningStartEvent): + # A new reasoning block supersedes anything still open from the last one. + self._begin_reasoning_block() + elif isinstance(event, ReasoningMessageStartEvent): + self._observe_reasoning_start(event) + elif isinstance(event, ReasoningMessageContentEvent): + self._observe_reasoning_content(event) + elif isinstance(event, (ReasoningMessageEndEvent, ReasoningEndEvent)): + self._observe_reasoning_end(event) + elif isinstance(event, ReasoningEncryptedValueEvent): + self._observe_reasoning_encrypted_value(event) def build(self) -> AGUIThreadSnapshot: """Return the replayable thread snapshot.""" + self._flush_open_reasoning_message() self._flush_open_text_message() - messages = self._emitted_messages if self._emitted_messages is not None else self._synthesized_messages + messages = ( + self._emitted_messages if self._emitted_messages is not None else self._replayable_synthesized_messages() + ) persisted = _persistable_host_payload_history(messages) return AGUIThreadSnapshot( messages=_bound_host_payload_history(persisted), @@ -186,12 +211,22 @@ def build(self) -> AGUIThreadSnapshot: ) def _observe_text_start(self, event: TextMessageStartEvent) -> None: + self._flush_open_reasoning_message() if self._open_text_message is not None and self._open_text_message.get("id") != event.message_id: self._flush_open_text_message() self._open_text_message = {"id": event.message_id, "role": event.role, "content": ""} def _observe_text_content(self, event: TextMessageContentEvent) -> None: if self._open_text_message is None or self._open_text_message.get("id") != event.message_id: + # Text arriving without a start event still opens a message, so it has to + # close what is open the way `_observe_text_start` does. Without the + # reasoning flush the reasoning outlives it and `build()` replays it after + # text that streamed later; without the text flush a message already open + # under a different id was overwritten and its content lost, which + # concurrently scheduled executors can trigger by interleaving content + # events. + self._flush_open_reasoning_message() + self._flush_open_text_message() self._open_text_message = {"id": event.message_id, "role": "assistant", "content": ""} self._open_text_message["content"] = f"{self._open_text_message.get('content', '')}{event.delta}" @@ -201,6 +236,7 @@ def _observe_text_end(self, event: TextMessageEndEvent) -> None: self._flush_open_text_message() def _observe_tool_call_start(self, event: ToolCallStartEvent) -> None: + self._flush_open_reasoning_message() parent_message_id = event.parent_message_id if ( self._open_text_message is not None @@ -218,7 +254,7 @@ def _observe_tool_call_start(self, event: ToolCallStartEvent) -> None: "role": "assistant", "tool_calls": [], } - self._synthesized_messages.append(self._tool_call_message) + self._append_synthesized_message(self._tool_call_message) tool_call = { "id": event.tool_call_id, @@ -236,6 +272,9 @@ def _observe_tool_call_args(self, event: ToolCallArgsEvent) -> None: function_payload["arguments"] = f"{function_payload.get('arguments', '')}{event.delta}" def _observe_tool_call_result(self, event: ToolCallResultEvent) -> None: + # `_observe_tool_call_start` closes open reasoning but the result path did not, + # so reasoning that streamed before a result replayed after it. + self._flush_open_reasoning_message() message: dict[str, Any] = { "id": event.message_id, "role": "tool", @@ -252,7 +291,9 @@ def _observe_tool_call_result(self, event: ToolCallResultEvent) -> None: _AGUI_TOOL_RESULT_MODEL_CONTENT_KEY, [{"type": "text", "text": "Tool result unavailable."}], ) - self._synthesized_messages.append(message) + # Through the helper, not `_synthesized_messages.append`, so the id index this + # branch relies on for collision detection stays in step. + self._append_synthesized_message(message) # A result closes the current tool-call group; later tool calls start a new # assistant message so replayed transcripts keep results adjacent to their # tool_calls message, which provider APIs require. @@ -262,11 +303,133 @@ def _flush_open_text_message(self) -> None: if self._open_text_message is None: return if self._open_text_message.get("content"): - self._synthesized_messages.append(self._open_text_message) + # A text message resumed after an interleaved reasoning block reuses its + # `message_id`, so flushing both fragments would put two messages under one + # id. Re-id the later fragment the way `_observe_tool_call_start` re-ids a + # split message, and only when a collision is real, so the common case + # keeps the id it streamed under. + if self._has_synthesized_message_id(self._open_text_message.get("id")): + self._open_text_message["id"] = generate_event_id() + self._append_synthesized_message(self._open_text_message) # Text between tool calls closes the current tool-call group as well. self._tool_call_message = None self._open_text_message = None + def _append_synthesized_message(self, message: dict[str, Any]) -> None: + """Append a replayable message, keeping the id index in step. + + Every append goes through here so ``_has_synthesized_message_id`` can stay a set + lookup. Scanning the accumulated list on each flush instead made snapshot + building quadratic in message count -- about 8us per message at any length + before, rising to 124us per message by 4000 messages. + """ + self._synthesized_messages.append(message) + message_id = message.get("id") + if message_id: + self._synthesized_message_ids.add(message_id) + + def _has_synthesized_message_id(self, message_id: Any) -> bool: + """Whether a message already replays under *message_id*. + + A falsy id never collides: it would otherwise match every message that carries + no id of its own. + """ + return bool(message_id) and message_id in self._synthesized_message_ids + + def _begin_reasoning_block(self) -> None: + """Close whatever is open so a reasoning block replays where it streamed. + + ``_observe_text_start`` and ``_observe_tool_call_start`` already flush open + reasoning, but nothing flushed open text, so an ``output`` event followed by a + later ``intermediate`` event left both open and let ``build()``'s flush order + decide the sequence -- reversing what streamed. Flushing here keeps the two + slots peers, so they can never both be open and that order cannot matter. + """ + self._flush_open_reasoning_message() + self._flush_open_text_message() + + def _observe_reasoning_start(self, event: ReasoningMessageStartEvent) -> None: + if self._open_reasoning_message is not None and self._open_reasoning_message.get("id") == event.message_id: + # A repeated start for the block already open is a no-op: reopening it would + # discard the deltas already folded in, and flushing first would replay two + # messages under one id. + return + self._begin_reasoning_block() + self._open_reasoning_message = {"id": event.message_id, "role": "reasoning", "content": ""} + + def _observe_reasoning_content(self, event: ReasoningMessageContentEvent) -> None: + if self._open_reasoning_message is None or self._open_reasoning_message.get("id") != event.message_id: + self._begin_reasoning_block() + self._open_reasoning_message = {"id": event.message_id, "role": "reasoning", "content": ""} + self._open_reasoning_message["content"] = f"{self._open_reasoning_message.get('content', '')}{event.delta}" + + def _observe_reasoning_end(self, event: ReasoningMessageEndEvent | ReasoningEndEvent) -> None: + if self._open_reasoning_message is None or self._open_reasoning_message.get("id") != event.message_id: + return + if isinstance(event, ReasoningMessageEndEvent): + # REASONING_MESSAGE_END closes the message, not the block, and an + # encrypted value is block-scoped so it legitimately trails it -- that is + # the order `_emit_text_reasoning` produces without a flow. Keep the + # message open so protected-data-only reasoning still has somewhere to + # land; REASONING_END, the next block, or build() finalizes it. + return + self._flush_open_reasoning_message() + + def _observe_reasoning_encrypted_value(self, event: ReasoningEncryptedValueEvent) -> None: + # Only message-scoped encrypted values belong on a reasoning message; the + # protocol also uses this event for other subtypes. + if event.subtype != "message": + return + if self._open_reasoning_message is not None and self._open_reasoning_message.get("id") == event.entity_id: + self._open_reasoning_message["encryptedValue"] = event.encrypted_value + return + # Intervening text or tool output can flush the message before its encrypted + # value arrives; attach it to the message we already synthesized. + for message in reversed(self._synthesized_messages): + if message.get("role") == "reasoning" and message.get("id") == event.entity_id: + message["encryptedValue"] = event.encrypted_value + return + + def _flush_open_reasoning_message(self) -> None: + if self._open_reasoning_message is None: + return + message = self._open_reasoning_message + self._open_reasoning_message = None + # An encrypted-value-only block carries no display text but still has to + # survive hydration, so it counts as content worth keeping. + self._append_synthesized_message(message) + if not (message.get("content") or message.get("encryptedValue")): + # A block whose only payload is protected data emits no content event, so an + # empty message here may still be waiting for its encrypted value. Measured + # on a live fan-out workflow: with a flow the value arrives between + # REASONING_MESSAGE_START and REASONING_MESSAGE_END, so output interleaved by + # a concurrent executor can flush this message before it lands; without a + # flow `_emit_text_reasoning` emits REASONING_MESSAGE_END first, widening the + # same window. Keep it addressable in the position it streamed rather than + # dropping it -- attaching the value on arrival instead would replay the + # reasoning after the output that flushed it. `build()` leaves it out of the + # snapshot while nothing has claimed it. + self._provisional_reasoning_messages.append(message) + + def _replayable_synthesized_messages(self) -> list[dict[str, Any]]: + """Synthesized messages without reasoning shells that nothing ever claimed. + + Filters rather than deletes so ``build()`` stays a projection of accumulated + state. The single caller builds once per run, so that is not observable today; it + keeps a shell excluded from one snapshot addressable afterwards, which is the + behaviour to want if the builder is ever driven incrementally. + """ + # Compare by identity: a caller-supplied message from `raw_messages` may share + # an id with one we synthesized, and must not be filtered out with it. + unclaimed = { + id(message) + for message in self._provisional_reasoning_messages + if not (message.get("content") or message.get("encryptedValue")) + } + if not unclaimed: + return self._synthesized_messages + return [message for message in self._synthesized_messages if id(message) not in unclaimed] + class AgentFrameworkWorkflow: """Base AG-UI workflow wrapper. diff --git a/python/packages/ag-ui/tests/ag_ui/test_snapshots.py b/python/packages/ag-ui/tests/ag_ui/test_snapshots.py index 0cde9c60020..4aa6a83ea27 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_snapshots.py +++ b/python/packages/ag-ui/tests/ag_ui/test_snapshots.py @@ -221,6 +221,290 @@ def test_workflow_snapshot_builder_splits_tool_call_groups() -> None: ] +def test_workflow_snapshot_builder_folds_reasoning_into_snapshot() -> None: + """Streamed reasoning deltas accumulate into a replayable reasoning message.""" + from ag_ui.core import ( + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="step one ")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="step two")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + + assert builder.build().messages == [{"id": "reason-1", "role": "reasoning", "content": "step one step two"}] + + +def test_workflow_snapshot_builder_keeps_reasoning_in_emission_order() -> None: + """Reasoning is replayed where it streamed, not appended after the visible output.""" + from ag_ui.core import ( + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallResultEvent, + ToolCallStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="planning")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + builder.observe(ToolCallStartEvent(tool_call_id="call-a", tool_call_name="toolA")) + builder.observe(ToolCallResultEvent(message_id="result-a", tool_call_id="call-a", content="resA")) + builder.observe(TextMessageStartEvent(message_id="text-1", role="assistant")) + builder.observe(TextMessageContentEvent(message_id="text-1", delta="done")) + builder.observe(TextMessageEndEvent(message_id="text-1")) + + messages = builder.build().messages + assert [ + ( + message.get("role"), + [tool_call["id"] for tool_call in message.get("tool_calls", [])] + or message.get("toolCallId") + or message.get("content"), + ) + for message in messages + ] == [ + ("reasoning", "planning"), + ("assistant", ["call-a"]), + ("tool", "call-a"), + ("assistant", "done"), + ] + + +def test_workflow_snapshot_builder_captures_reasoning_encrypted_value() -> None: + """Encrypted reasoning payloads survive hydration under the protocol's camelCase key.""" + from ag_ui.core import ( + ReasoningEncryptedValueEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="hidden")) + builder.observe(ReasoningEncryptedValueEvent(subtype="message", entity_id="reason-1", encrypted_value="cipher")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + + assert builder.build().messages == [ + {"id": "reason-1", "role": "reasoning", "content": "hidden", "encryptedValue": "cipher"} + ] + + +def test_workflow_snapshot_builder_flushes_reasoning_left_open_at_build() -> None: + """A run that ends without REASONING_MESSAGE_END still snapshots what streamed.""" + from ag_ui.core import ReasoningMessageContentEvent, ReasoningMessageStartEvent + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="unterminated")) + + assert builder.build().messages == [{"id": "reason-1", "role": "reasoning", "content": "unterminated"}] + + +def test_workflow_snapshot_builder_separates_consecutive_reasoning_blocks() -> None: + """A new reasoning message id closes the previous block instead of merging into it.""" + from ag_ui.core import ReasoningMessageContentEvent, ReasoningMessageStartEvent + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="first")) + # Second block opens without the first ever being closed. + builder.observe(ReasoningMessageStartEvent(message_id="reason-2", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-2", delta="second")) + + assert builder.build().messages == [ + {"id": "reason-1", "role": "reasoning", "content": "first"}, + {"id": "reason-2", "role": "reasoning", "content": "second"}, + ] + + +def test_workflow_snapshot_builder_folds_reasoning_content_without_a_start_event() -> None: + """Reasoning deltas are kept even if the opening REASONING_MESSAGE_START was missed.""" + from ag_ui.core import ReasoningMessageContentEvent + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="orphaned")) + + assert builder.build().messages == [{"id": "reason-1", "role": "reasoning", "content": "orphaned"}] + + +def test_workflow_snapshot_builder_attaches_encrypted_value_after_block_closed() -> None: + """An encrypted value trailing REASONING_MESSAGE_END still lands on its message.""" + from ag_ui.core import ( + ReasoningEncryptedValueEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="hidden")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + builder.observe( + ReasoningEncryptedValueEvent(subtype="message", entity_id="reason-1", encrypted_value="late-cipher") + ) + + assert builder.build().messages == [ + {"id": "reason-1", "role": "reasoning", "content": "hidden", "encryptedValue": "late-cipher"} + ] + + +def test_workflow_snapshot_builder_keeps_encrypted_only_reasoning_ended_before_its_value() -> None: + """Protected-data-only reasoning survives the no-flow order, where END precedes the value. + + `_emit_text_reasoning` without a flow emits REASONING_MESSAGE_END before + REASONING_ENCRYPTED_VALUE. The message carries no display text, so closing it at + REASONING_MESSAGE_END would discard it and leave the encrypted value nothing to + attach to. + """ + from ag_ui.core import ( + ReasoningEncryptedValueEvent, + ReasoningEndEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + builder.observe(ReasoningEncryptedValueEvent(subtype="message", entity_id="reason-1", encrypted_value="cipher")) + builder.observe(ReasoningEndEvent(message_id="reason-1")) + + assert builder.build().messages == [ + {"id": "reason-1", "role": "reasoning", "content": "", "encryptedValue": "cipher"} + ] + + +def test_workflow_snapshot_builder_drops_reasoning_with_neither_text_nor_encrypted_value() -> None: + """An empty reasoning block with nothing to replay is not synthesized into a message.""" + from ag_ui.core import ( + ReasoningEndEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + builder.observe(ReasoningEndEvent(message_id="reason-1")) + + assert builder.build().messages == [] + + +def test_workflow_snapshot_builder_closes_previous_block_on_new_reasoning_start() -> None: + """A new REASONING_START finalizes a message the previous block left open.""" + from ag_ui.core import ( + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="first")) + # No REASONING_END: the next block's start has to close this one. + builder.observe(ReasoningStartEvent(message_id="reason-2")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-2", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-2", delta="second")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-2")) + + assert builder.build().messages == [ + {"id": "reason-1", "role": "reasoning", "content": "first"}, + {"id": "reason-2", "role": "reasoning", "content": "second"}, + ] + + +def test_workflow_snapshot_builder_attaches_encrypted_value_after_intervening_output() -> None: + """Text arriving between a reasoning message and its encrypted value does not lose the value.""" + from ag_ui.core import ( + ReasoningEncryptedValueEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + TextMessageContentEvent, + TextMessageStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="thinking")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + # Text output flushes the reasoning message before the encrypted value shows up. + builder.observe(TextMessageStartEvent(message_id="text-1", role="assistant")) + builder.observe(TextMessageContentEvent(message_id="text-1", delta="answer")) + builder.observe(ReasoningEncryptedValueEvent(subtype="message", entity_id="reason-1", encrypted_value="cipher")) + + assert builder.build().messages == [ + {"id": "reason-1", "role": "reasoning", "content": "thinking", "encryptedValue": "cipher"}, + {"id": "text-1", "role": "assistant", "content": "answer"}, + ] + + +def test_workflow_snapshot_builder_ignores_reasoning_end_for_unopened_message() -> None: + """A reasoning end event for a message that was never opened is a no-op.""" + from ag_ui.core import ReasoningEndEvent, ReasoningMessageContentEvent, ReasoningMessageStartEvent + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningEndEvent(message_id="never-opened")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="kept")) + # An end event for a different id must not close the open message. + builder.observe(ReasoningEndEvent(message_id="other")) + + assert builder.build().messages == [{"id": "reason-1", "role": "reasoning", "content": "kept"}] + + +def test_workflow_snapshot_builder_ignores_tool_call_encrypted_values() -> None: + """A tool-call-scoped encrypted value must not be folded in as reasoning content.""" + from ag_ui.core import ReasoningEncryptedValueEvent + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningEncryptedValueEvent(subtype="tool-call", entity_id="call-a", encrypted_value="cipher")) + + assert builder.build().messages == [] + + def test_workflow_snapshot_builder_preserves_safe_bounded_mcp_replay() -> None: """Workflow event synthesis persists model-safe content and private Host replay data.""" from ag_ui.core import ToolCallResultEvent @@ -273,3 +557,299 @@ async def test_in_memory_snapshot_store_rejects_invalid_keys() -> None: await store.delete(scope=None, thread_id="thread-1") # type: ignore[arg-type] # ty: ignore[invalid-argument-type] with pytest.raises(ValueError): await store.clear(scope="") + + +def test_workflow_snapshot_builder_keeps_output_before_later_reasoning() -> None: + """An output event that streams before reasoning replays before it, not after.""" + from ag_ui.core import ( + ReasoningMessageContentEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + TextMessageContentEvent, + TextMessageStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + # An `output` event streams assistant text. The workflow keeps running, so no + # TextMessageEndEvent arrives before the later `intermediate` event opens reasoning. + builder.observe(TextMessageStartEvent(message_id="text-1", role="assistant")) + builder.observe(TextMessageContentEvent(message_id="text-1", delta="visible output")) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="later thought")) + + messages = builder.build().messages + assert [(message["role"], message.get("content")) for message in messages] == [ + ("assistant", "visible output"), + ("reasoning", "later thought"), + ] + + +def test_workflow_snapshot_builder_keeps_encrypted_only_reasoning_addressable_across_text() -> None: + """A protected-data-only block flushed by intervening text still receives its value.""" + from ag_ui.core import ( + ReasoningEncryptedValueEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + # `_emit_text_reasoning` without a flow emits REASONING_MESSAGE_END before the + # encrypted value, and emits no content event at all when the reasoning carries + # only protected data. Intervening text must not discard the message the value + # still has to land on. + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + builder.observe(TextMessageStartEvent(message_id="text-1", role="assistant")) + builder.observe(TextMessageContentEvent(message_id="text-1", delta="visible")) + builder.observe(TextMessageEndEvent(message_id="text-1")) + builder.observe(ReasoningEncryptedValueEvent(subtype="message", entity_id="reason-1", encrypted_value="enc-1")) + + messages = builder.build().messages + reasoning = [message for message in messages if message.get("role") == "reasoning"] + assert reasoning == [{"id": "reason-1", "role": "reasoning", "content": "", "encryptedValue": "enc-1"}] + assert [message.get("role") for message in messages] == ["reasoning", "assistant"] + + +def test_workflow_snapshot_builder_splits_text_resumed_after_reasoning() -> None: + """Text resumed after a reasoning block replays around it under distinct ids.""" + from ag_ui.core import ( + ReasoningMessageContentEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(TextMessageStartEvent(message_id="text-1", role="assistant")) + builder.observe(TextMessageContentEvent(message_id="text-1", delta="part one ")) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="thought")) + # The provider reuses the text message id when the visible message resumes. + builder.observe(TextMessageContentEvent(message_id="text-1", delta="part two")) + builder.observe(TextMessageEndEvent(message_id="text-1")) + + messages = builder.build().messages + assert [(message["role"], message.get("content")) for message in messages] == [ + ("assistant", "part one "), + ("reasoning", "thought"), + ("assistant", "part two"), + ] + ids = [message.get("id") for message in messages] + assert len(set(ids)) == len(ids), f"snapshot replays two messages under one id: {ids}" + assert ids[0] == "text-1" + + +def test_workflow_snapshot_builder_keeps_reasoning_before_a_tool_result() -> None: + """Reasoning that streams before a tool result replays before it.""" + from ag_ui.core import ( + ReasoningMessageContentEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + ToolCallResultEvent, + ToolCallStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ToolCallStartEvent(tool_call_id="call-1", tool_call_name="toolA", parent_message_id="msg-1")) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="deciding")) + builder.observe(ToolCallResultEvent(message_id="result-1", tool_call_id="call-1", content="done")) + + assert [message.get("role") for message in builder.build().messages] == ["assistant", "reasoning", "tool"] + + +def test_workflow_snapshot_builder_ignores_repeated_reasoning_start_for_open_block() -> None: + """A duplicate start for the open block keeps its deltas under a single id.""" + from ag_ui.core import ReasoningMessageContentEvent, ReasoningMessageStartEvent + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="first ")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="second")) + + assert builder.build().messages == [{"id": "reason-1", "role": "reasoning", "content": "first second"}] + + +def test_workflow_snapshot_reasoning_between_tool_call_and_result_stays_convertible() -> None: + """Reasoning between a tool call and its result must not break provider adjacency.""" + from ag_ui.core import ( + ReasoningMessageContentEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + ToolCallArgsEvent, + ToolCallResultEvent, + ToolCallStartEvent, + ) + + from agent_framework_ag_ui._message_adapters import agui_messages_to_agent_framework + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ToolCallStartEvent(tool_call_id="call-1", tool_call_name="toolA", parent_message_id="msg-1")) + builder.observe(ToolCallArgsEvent(tool_call_id="call-1", delta='{"a": 1}')) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="deciding")) + builder.observe(ToolCallResultEvent(message_id="result-1", tool_call_id="call-1", content="ok")) + + snapshot = builder.build().messages + assert [message.get("role") for message in snapshot] == ["assistant", "reasoning", "tool"] + + # Reasoning is UI-only and dropped before provider conversion, so the result has to + # come back adjacent to the call it belongs to. + converted = agui_messages_to_agent_framework(snapshot) + shapes = [(message.role, [content.type for content in message.contents]) for message in converted] + call_indexes = [index for index, (_, types) in enumerate(shapes) if "function_call" in types] + result_indexes = [index for index, (_, types) in enumerate(shapes) if "function_result" in types] + assert call_indexes and result_indexes, shapes + assert result_indexes[0] == call_indexes[0] + 1, shapes + + +def test_workflow_snapshot_builder_keeps_reasoning_shell_addressable_after_a_build() -> None: + """A value arriving after a snapshot is taken still lands on the next one.""" + from ag_ui.core import ( + ReasoningEncryptedValueEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageEndEvent(message_id="reason-1")) + + # An unclaimed shell is excluded from the snapshot rather than deleted, so a store + # that saves mid-run does not make the protected value unrecoverable. + assert builder.build().messages == [] + + builder.observe(ReasoningEncryptedValueEvent(subtype="message", entity_id="reason-1", encrypted_value="enc-late")) + assert builder.build().messages == [ + {"id": "reason-1", "role": "reasoning", "content": "", "encryptedValue": "enc-late"} + ] + + +def test_workflow_snapshot_builder_message_id_index_tracks_every_append() -> None: + """The id index must cover every appended message, or collisions go undetected. + + `_has_synthesized_message_id` is a set lookup because scanning the message list on + each flush made snapshot building quadratic. That only stays correct while every + append goes through `_append_synthesized_message`, so pin the invariant rather than + the timing. + """ + from ag_ui.core import ( + ReasoningMessageContentEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ToolCallArgsEvent, + ToolCallResultEvent, + ToolCallStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([{"id": "seed-1", "role": "user", "content": "hi"}]) + builder.observe(ToolCallStartEvent(tool_call_id="call-1", tool_call_name="toolA", parent_message_id="msg-1")) + builder.observe(ToolCallArgsEvent(tool_call_id="call-1", delta="{}")) + builder.observe(ReasoningStartEvent(message_id="reason-1")) + builder.observe(ReasoningMessageStartEvent(message_id="reason-1", role="reasoning")) + builder.observe(ReasoningMessageContentEvent(message_id="reason-1", delta="why")) + builder.observe(ToolCallResultEvent(message_id="result-1", tool_call_id="call-1", content="ok")) + builder.observe(TextMessageStartEvent(message_id="text-1", role="assistant")) + builder.observe(TextMessageContentEvent(message_id="text-1", delta="answer")) + builder.observe(TextMessageEndEvent(message_id="text-1")) + + messages = builder.build().messages + appended_ids = {message_id for message in messages if (message_id := message.get("id"))} + assert appended_ids <= builder._synthesized_message_ids, ( # pyright: ignore[reportPrivateUsage] + "an append bypassed `_append_synthesized_message`, so the id index is stale: " + f"missing {appended_ids - builder._synthesized_message_ids}" # pyright: ignore[reportPrivateUsage] + ) + + +def test_workflow_snapshot_builder_keeps_encrypted_reasoning_across_concurrent_text() -> None: + """Concurrent output must not discard a reasoning block awaiting its encrypted value. + + Event order captured from a live fan-out workflow on gpt-5-mini: with a flow, the + encrypted value is emitted right after REASONING_MESSAGE_START and before + REASONING_MESSAGE_END, and the provider sends it twice. The reasoning carries no + visible text, so a concurrent executor's text message landing in the gap before the + value arrives used to flush an empty message and lose the value. + """ + from ag_ui.core import ( + ReasoningEncryptedValueEvent, + ReasoningEndEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, + TextMessageContentEvent, + TextMessageEndEvent, + TextMessageStartEvent, + ) + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + reasoning_id = "rs_0773abc2200cb36b006aa02287c0d0819" + builder = _WorkflowSnapshotBuilder([]) + builder.observe(ReasoningStartEvent(message_id=reasoning_id)) + builder.observe(ReasoningMessageStartEvent(message_id=reasoning_id, role="reasoning")) + # A concurrently scheduled executor emits its own message here. + builder.observe(TextMessageStartEvent(message_id="concurrent-1", role="assistant")) + builder.observe(TextMessageContentEvent(message_id="concurrent-1", delta="from the other branch")) + builder.observe(TextMessageEndEvent(message_id="concurrent-1")) + # The provider emits the value twice for one item; both must be idempotent. + builder.observe(ReasoningEncryptedValueEvent(subtype="message", entity_id=reasoning_id, encrypted_value="enc")) + builder.observe(ReasoningEncryptedValueEvent(subtype="message", entity_id=reasoning_id, encrypted_value="enc")) + builder.observe(ReasoningMessageEndEvent(message_id=reasoning_id)) + builder.observe(ReasoningEndEvent(message_id=reasoning_id)) + + messages = builder.build().messages + assert [message.get("role") for message in messages] == ["reasoning", "assistant"] + assert messages[0] == {"id": reasoning_id, "role": "reasoning", "content": "", "encryptedValue": "enc"} + + +def test_workflow_snapshot_builder_keeps_both_interleaved_text_messages_without_starts() -> None: + """Content events for a second message must not overwrite the first one's text. + + `_observe_text_start` flushes an open message under a different id; the no-start path + used to overwrite it and lose the content. Concurrently scheduled executors reach + this by interleaving content events, which a live fan-out workflow does. + """ + from ag_ui.core import TextMessageContentEvent + + from agent_framework_ag_ui._workflow import _WorkflowSnapshotBuilder + + builder = _WorkflowSnapshotBuilder([]) + builder.observe(TextMessageContentEvent(message_id="from-agent-a", delta="branch A")) + builder.observe(TextMessageContentEvent(message_id="from-agent-b", delta="branch B")) + + assert builder.build().messages == [ + {"id": "from-agent-a", "role": "assistant", "content": "branch A"}, + {"id": "from-agent-b", "role": "assistant", "content": "branch B"}, + ] diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py index 4a4ec411605..8702adcd047 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_agent.py @@ -359,3 +359,64 @@ async def test_workflow_checkpoint_only_resume_preserves_thread_snapshot() -> No assert "Earlier reply" in contents # ...plus the newly produced output from the resumed run. assert any(isinstance(content, str) and "done" in content for content in contents) + + +async def test_workflow_snapshot_preserves_streamed_reasoning() -> None: + """Reasoning that streamed during a workflow run survives thread hydration. + + Regression test for workflow reasoning rendering live and then vanishing from the + stored AG-UI Thread Snapshot, so a hydrated thread lost the intermediate output + that the agent path retains. + """ + from agent_framework_ag_ui import InMemoryAGUIThreadSnapshotStore + from agent_framework_ag_ui._snapshots import _SNAPSHOT_SCOPE_INPUT_KEY + + @executor(id="thinker") + async def thinker(message: Any, ctx: WorkflowContext[str, str]) -> None: + await ctx.yield_output("Weighing the options...") + await ctx.send_message("go") + + @executor(id="finalizer") + async def finalizer(message: str, ctx: WorkflowContext[None, str]) -> None: + await ctx.yield_output("Final answer.") + + workflow = ( + WorkflowBuilder( + start_executor=thinker, + output_from=[finalizer], + intermediate_output_from=[thinker], + ) + .add_edge(thinker, finalizer) + .build() + ) + + store = InMemoryAGUIThreadSnapshotStore() + agent = AgentFrameworkWorkflow(workflow=workflow, snapshot_store=store) + + events = await _run( + agent, + { + "thread_id": "thread-reasoning", + "run_id": "run-1", + "messages": [{"id": "user-1", "role": "user", "content": "Decide"}], + _SNAPSHOT_SCOPE_INPUT_KEY: "tenant-a", + }, + ) + assert "RUN_ERROR" not in [event.type for event in events] + + # The reasoning really did stream ... + reasoning_deltas = [ + event.delta # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + for event in events + if event.type == "REASONING_MESSAGE_CONTENT" + ] + assert "Weighing the options..." in reasoning_deltas + + # ... and it is still there once the thread is hydrated from the snapshot. + snapshot = await store.get(scope="tenant-a", thread_id="thread-reasoning") + assert snapshot is not None + reasoning_messages = [message for message in snapshot.messages if message.get("role") == "reasoning"] + assert [message.get("content") for message in reasoning_messages] == ["Weighing the options..."] + + # The final assistant text is still preserved alongside it. + assert any(message.get("content") == "Final answer." for message in snapshot.messages) From 8728837f3c491f169d1adf8439e75e82be937a7d Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Tue, 15 Sep 2026 01:38:55 +0000 Subject: [PATCH 056/108] Python: Sanitize workflow error messages (#8368) * Python: Sanitize workflow error messages Keep workflow diagnostics server-side while preserving public error codes. Remove HTTP response body previews from declarative action exceptions and cover serialized workflow errors with regression tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Deduplicate workflow failure diagnostics Prefer one complete exception traceback when a workflow emits failure events and then re-raises. Retain a single structured diagnostic when no exception follows or the stream is closed early. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework_ag_ui/_workflow_run.py | 34 ++++- .../ag_ui/golden/test_scenario_workflow.py | 36 +++-- .../ag-ui/tests/ag_ui/test_workflow_run.py | 138 +++++++++++++----- .../_workflows/_executors_http.py | 38 +---- .../tests/test_http_request_executor.py | 115 +++++++++++---- 5 files changed, 241 insertions(+), 120 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index f14aa0e206c..4a10053777a 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -33,6 +33,7 @@ Content, Message, Workflow, + WorkflowEvent, WorkflowRunState, ) from agent_framework._workflows._typing_utils import ( # pyright: ignore[reportPrivateUsage] @@ -61,6 +62,8 @@ logger = logging.getLogger(__name__) +_PUBLIC_WORKFLOW_ERROR_MESSAGE = "Workflow execution failed." + _TERMINAL_STATES: set[str] = { WorkflowRunState.IDLE.value, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS.value, @@ -989,8 +992,9 @@ def _custom_event_value(event: Any) -> Any: def _details_message(details: Any) -> str: + """Extract an internal diagnostic message for server-side logging only.""" if details is None: - return "Workflow execution failed." + return _PUBLIC_WORKFLOW_ERROR_MESSAGE if hasattr(details, "message"): message = getattr(details, "message") if isinstance(message, str) and message: @@ -1017,6 +1021,9 @@ async def run_workflow_stream( ) -> AsyncGenerator[BaseEvent]: """Run a Workflow and emit AG-UI protocol events. + Execution failures expose a generic message and error code. Internal messages + and tracebacks are logged server-side, not included in public error events. + Args: input_data: Normalized AG-UI request payload (a ``RunAgentInput`` dump). workflow: The core ``Workflow`` instance to execute. @@ -1195,6 +1202,8 @@ def _drain_open_blocks() -> list[BaseEvent]: if checkpoint_storage is not None or checkpoint_id is not None: checkpoint_kwargs = {"checkpoint_storage": checkpoint_storage, "checkpoint_id": checkpoint_id} + # Core workflows emit failure events before re-raising; prefer one full exception traceback. + failure_event: WorkflowEvent | None = None try: telemetry_conversation_id = str(supplied_thread_id) if supplied_thread_id is not None else None telemetry_context = partial(_use_telemetry_conversation_id, telemetry_conversation_id) @@ -1221,12 +1230,13 @@ def _drain_open_blocks() -> list[BaseEvent]: run_started_emitted = True if event_type == "failed": + failure_event = event # Close any open reasoning block / text message so RUN_ERROR stays the # last event a client receives for this run. for end_event in _drain_open_blocks(): yield end_event details = getattr(event, "details", None) - yield RunErrorEvent(message=_details_message(details), code=_details_code(details)) + yield RunErrorEvent(message=_PUBLIC_WORKFLOW_ERROR_MESSAGE, code=_details_code(details)) run_error_emitted = True terminal_emitted = True continue @@ -1281,7 +1291,13 @@ def _drain_open_blocks() -> list[BaseEvent]: "status": status, } if event_type == "executor_failed": - executor_payload["details"] = make_json_safe(getattr(event, "details", None)) + failure_event = event + details = getattr(event, "details", None) + # Only project public fields; traceback and extra can contain backend data. + executor_payload["details"] = { + "message": _PUBLIC_WORKFLOW_ERROR_MESSAGE, + "error_type": _details_code(details), + } else: executor_payload["data"] = make_json_safe(getattr(event, "data", None)) @@ -1367,6 +1383,7 @@ def _drain_open_blocks() -> list[BaseEvent]: yield CustomEvent(name=_event_name(event), value=_custom_event_value(event)) except Exception as exc: + failure_event = None logger.exception("Workflow AG-UI stream failed: %s", exc) if not run_started_emitted: yield RunStartedEvent(run_id=run_id, thread_id=thread_id) @@ -1375,9 +1392,18 @@ def _drain_open_blocks() -> list[BaseEvent]: for end_event in _drain_open_blocks(): yield end_event if not run_error_emitted: - yield RunErrorEvent(message=str(exc), code=type(exc).__name__) + yield RunErrorEvent(message=_PUBLIC_WORKFLOW_ERROR_MESSAGE, code=type(exc).__name__) run_error_emitted = True terminal_emitted = True + finally: + if failure_event is not None: + details = getattr(failure_event, "details", None) + logger.error( + "Workflow execution failed (executor=%s): %s\n%s", + getattr(failure_event, "executor_id", None) or getattr(details, "executor_id", None), + _details_message(details), + getattr(details, "traceback", None) or "", + ) for reasoning_evt in _close_reasoning_block(flow): yield reasoning_evt diff --git a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_workflow.py b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_workflow.py index 1f8da4b13b1..a458da58e90 100644 --- a/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_workflow.py +++ b/python/packages/ag-ui/tests/ag_ui/golden/test_scenario_workflow.py @@ -15,8 +15,10 @@ """ import json +import logging from typing import Any, cast +import pytest from ag_ui.core import EventType, StateSnapshotEvent from agent_framework import ( AgentResponse, @@ -443,26 +445,30 @@ async def multisayer(message: Any, ctx: WorkflowContext[Any, str]) -> None: # ────────────────────────────────────────────────────────────────────── -async def test_workflow_error_emits_run_error_event() -> None: - """Exceptions during workflow streaming produce RUN_ERROR events.""" +async def test_workflow_error_emits_run_error_event(caplog: pytest.LogCaptureFixture) -> None: + """Real executor failures reach the wrapper as sanitized RUN_ERROR events.""" + caplog.set_level(logging.ERROR, logger="agent_framework_ag_ui._workflow_run") - class FailingWorkflow: - def run(self, **kwargs: Any): - async def _stream(): - raise RuntimeError("workflow exploded") - yield # pragma: no cover + @executor(id="failing") + async def failing(message: Any, ctx: WorkflowContext[Any, str]) -> None: + raise RuntimeError("workflow exploded") - return _stream() - - wrapper = AgentFrameworkWorkflow(workflow=cast(Any, FailingWorkflow())) + wrapper = AgentFrameworkWorkflow(workflow=WorkflowBuilder(start_executor=failing).build()) stream = await _run(wrapper, _payload()) - # Should still have RUN_STARTED - stream.assert_has_type("RUN_STARTED") - # Should have RUN_ERROR - stream.assert_has_type("RUN_ERROR") + assert stream.types()[0] == "RUN_STARTED" + assert len(stream.get("RUN_ERROR")) == 1 + assert "RUN_FINISHED" not in stream.types() error = stream.first("RUN_ERROR") - assert "workflow exploded" in error.message + assert error.code == "RuntimeError" + assert error.message + assert "workflow exploded" not in error.message + records = [record for record in caplog.records if record.name == "agent_framework_ag_ui._workflow_run"] + assert len(records) == 1 + assert records[0].exc_info is not None + assert isinstance(records[0].exc_info[1], RuntimeError) + assert records[0].exc_info[2] is not None + assert "workflow exploded" in records[0].getMessage() async def test_workflow_error_preserves_bookend_structure() -> None: diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py index f1cd16c9593..2992439fa46 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py @@ -3,7 +3,9 @@ """Tests for native workflow AG-UI runner.""" import json +import logging from collections.abc import AsyncIterator +from contextlib import aclosing from dataclasses import dataclass, make_dataclass from enum import Enum from types import SimpleNamespace @@ -23,6 +25,7 @@ Message, WorkflowBuilder, WorkflowContext, + WorkflowErrorDetails, WorkflowEvent, executor, handler, @@ -1581,16 +1584,24 @@ class ReviewDecision(BaseModel): assert _coerce_response_for_request(request, {"approved": "not-a-bool"}) is None -async def test_workflow_run_emits_run_error_when_stream_raises() -> None: - """Unexpected stream exceptions should be converted into RUN_ERROR events.""" +@pytest.mark.parametrize("emit_started", [False, True]) +async def test_workflow_run_emits_run_error_when_stream_raises( + emit_started: bool, caplog: pytest.LogCaptureFixture +) -> None: + """Stream failures retain server diagnostics, not exception text on the wire.""" + private_detail = "SYNTHETIC_BACKEND_RESPONSE" + failure = RuntimeError(f"workflow stream exploded: {private_detail}") + caplog.set_level(logging.ERROR, logger="agent_framework_ag_ui._workflow_run") class FailingWorkflow: - def run(self, **kwargs: Any): + def run(self, **kwargs: Any) -> AsyncIterator[WorkflowEvent]: del kwargs - async def _stream(): - raise RuntimeError("workflow stream exploded") - yield # pragma: no cover + async def _stream() -> AsyncIterator[WorkflowEvent]: + if emit_started: + yield WorkflowEvent("started") + yield WorkflowEvent("output", data="Public progress") + raise failure return _stream() @@ -1604,9 +1615,20 @@ async def _stream(): event_types = [event.type for event in events] assert event_types[0] == "RUN_STARTED" - assert "RUN_ERROR" in event_types + assert event_types[-1] == "RUN_ERROR" + assert event_types.count("RUN_ERROR") == 1 + assert "RUN_FINISHED" not in event_types + if emit_started: + assert event_types.index("TEXT_MESSAGE_END") < event_types.index("RUN_ERROR") run_error = next(event for event in events if event.type == "RUN_ERROR") - assert "workflow stream exploded" in run_error.message + assert run_error.code == "RuntimeError" + assert run_error.message == "Workflow execution failed." + records = [record for record in caplog.records if record.name == "agent_framework_ag_ui._workflow_run"] + assert len(records) == 1 + assert records[0].exc_info and records[0].exc_info[1] is failure + wire = "\n".join(event.model_dump_json(by_alias=True, exclude_none=True) for event in events) + assert private_detail not in wire + assert "workflow stream exploded" not in wire # ── Helper function unit tests ── @@ -2580,32 +2602,48 @@ async def noop(message: Any, ctx: WorkflowContext) -> None: assert "RUN_ERROR" not in event_types -async def test_workflow_run_failed_event(): - """Workflow 'failed' event should produce RUN_ERROR.""" +@pytest.mark.parametrize("close_on_error", [False, True]) +async def test_workflow_run_failed_event(close_on_error: bool, caplog: pytest.LogCaptureFixture) -> None: + """Structured failures preserve their code and log details only server-side.""" + private_detail = "SYNTHETIC_FAILURE_MESSAGE" + details = WorkflowErrorDetails( + message=private_detail, + error_type="TestError", + traceback="SYNTHETIC_FAILURE_TRACEBACK", + extra={"response": "SYNTHETIC_FAILURE_EXTRA"}, + ) + caplog.set_level(logging.ERROR, logger="agent_framework_ag_ui._workflow_run") class FailingWorkflow: - def run(self, **kwargs: Any): - async def _stream(): - yield SimpleNamespace(type="started") - yield SimpleNamespace( - type="failed", details=SimpleNamespace(message="it broke", error_type="TestError") - ) + def run(self, **kwargs: Any) -> AsyncIterator[WorkflowEvent]: + async def _stream() -> AsyncIterator[WorkflowEvent]: + yield WorkflowEvent("started") + yield WorkflowEvent.failed(details) return _stream() - events: list[Any] = [ - event - async for event in run_workflow_stream( - {"messages": [{"role": "user", "content": "go"}]}, cast(Any, FailingWorkflow()) - ) - ] + events: list[Any] = [] + async with aclosing( + run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, cast(Any, FailingWorkflow())) + ) as stream: + async for event in stream: + events.append(event) + if close_on_error and event.type == "RUN_ERROR": + break event_types = [event.type for event in events] - assert "RUN_STARTED" in event_types - assert "RUN_ERROR" in event_types + assert event_types == ["RUN_STARTED", "RUN_ERROR"] error_event = next(e for e in events if e.type == "RUN_ERROR") - assert error_event.message == "it broke" + assert error_event.message == "Workflow execution failed." assert error_event.code == "TestError" + wire = "\n".join(event.model_dump_json(by_alias=True, exclude_none=True) for event in events) + assert private_detail not in wire + assert "SYNTHETIC_FAILURE_TRACEBACK" not in wire + assert "SYNTHETIC_FAILURE_EXTRA" not in wire + assert private_detail in caplog.text + assert "SYNTHETIC_FAILURE_TRACEBACK" in caplog.text + assert "SYNTHETIC_FAILURE_EXTRA" not in caplog.text + assert sum(record.name == "agent_framework_ag_ui._workflow_run" for record in caplog.records) == 1 async def test_workflow_run_status_enum_state(): @@ -2660,18 +2698,25 @@ async def _stream(): assert text_end_idx < step_start_idx -async def test_workflow_run_executor_failed_event(): - """executor_failed event should emit activity snapshot with failed status.""" +@pytest.mark.parametrize("emit_failed", [False, True]) +async def test_workflow_run_executor_failed_event(emit_failed: bool, caplog: pytest.LogCaptureFixture) -> None: + """Executor failure activity must not bypass public error sanitization.""" + details = WorkflowErrorDetails( + message="SYNTHETIC_EXECUTOR_MESSAGE", + error_type="TestError", + traceback="SYNTHETIC_EXECUTOR_TRACEBACK", + extra={"response": "SYNTHETIC_EXECUTOR_EXTRA"}, + executor_id="agent_1", + ) + caplog.set_level(logging.ERROR, logger="agent_framework_ag_ui._workflow_run") class ExecutorFailWorkflow: - def run(self, **kwargs: Any): - async def _stream(): - yield SimpleNamespace(type="started") - yield SimpleNamespace( - type="executor_failed", - executor_id="agent_1", - details=SimpleNamespace(message="agent crashed"), - ) + def run(self, **kwargs: Any) -> AsyncIterator[WorkflowEvent]: + async def _stream() -> AsyncIterator[WorkflowEvent]: + yield WorkflowEvent("started") + yield WorkflowEvent.executor_failed("agent_1", details) + if emit_failed: + yield WorkflowEvent.failed(details) return _stream() @@ -2685,7 +2730,28 @@ async def _stream(): activity = [e for e in events if e.type == "ACTIVITY_SNAPSHOT"] assert len(activity) == 1 assert activity[0].content["status"] == "failed" - assert activity[0].content["details"]["message"] == "agent crashed" + assert activity[0].content["executor_id"] == "agent_1" + assert activity[0].content["details"] == { + "message": "Workflow execution failed.", + "error_type": "TestError", + } + if emit_failed: + assert events[-1].type == "RUN_ERROR" + assert sum(event.type == "RUN_ERROR" for event in events) == 1 + assert not any(event.type == "RUN_FINISHED" for event in events) + else: + assert events[-1].type == "RUN_FINISHED" + assert not any(event.type == "RUN_ERROR" for event in events) + activity_wire = activity[0].model_dump_json(by_alias=True, exclude_none=True) + assert "SYNTHETIC_EXECUTOR_MESSAGE" not in activity_wire + assert "SYNTHETIC_EXECUTOR_TRACEBACK" not in activity_wire + assert "SYNTHETIC_EXECUTOR_EXTRA" not in activity_wire + assert details.message in caplog.text + assert "SYNTHETIC_EXECUTOR_TRACEBACK" in caplog.text + assert "SYNTHETIC_EXECUTOR_EXTRA" not in caplog.text + assert sum(record.name == "agent_framework_ag_ui._workflow_run" for record in caplog.records) == 1 + assert details.message == "SYNTHETIC_EXECUTOR_MESSAGE" + assert details.extra == {"response": "SYNTHETIC_EXECUTOR_EXTRA"} async def test_workflow_run_list_base_event_output(): diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_http.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_http.py index 0a91748c6ba..0c67b579ab1 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_http.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_http.py @@ -6,9 +6,8 @@ configured :class:`HttpRequestHandler`, parses the response body, and assigns the parsed body and response headers to the declared state paths. -Security note: response bodies can echo secrets and may be very large. Diagnostic -messages produced for non-2xx responses truncate the body to 256 characters and -collapse CR/LF/TAB to spaces (parity with .NET ``FormatBodyForDiagnostics``). +Response bodies are excluded from non-2xx exceptions because they may contain +private backend data. Request URLs and status codes remain available for diagnostics. """ from __future__ import annotations @@ -40,10 +39,6 @@ logger = logging.getLogger(__name__) -_MAX_BODY_DIAGNOSTIC_LENGTH = 256 -_BODY_TRUNCATION_SUFFIX = " \u2026 [truncated]" - - # Body discriminator aliases. Long forms match the .NET object-model type # names so YAML produced by .NET round-trips. Short forms are the .NET YAML # convention used in test fixtures. @@ -69,24 +64,6 @@ def _get_path(action_def: Mapping[str, Any], key: str) -> str | None: return None -def _format_body_for_diagnostics(body: str | None) -> str: - """Truncate and sanitise a response body for inclusion in error messages. - - Mirrors the .NET ``FormatBodyForDiagnostics`` helper: - - - Empty/None -> empty string. - - Replaces CR/LF/TAB with spaces. - - Truncates to 256 chars with a unicode-ellipsis ``[truncated]`` suffix. - """ - if not body: - return "" - - truncated = len(body) > _MAX_BODY_DIAGNOSTIC_LENGTH - head = body[:_MAX_BODY_DIAGNOSTIC_LENGTH] if truncated else body - sanitized = head.replace("\r", " ").replace("\n", " ").replace("\t", " ") - return sanitized + _BODY_TRUNCATION_SUFFIX if truncated else sanitized - - def _parse_response_body(body: str | None) -> Any: """Parse an HTTP response body the same way the .NET executor does. @@ -148,8 +125,8 @@ class HttpRequestActionExecutor(DeclarativeActionExecutor): an Assistant :class:`agent_framework.Message` to ``System.conversations.{id}.messages``. - On non-2xx, still publishes ``responseHeaders`` (diagnostic) and raises - :class:`DeclarativeActionError` with a status-coded message containing a - truncated/sanitised body preview. + :class:`DeclarativeActionError` with the request URL and status code, + without including the response body. Transport errors (``httpx.TimeoutException``, ``TimeoutError``, ``httpx.HTTPError``) become :class:`DeclarativeActionError`. ``CancelledError`` @@ -229,12 +206,7 @@ async def handle_action( # Non-success path: still publish headers diagnostically, then raise. self._assign_response_headers(state, result) - body_preview = _format_body_for_diagnostics(result.body) - if body_preview: - message = f"HTTP request to '{url}' failed with status code {result.status_code}. Body: '{body_preview}'" - else: - message = f"HTTP request to '{url}' failed with status code {result.status_code}." - raise DeclarativeActionError(message) + raise DeclarativeActionError(f"HTTP request to '{url}' failed with status code {result.status_code}.") # ----- Field resolution ---------------------------------------------------- diff --git a/python/packages/declarative/tests/test_http_request_executor.py b/python/packages/declarative/tests/test_http_request_executor.py index f07f19d27a3..1e71bd48b02 100644 --- a/python/packages/declarative/tests/test_http_request_executor.py +++ b/python/packages/declarative/tests/test_http_request_executor.py @@ -389,30 +389,33 @@ async def test_no_body_omitted(self) -> None: class TestErrorHandling: - @pytest.mark.asyncio - async def test_non_2xx_raises_declarative_action_error(self) -> None: - handler = StubHandler(_err(status=500, body="server exploded")) - factory = WorkflowFactory(http_request_handler=handler) - workflow = factory.create_workflow_from_definition(_yaml(_action())) - with pytest.raises(DeclarativeActionError) as excinfo: - await workflow.run({}) - msg = str(excinfo.value) - assert "500" in msg - assert "server exploded" in msg - - @pytest.mark.asyncio - async def test_non_2xx_long_body_truncated(self) -> None: - big_body = "A" * 1000 - handler = StubHandler(_err(status=500, body=big_body)) + @pytest.mark.parametrize("status", [403, 500]) + @pytest.mark.parametrize( + "body", + [ + pytest.param("SYNTHETIC_PRIVATE_BODY", id="plain"), + pytest.param('{"detail":"SYNTHETIC_PRIVATE_BODY"}', id="json"), + pytest.param("SYNTHETIC_PRIVATE_BODY" + "A" * 1000, id="long"), + pytest.param("SYNTHETIC_PRIVATE_BODY\r\nsecond line\tend", id="control-chars"), + pytest.param("", id="empty"), + ], + ) + async def test_non_2xx_error_omits_body_but_preserves_status(self, status: int, body: str) -> None: + handler = StubHandler(_err(status=status, body=body)) factory = WorkflowFactory(http_request_handler=handler) workflow = factory.create_workflow_from_definition(_yaml(_action())) with pytest.raises(DeclarativeActionError) as excinfo: await workflow.run({}) msg = str(excinfo.value) - assert "[truncated]" in msg - assert len(msg) < 512 - # Should NOT contain the full 1000-char body - assert big_body not in msg + assert handler.call_count == 1 + assert str(status) in msg + assert _TEST_URL in msg + assert "SYNTHETIC_PRIVATE_BODY" not in msg + assert "Body:" not in msg + assert "[truncated]" not in msg + assert "\r" not in msg + assert "\n" not in msg + assert "\t" not in msg @pytest.mark.asyncio async def test_non_2xx_empty_body_omits_body_section(self) -> None: @@ -425,19 +428,6 @@ async def test_non_2xx_empty_body_omits_body_section(self) -> None: assert "404" in msg assert "Body:" not in msg - @pytest.mark.asyncio - async def test_non_2xx_control_chars_collapsed(self) -> None: - handler = StubHandler(_err(status=500, body="line1\r\nline2\tlong")) - factory = WorkflowFactory(http_request_handler=handler) - workflow = factory.create_workflow_from_definition(_yaml(_action())) - with pytest.raises(DeclarativeActionError) as excinfo: - await workflow.run({}) - msg = str(excinfo.value) - assert "\r" not in msg - assert "\n" not in msg - assert "\t" not in msg - assert "line1 line2 long" in msg - @pytest.mark.asyncio async def test_timeout_exception_becomes_declarative_action_error(self) -> None: handler = StubHandler(raise_exc=httpx.TimeoutException("timeout")) @@ -499,6 +489,67 @@ async def test_generic_exception_from_custom_handler_wrapped(self) -> None: assert _TEST_URL in msg +@pytest.mark.parametrize( + ("status", "body"), + [ + pytest.param(403, "SYNTHETIC_UNPUBLISHED_RESPONSE", id="private-403"), + pytest.param(500, "SYNTHETIC_UNPUBLISHED_RESPONSE", id="private-500"), + pytest.param(403, "Access denied", id="nonsecret-403-control"), + pytest.param(200, "SYNTHETIC_UNPUBLISHED_RESPONSE", id="unpublished-200-control"), + ], +) +async def test_http_response_is_not_published_in_agui_errors( + status: int, body: str, caplog: pytest.LogCaptureFixture +) -> None: + """An HTTP action without output bindings must not publish its body via errors.""" + pytest.importorskip("agent_framework_ag_ui") + + from ag_ui.core import CustomEvent, RunErrorEvent + from ag_ui.encoder import EventEncoder + from agent_framework_ag_ui import AgentFrameworkWorkflow + + handler = StubHandler( + HttpRequestResult( + status_code=status, + is_success_status_code=200 <= status < 300, + body=body, + headers={}, + ) + ) + factory = WorkflowFactory(http_request_handler=handler) + workflow = factory.create_workflow_from_definition(_yaml(_action())) + wrapper = AgentFrameworkWorkflow(workflow=workflow) + events = [ + event + async for event in wrapper.run({ + "thread_id": "http-error-thread", + "run_id": "http-error-run", + "messages": [{"role": "user", "content": "go"}], + }) + ] + encoder = EventEncoder() + wire = "".join(encoder.encode(event) for event in events) + event_types = [event.type for event in events] + + assert handler.call_count == 1 + assert event_types[0] == "RUN_STARTED" + assert not any(isinstance(event, CustomEvent) and event.name == "workflow_output" for event in events) + if status == 200: + assert event_types[-1] == "RUN_FINISHED" + assert "RUN_ERROR" not in event_types + else: + assert sum(isinstance(event, RunErrorEvent) for event in events) == 1 + assert "RUN_FINISHED" not in event_types + error = next(event for event in events if isinstance(event, RunErrorEvent)) + assert error.code == "DeclarativeActionError" + assert error.message == "Workflow execution failed." + assert _TEST_URL in caplog.text + assert f"status code {status}" in caplog.text + assert _TEST_URL not in wire + assert "SYNTHETIC_UNPUBLISHED_RESPONSE" not in wire + assert "SYNTHETIC_UNPUBLISHED_RESPONSE" not in caplog.text + + # ---------- Response headers ------------------------------------------------ From f39e460f7c14855e8b6c2e650af857b97994be78 Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Tue, 15 Sep 2026 01:43:29 +0000 Subject: [PATCH 057/108] Python: handle Literal annotations in is_instance_of (#8351) * Python: fix(workflows): support Literal message annotations in is_instance_of Executor handlers annotated with a Literal message type (e.g. `message: Literal["yes", "no"]`) pass @handler registration, executor construction, and WorkflowBuilder type validation, but crashed at message-delivery time with: TypeError: typing.Literal cannot be used with isinstance() is_instance_of() had no case for Literal origins and fell through to isinstance(data, Literal), which always raises. Executor.can_handle() and Executor._find_handler() call is_instance_of() directly, so any workflow containing such an executor failed on the first delivery. Add a Literal case that matches by allowed values with strict member types (bool never matches an int member), mirroring the existing _matches_annotation() semantics used by try_coerce_to_type. * fix(workflows): reorder type guard before equality and delegate _matches_annotation to is_instance_of - Reorder Literal evaluation to check type(data) is type(member) before data == member to prevent __eq__ side effects on mismatched types - Remove duplicate Literal check in _matches_annotation to delegate directly to is_instance_of - Add test verifying mismatched type with throwing __eq__ returns False without raising --------- Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Manohar paturi Co-authored-by: Eduard van Valkenburg Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../_workflows/_typing_utils.py | 10 ++++-- .../core/tests/workflow/test_executor.py | 35 ++++++++++++++++++- .../core/tests/workflow/test_typing_utils.py | 24 +++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index f7f1caf23f0..134641f8703 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -176,6 +176,14 @@ def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool: if origin is None: return isinstance(data, target_type) + # Case 1b: target_type is Literal[...] + # isinstance() cannot be used with Literal, so match by allowed values with + # strict member types, mirroring ``_matches_annotation``. Without this, + # executors whose handlers declare Literal message annotations crash at + # delivery time (Executor.can_handle/_find_handler call this directly). + if origin is Literal: + return any(type(data) is type(member) and data == member for member in args) + # Case 2: target_type is Optional[T] or Union[T1, T2, ...] # Optional[T] is really just as Union[T, None] if origin is UnionType: @@ -228,8 +236,6 @@ def is_instance_of(data: Any, target_type: type | UnionType | Any) -> bool: def _matches_annotation(data: Any, annotation: Any) -> bool: """Check an annotation that may not be runtime-checkable, treating unchecked ones as a match.""" - if get_origin(annotation) is Literal: - return any(data == member and type(data) is type(member) for member in get_args(annotation)) try: return is_instance_of(data, annotation) except TypeError: diff --git a/python/packages/core/tests/workflow/test_executor.py b/python/packages/core/tests/workflow/test_executor.py index d20fad32afd..eed1b96e894 100644 --- a/python/packages/core/tests/workflow/test_executor.py +++ b/python/packages/core/tests/workflow/test_executor.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import Generic, Literal, TypeVar import pytest from typing_extensions import Never @@ -138,6 +138,39 @@ async def handle_integer(self, number: int, ctx: WorkflowContext[int]) -> None: assert int_handler._handler_spec["output_types"] == [int] # type: ignore +def test_executor_literal_message_annotation(): + """Test that a handler with a Literal message annotation routes by allowed values. + + Literal annotations pass handler registration and workflow-build validation, + so delivery-time type matching (can_handle/_find_handler) must not raise + ``TypeError: typing.Literal cannot be used with isinstance()``. + """ + + class LiteralExecutor(Executor): + @handler + async def handle_choice(self, message: Literal["yes", "no"], ctx: WorkflowContext) -> None: # type: ignore + pass + + exec_instance = LiteralExecutor(id="literal") + assert exec_instance.can_handle(WorkflowMessage(data="yes", source_id="mock")) is True + assert exec_instance.can_handle(WorkflowMessage(data="no", source_id="mock")) is True + assert exec_instance.can_handle(WorkflowMessage(data="maybe", source_id="mock")) is False + + +async def test_workflow_with_literal_message_annotation(): + """Test that a workflow whose start handler uses a Literal annotation runs end to end.""" + + class LiteralStartExecutor(Executor): + @handler + async def handle_choice(self, message: Literal["yes", "no"], ctx: WorkflowContext[Never, str]) -> None: # type: ignore + await ctx.yield_output(f"got:{message}") + + start = LiteralStartExecutor(id="literal_start") + workflow = WorkflowBuilder(start_executor=start).build() + result = await workflow.run("yes") + assert result.get_outputs() == ["got:yes"] + + async def test_executor_invoked_event_contains_input_data(): """Test that executor_invoked event (type='executor_invoked') contains the input message data.""" diff --git a/python/packages/core/tests/workflow/test_typing_utils.py b/python/packages/core/tests/workflow/test_typing_utils.py index c7d8c3c56f7..df08120282c 100644 --- a/python/packages/core/tests/workflow/test_typing_utils.py +++ b/python/packages/core/tests/workflow/test_typing_utils.py @@ -171,6 +171,30 @@ def test_union_types() -> None: assert not is_instance_of(5.0, int | str) +def test_literal_types() -> None: + """Test Literal types match by allowed values without raising TypeError. + + ``isinstance()`` cannot be used with ``typing.Literal``; matching must fall + back to value equality with strict member types (bool is not int, etc.). + """ + assert is_instance_of("a", Literal["a", "b"]) + assert is_instance_of("b", Literal["a", "b"]) + assert not is_instance_of("c", Literal["a", "b"]) + assert is_instance_of(1, Literal[1, 2]) + # bool must not match an int literal member even though True == 1 + assert not is_instance_of(True, Literal[1, 2]) + # nested literals inside other containers keep working + assert is_instance_of(["a"], list[Literal["a", "b"]]) + assert not is_instance_of(["c"], list[Literal["a", "b"]]) + + class Explosive: + def __eq__(self, other: object) -> bool: + raise RuntimeError("Equality should not be called when types differ") + + # Strict type guard runs before equality, avoiding __eq__ on mismatched types + assert not is_instance_of(Explosive(), Literal["yes"]) + + def test_list_types() -> None: """Test list types with various element types.""" assert is_instance_of([], list) From 490d0dada9b8dbad127761be64083e93df5d7b45 Mon Sep 17 00:00:00 2001 From: WhaleTech Date: Tue, 15 Sep 2026 02:37:43 +0000 Subject: [PATCH 058/108] Python: Restore runtime tools on nested workflow response resume (#8345) Co-authored-by: WhaleTech <304937387+ryo-whaletech@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../_workflows/_workflow_executor.py | 5 +++- .../core/tests/workflow/test_sub_workflow.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 749283d2d74..691dafc03b8 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -634,5 +634,8 @@ async def _handle_response( # Forward the response to the sub-workflow, which resumes and validates it against its own # pending requests, then process whatever the sub-workflow produces. - result = await self.workflow.run(responses={request_id: response}) + result = await self.workflow.run( + responses={request_id: response}, + tools=ctx.get_runtime_tools(), + ) await self._process_workflow_result(result, ctx) diff --git a/python/packages/core/tests/workflow/test_sub_workflow.py b/python/packages/core/tests/workflow/test_sub_workflow.py index f4ca5b38190..7e541470121 100644 --- a/python/packages/core/tests/workflow/test_sub_workflow.py +++ b/python/packages/core/tests/workflow/test_sub_workflow.py @@ -219,6 +219,34 @@ async def test_basic_sub_workflow() -> None: assert parent.result.is_valid is True +async def test_propagated_sub_workflow_response_receives_runtime_tools() -> None: + """Runtime tools supplied on parent resume reach the child response handler.""" + captured_runtime_tools: list[Any] = [] + + class RequestingExecutor(Executor): + @handler + async def start(self, input_data: str, ctx: WorkflowContext) -> None: + del input_data + await ctx.request_info("Continue?", str, request_id="child-request") + + @response_handler + async def resume(self, request: str, response: str, ctx: WorkflowContext) -> None: + del request, response + captured_runtime_tools.append(ctx.get_runtime_tools()) + + child = WorkflowBuilder(start_executor=RequestingExecutor(id="child-requester")).build() + child_executor = WorkflowExecutor(child, id="child", propagate_request=True) + parent = WorkflowBuilder(start_executor=child_executor).build() + + result = await parent.run("start") + assert [event.request_id for event in result.get_request_info_events()] == ["child-request"] + + runtime_tool = object() + await parent.run(responses={"child-request": "yes"}, tools=[runtime_tool]) + + assert captured_runtime_tools == [[runtime_tool]] + + async def test_sub_workflow_with_interception(): """Test sub-workflow with parent interception and conditional forwarding.""" # Create sub-workflow From 624f0cc3984e9c35e4a20170c07512e0b0b5e1de Mon Sep 17 00:00:00 2001 From: Manohar Paturi Date: Tue, 15 Sep 2026 02:41:30 +0000 Subject: [PATCH 059/108] Python: fix(core): preserve instruction order on partial dedup in prepend_instructions_to_messages (#8353) When only a prefix of the instruction list is already present as leading messages, the previous dedup logic collected the non-matching remainder and prepended it in front of the matched messages. For instructions ["First", "Second"] with an existing leading system message "First", the result became ["Second", "First", ...] - inverting the relative order of the instructions. Deduplicate only the matching prefix and insert the remaining instructions immediately after it, so the instruction order is preserved. Behavior for the previously tested cases (no match, full match, single instruction) is unchanged. Co-authored-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../packages/core/agent_framework/_types.py | 21 ++++++---- python/packages/core/tests/core/test_types.py | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index b2ea47626e8..990c55c4ae9 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -2009,20 +2009,27 @@ def prepend_instructions_to_messages( if isinstance(instructions, str): instructions = [instructions] - # Skip instructions that are already present as leading messages with the + # Skip instructions that are already present as the leading messages with the # same role and text. This prevents duplicate system messages when # instructions are injected by multiple layers (e.g. Agent + chat client). - deduplicated: list[str] = [] + # Only a *prefix* of instructions can be deduplicated: once an instruction + # does not match, any remaining instructions must keep their relative order. + # Prepending the non-matching remainder in front of the matched messages + # would invert the instruction order (e.g. ["First", "Second"] with a + # leading "First" message becoming ["Second", "First", ...]), so the + # remainder is inserted right after the matched prefix instead. + matched_count = 0 for idx, instr in enumerate(instructions): if idx < len(messages) and messages[idx].role == role and messages[idx].text == instr: - continue - deduplicated.append(instr) + matched_count += 1 + else: + break - if not deduplicated: + if matched_count == len(instructions): return messages - instruction_messages = [Message(role, [instr]) for instr in deduplicated] - return [*instruction_messages, *messages] + instruction_messages = [Message(role, [instr]) for instr in instructions[matched_count:]] + return [*messages[:matched_count], *instruction_messages, *messages[matched_count:]] # region ChatResponse diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index b9810a98d6d..846c45e0b2a 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -5018,6 +5018,48 @@ def test_prepend_instructions_custom_role(): assert result[0].role == "developer" +def test_prepend_instructions_partial_dedup_preserves_order(): + """Test that partially deduplicated instructions keep their relative order. + + When only a prefix of the instructions is already present as leading + messages, the remaining instructions must still appear in their original + order after the matched prefix, not inverted in front of it. + """ + from agent_framework._types import prepend_instructions_to_messages + + messages = [ + Message("system", ["First instruction"]), + Message("user", ["Hello"]), + ] + result = prepend_instructions_to_messages(messages, ["First instruction", "Second instruction"]) + + assert [message.text for message in result] == [ + "First instruction", + "Second instruction", + "Hello", + ] + assert result[0] is messages[0] + assert result[2] is messages[1] + + +def test_prepend_instructions_partial_dedup_no_match_keeps_prefix_behavior(): + """Test that a non-matching leading message still yields a plain prepend.""" + from agent_framework._types import prepend_instructions_to_messages + + messages = [ + Message("system", ["Different instruction"]), + Message("user", ["Hello"]), + ] + result = prepend_instructions_to_messages(messages, ["First instruction", "Second instruction"]) + + assert [message.text for message in result] == [ + "First instruction", + "Second instruction", + "Different instruction", + "Hello", + ] + + # endregion From 91b3c8b02248e675079419635eceabc7d067bf7c Mon Sep 17 00:00:00 2001 From: lsmlhi_25 Date: Tue, 15 Sep 2026 02:43:20 +0000 Subject: [PATCH 060/108] Python: FileCheckpointStorage save/load symmetry (#8181) (#8214) * Python: FileCheckpointStorage save/load symmetry (#8181) * Python: harden FileCheckpointStorage exception contract (#8181) Include encoding in save-time validation, wrap invalid UTF-8 on load, and delegate list_checkpoint_ids to list_checkpoints. * Python: write FileCheckpointStorage JSON as UTF-8 (#8181) Match load()'s explicit UTF-8 encoding so save/load stay symmetric on non-UTF-8 default locales. * Python: clarify UTF-8 vs JSON load errors for FileCheckpointStorage (#8181) Copilot review: UnicodeDecodeError should not claim invalid JSON. * Python: avoid blocking Path.glob in async checkpoint test (#8181) Package Checks ruff rule blocking-path-method-in-async-function failed on Path(tmpdir).glob in an async test. Use asyncio.to_thread like other core tests. --------- Co-authored-by: minelhi <3417378192@qq.com> --- .../agent_framework/_workflows/_checkpoint.py | 53 ++++++++++++------- .../core/tests/workflow/test_checkpoint.py | 48 +++++++++++++++++ .../test_checkpoint_unrestricted_pickle.py | 18 +++++++ 3 files changed, 99 insertions(+), 20 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index d9b30d83919..c6a6a9dc891 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -320,16 +320,30 @@ async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: Returns: The unique ID of the saved checkpoint. + + Raises: + WorkflowCheckpointException: If the checkpoint cannot be encoded or would + fail to decode under this storage's ``allowed_checkpoint_types``. """ - from ._checkpoint_encoding import encode_checkpoint_value + from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value file_path = self._validate_file_path(checkpoint.checkpoint_id) checkpoint_dict = checkpoint.to_dict() - encoded_checkpoint = encode_checkpoint_value(checkpoint_dict) + # Fail at save time if encoding or restore validation fails (#8181). + try: + encoded_checkpoint = encode_checkpoint_value(checkpoint_dict) + decode_checkpoint_value(encoded_checkpoint, allowed_types=self._allowed_types) + except WorkflowCheckpointException: + raise + except Exception as ex: + raise WorkflowCheckpointException( + f"Checkpoint {checkpoint.checkpoint_id} cannot be encoded or restored under " + "this storage's allowed types; refusing to save." + ) from ex def _write_atomic() -> None: tmp_path = file_path.with_suffix(".json.tmp") - with open(tmp_path, "w") as f: + with open(tmp_path, "w", encoding="utf-8") as f: json.dump(encoded_checkpoint, f, indent=2, ensure_ascii=False) os.replace(tmp_path, file_path) @@ -357,10 +371,19 @@ async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}") def _read() -> dict[str, Any]: - with open(file_path) as f: + with open(file_path, encoding="utf-8") as f: return json.load(f) - encoded_checkpoint = await asyncio.to_thread(_read) + try: + encoded_checkpoint = await asyncio.to_thread(_read) + except UnicodeDecodeError as ex: + raise WorkflowCheckpointException( + f"Checkpoint file for {checkpoint_id} is not valid UTF-8 and cannot be loaded." + ) from ex + except json.JSONDecodeError as ex: + raise WorkflowCheckpointException( + f"Checkpoint file for {checkpoint_id} is not valid JSON and cannot be loaded." + ) from ex from ._checkpoint_encoding import decode_checkpoint_value @@ -386,7 +409,7 @@ def _list_checkpoints() -> list[WorkflowCheckpoint]: checkpoints: list[WorkflowCheckpoint] = [] for file_path in self.storage_path.glob("*.json"): try: - with open(file_path) as f: + with open(file_path, encoding="utf-8") as f: encoded_checkpoint = json.load(f) from ._checkpoint_encoding import decode_checkpoint_value @@ -446,18 +469,8 @@ async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID] Returns: A list of checkpoint IDs for the specified workflow name. + Only includes checkpoints that can be decoded under this storage's + allowed types (aligned with :meth:`list_checkpoints`, #8181). """ - - def _list_ids() -> list[CheckpointID]: - checkpoint_ids: list[CheckpointID] = [] - for file_path in self.storage_path.glob("*.json"): - try: - with open(file_path) as f: - data = json.load(f) - if data.get("workflow_name") == workflow_name: - checkpoint_ids.append(data.get("checkpoint_id", file_path.stem)) - except Exception as e: - logger.warning(f"Failed to read checkpoint file {file_path}: {e}") - return checkpoint_ids - - return await asyncio.to_thread(_list_ids) + checkpoints = await self.list_checkpoints(workflow_name=workflow_name) + return [checkpoint.checkpoint_id for checkpoint in checkpoints] diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py index be8de7c13b6..5c06b617c89 100644 --- a/python/packages/core/tests/workflow/test_checkpoint.py +++ b/python/packages/core/tests/workflow/test_checkpoint.py @@ -1289,6 +1289,54 @@ async def test_file_checkpoint_storage_corrupted_file(): assert checkpoints == [] +async def test_file_checkpoint_storage_load_invalid_json_raises(): + """Issue #8181: load wraps JSONDecodeError as WorkflowCheckpointException.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + bad_id = "bad-json-checkpoint" + bad_file = Path(temp_dir) / f"{bad_id}.json" + with open(bad_file, "w") as f: # noqa: ASYNC230 + f.write("{ not json") + with pytest.raises(WorkflowCheckpointException, match="not valid JSON"): + await storage.load(bad_id) + + +async def test_file_checkpoint_storage_load_invalid_utf8_raises(): + """Issue #8181: load wraps UnicodeDecodeError as WorkflowCheckpointException.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + bad_id = "bad-utf8-checkpoint" + bad_file = Path(temp_dir) / f"{bad_id}.json" + bad_file.write_bytes(b'{"x": "\xff\xfe"}') + with pytest.raises(WorkflowCheckpointException, match="not valid UTF-8"): + await storage.load(bad_id) + + +async def test_file_checkpoint_storage_list_ids_matches_list_decode_filter(): + """Issue #8181: list_checkpoint_ids skips undecodable files like list_checkpoints.""" + from tests.workflow.test_checkpoint_unrestricted_pickle import _AllowedTestState + + with tempfile.TemporaryDirectory() as temp_dir: + type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}" + writer = FileCheckpointStorage(temp_dir, allowed_checkpoint_types=[type_key]) + good = WorkflowCheckpoint(workflow_name="wf-a", graph_signature_hash="h") + await writer.save(good) + blocked = WorkflowCheckpoint( + workflow_name="wf-a", + graph_signature_hash="h", + checkpoint_id="orphan-blocked", + state={"x": _AllowedTestState(name="x", value=1)}, + ) + await writer.save(blocked) + + reader = FileCheckpointStorage(temp_dir) # no allow list + listed = await reader.list_checkpoints(workflow_name="wf-a") + ids = await reader.list_checkpoint_ids(workflow_name="wf-a") + assert [c.checkpoint_id for c in listed] == ids + assert "orphan-blocked" not in ids + assert good.checkpoint_id in ids + + async def test_file_checkpoint_storage_json_serialization(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) diff --git a/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py index 4e89dd1b2cc..1f78f4e6ad4 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py +++ b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py @@ -11,6 +11,7 @@ - Built-in safe types and framework types are always allowed """ +import asyncio import base64 import enum import os @@ -18,6 +19,7 @@ import tempfile from dataclasses import dataclass from datetime import datetime, timezone +from pathlib import Path from typing import Any import pytest @@ -287,6 +289,22 @@ async def test_file_storage_blocks_unlisted_user_type(): await load_storage.load(checkpoint.checkpoint_id) +async def test_file_storage_rejects_unlisted_user_type_at_save(): + """Issue #8181: same storage must refuse to save what it cannot restore.""" + from agent_framework import WorkflowCheckpoint + + with tempfile.TemporaryDirectory() as tmpdir: + storage = FileCheckpointStorage(tmpdir) + checkpoint = WorkflowCheckpoint( + workflow_name="test", + graph_signature_hash="hash", + state={"data": _AllowedTestState(name="test", value=1)}, + ) + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked|Unable to save|cannot be restored|cannot be encoded"): + await storage.save(checkpoint) + assert not await asyncio.to_thread(lambda: list(Path(tmpdir).glob("*.json"))) + + async def test_file_storage_allows_listed_user_type(): """FileCheckpointStorage allows user types listed in allowed_checkpoint_types.""" from agent_framework import WorkflowCheckpoint From e07c6bc75dcb67808f84794d7c5d7752c5a4cab4 Mon Sep 17 00:00:00 2001 From: Patel Namraa <131944677+Namraa310806@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:51:21 +0000 Subject: [PATCH 061/108] Python: fix: preserve synthetic compaction summaries (#7944) * fix: preserve synthetic compaction summaries * fix: preserve compaction summary attribution * fix: preserve attribution in compaction summaries * fixing failing workflows * fixing failing workflows --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../core/agent_framework/_compaction.py | 92 +- .../core/tests/core/test_compaction.py | 897 +++++++++++++++++- .../agent_framework_monty/_monty_bridge.py | 39 +- 3 files changed, 998 insertions(+), 30 deletions(-) diff --git a/python/packages/core/agent_framework/_compaction.py b/python/packages/core/agent_framework/_compaction.py index b1d627b6359..870ced4d15e 100644 --- a/python/packages/core/agent_framework/_compaction.py +++ b/python/packages/core/agent_framework/_compaction.py @@ -48,6 +48,44 @@ } +def _deduplicate_origin_session_ids(origin_session_ids: Iterable[str]) -> list[str]: + """Return origin session IDs in first-seen order without duplicates.""" + unique_origin_session_ids: list[str] = [] + seen_origin_session_ids: set[str] = set() + for origin_session_id in origin_session_ids: + if origin_session_id not in seen_origin_session_ids: + seen_origin_session_ids.add(origin_session_id) + unique_origin_session_ids.append(origin_session_id) + return unique_origin_session_ids + + +def _aggregate_origin_session_ids(messages: Sequence[Message]) -> list[str]: + """Aggregate origin_session_ids from a sequence of Message objects. + + Extracts origin_session_ids from each message's _attribution and returns + a deduplicated list preserving first-seen order. Messages without attribution + or without origin_session_ids are silently skipped. + + Args: + messages: The Message objects to aggregate provenance from. + + Returns: + Deduplicated origin_session_ids in first-seen order. + """ + origin_session_ids: list[str] = [] + for message in messages: + attribution = message.additional_properties.get("_attribution") + if not isinstance(attribution, Mapping): + continue + origins = attribution.get("origin_session_ids") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType] + if not isinstance(origins, Sequence) or isinstance(origins, str): + continue + for origin in cast("Sequence[Any]", origins): + if isinstance(origin, str): + origin_session_ids.append(origin) + return _deduplicate_origin_session_ids(origin_session_ids) + + @runtime_checkable class TokenizerProtocol(Protocol): """Protocol for token counters used by token-aware compaction strategies.""" @@ -1142,19 +1180,28 @@ async def __call__(self, messages: list[Message]) -> bool: _set_group_summarized_by_summary_id(msg, summary_id) changed = set_excluded(msg, excluded=True, reason="tool_result_compaction") or changed + # Aggregate provenance directly from the actual Message objects being summarized + # This ensures origin_session_ids are preserved even when message_id is None + aggregated_origins = _aggregate_origin_session_ids(group_msgs) + # Insert summary with forward links to the originals. summary_annotation = { SUMMARY_OF_MESSAGE_IDS_KEY: original_message_ids, SUMMARY_OF_GROUP_IDS_KEY: [group_id], } insertion_index = starts.get(group_id, 0) + + summary_additional_properties: dict[str, Any] = { + GROUP_ANNOTATION_KEY: summary_annotation, + } + if aggregated_origins: + summary_additional_properties["_attribution"] = {"origin_session_ids": aggregated_origins} + summary_message = Message( role="assistant", contents=[summary_text], message_id=summary_id, - additional_properties={ - GROUP_ANNOTATION_KEY: summary_annotation, - }, + additional_properties=summary_additional_properties, ) messages.insert(insertion_index, summary_message) annotate_message_groups(messages, from_index=insertion_index, force_reannotate=False) @@ -1570,13 +1617,21 @@ async def __call__(self, messages: list[Message]) -> bool: SUMMARY_OF_GROUP_IDS_KEY: summary_of_group_ids, } + # Aggregate provenance directly from the actual Message objects being summarized + # This ensures origin_session_ids are preserved even when message_id is None + aggregated_origins = _aggregate_origin_session_ids(messages_to_summarize) + + summary_additional_properties: dict[str, Any] = { + GROUP_ANNOTATION_KEY: summary_annotation, + } + if aggregated_origins: + summary_additional_properties["_attribution"] = {"origin_session_ids": aggregated_origins} + summary_message = Message( role="assistant", contents=[summary_text], message_id=summary_id, - additional_properties={ - GROUP_ANNOTATION_KEY: summary_annotation, - }, + additional_properties=summary_additional_properties, ) for message in messages_to_summarize: @@ -1817,6 +1872,11 @@ async def before_run( if not all_messages: return + # Track each original message's source before compaction + source_by_id: dict[int, str] = { + id(message): sid for sid, msgs in context.context_messages.items() for message in msgs + } + await _run_compaction_strategy( all_messages, strategy=self.before_strategy, @@ -1825,9 +1885,23 @@ async def before_run( ) projected = project_included_messages(all_messages) - projected_set = {id(m) for m in projected} - for sid in list(context.context_messages): - context.context_messages[sid] = [m for m in context.context_messages[sid] if id(m) in projected_set] + + # Rebuild provider message lists from the projected list, preserving source attribution + # and including new synthetic messages created by compaction strategies + rebuilt: dict[str, list[Message]] = {sid: [] for sid in context.context_messages} + fallback_sid = next(iter(rebuilt), self.source_id) + last_sid = fallback_sid + for message in projected: + # For new synthetic messages, use the last known source; for original messages, use their tracked source + sid = source_by_id.get(id(message), last_sid) + if sid not in rebuilt: + # If the source was somehow removed during compaction, fall back to the last known source + sid = last_sid + rebuilt[sid].append(message) + last_sid = sid + + context.context_messages.clear() + context.context_messages.update(rebuilt) async def after_run( self, diff --git a/python/packages/core/tests/core/test_compaction.py b/python/packages/core/tests/core/test_compaction.py index bc62b0b6d90..8e48faa4bf4 100644 --- a/python/packages/core/tests/core/test_compaction.py +++ b/python/packages/core/tests/core/test_compaction.py @@ -104,10 +104,11 @@ def _assistant_reasoning_and_function_calls(*call_ids: str) -> Message: return Message(role="assistant", contents=contents) -def _tool_result(call_id: str, result: str) -> Message: +def _tool_result(call_id: str, result: str, message_id: str | None = None) -> Message: return Message( role="tool", contents=[Content.from_function_result(call_id=call_id, result=result)], + message_id=message_id, ) @@ -2357,3 +2358,897 @@ def test_serialize_message_preserves_non_ascii_for_token_count() -> None: assert text in serialized assert "\\u3053" not in serialized assert tokenizer.count_tokens(serialized) < tokenizer.count_tokens(escaped) + + +async def test_compaction_provider_before_run_preserves_synthetic_summary_messages() -> None: + """Test that before_run preserves synthetic summary messages created by compaction strategies. + + This is a regression test for a bug where ToolResultCompactionStrategy and SummarizationStrategy + create new synthetic Message objects during compaction, but CompactionProvider.before_run only + filtered existing messages by id(), causing the new summary messages to be silently dropped. + """ + from agent_framework._sessions import SessionContext + + # Create a session context with some messages from a history provider + messages = [ + Message(role="user", contents=["hello"]), + _assistant_function_call("c1"), + _tool_result("c1", "sunny, 18C"), + Message(role="assistant", contents=["final response"]), + ] + + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("history", messages) + + # Verify initial state - 4 messages from history provider + assert len(ctx.context_messages["history"]) == 4 + assert len(ctx.get_messages()) == 4 + + # Apply ToolResultCompactionStrategy via CompactionProvider.before_run + provider = CompactionProvider(before_strategy=ToolResultCompactionStrategy(keep_last_tool_call_groups=0)) + + await provider.before_run(agent=None, session=None, context=ctx, state={}) + + # After compaction, we should have 3 messages: + # - user message + # - synthetic summary message "[Tool results: tool: sunny, 18C]" + # - final assistant response + final_messages = ctx.get_messages() + assert len(final_messages) == 3, f"Expected 3 messages after compaction, got {len(final_messages)}" + + # Verify the summary message is present - it should be an assistant message with tool results summary + summary_messages = [ + m + for m in final_messages + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Tool results" in c.text for c in m.contents) + ] + assert len(summary_messages) == 1, "Summary message should be present in final messages" + + # Verify the context_messages dict was also updated correctly + assert len(ctx.context_messages["history"]) == 3, "History provider should have 3 messages after compaction" + + # Verify the summary is in the history provider's list + history_summary_messages = [ + m + for m in ctx.context_messages["history"] + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Tool results" in c.text for c in m.contents) + ] + assert len(history_summary_messages) == 1, "Summary message should be in history provider's message list" + + +async def test_compaction_provider_before_run_preserves_summarization_strategy_messages() -> None: + """Test that before_run preserves synthetic summary messages created by SummarizationStrategy. + + This is a regression test for the same bug affecting SummarizationStrategy, which creates + new synthetic Message objects during compaction. + """ + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._sessions import SessionContext + + # Create a mock chat client for summarization + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary of conversation" + mock_client.get_response.return_value = mock_response + + # Create messages that will trigger summarization + messages = [ + Message(role="user", contents=["first message"]), + Message(role="assistant", contents=["first response"]), + Message(role="user", contents=["second message"]), + Message(role="assistant", contents=["second response"]), + Message(role="user", contents=["third message"]), + Message(role="assistant", contents=["third response"]), + ] + + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("history", messages) + + # Verify initial state - 6 messages from history provider + assert len(ctx.context_messages["history"]) == 6 + assert len(ctx.get_messages()) == 6 + + # Apply SummarizationStrategy via CompactionProvider.before_run + # Set target_count=2 to trigger summarization since we have 6 non-system messages + provider = CompactionProvider( + before_strategy=SummarizationStrategy( + client=mock_client, + target_count=2, + threshold=0, + ) + ) + + await provider.before_run(agent=None, session=None, context=ctx, state={}) + + # After compaction, we should have messages including the synthetic summary + final_messages = ctx.get_messages() + assert len(final_messages) >= 3, ( + f"Expected at least 3 messages after compaction (summary + retained), got {len(final_messages)}" + ) + + # Verify the summary message is present + summary_messages = [ + m + for m in final_messages + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Summary of conversation" in c.text for c in m.contents) + ] + assert len(summary_messages) == 1, "Summary message should be present in final messages" + + # Verify the context_messages dict was also updated correctly + assert len(ctx.context_messages["history"]) >= 3, ( + "History provider should have at least 3 messages after compaction" + ) + + # Verify the summary is in the history provider's list + history_summary_messages = [ + m + for m in ctx.context_messages["history"] + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Summary of conversation" in c.text for c in m.contents) + ] + assert len(history_summary_messages) == 1, "Summary message should be in history provider's message list" + + +async def test_compaction_provider_preserves_attribution_on_synthetic_summaries() -> None: + """Test that synthetic summary messages preserve origin_session_ids from summarized messages. + + This is a regression test for a security/governance issue where cross-session attribution + was lost when messages were summarized, potentially bypassing CrossSessionObserver governance. + """ + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._sessions import SessionContext + + # Create a mock chat client for summarization + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary of conversation" + mock_client.get_response.return_value = mock_response + + # Create messages with cross-session attribution + messages = [ + Message(role="user", contents=["first message"], message_id="msg1"), + Message(role="assistant", contents=["first response"], message_id="msg2"), + Message(role="user", contents=["second message"], message_id="msg3"), + Message(role="assistant", contents=["second response"], message_id="msg4"), + ] + + # Add attribution to some messages to simulate cross-session content + messages[0].additional_properties["_attribution"] = { + "source_id": "history", + "source_type": "HistoryProvider", + "origin_session_ids": ["session-prior-1"], + } + messages[1].additional_properties["_attribution"] = { + "source_id": "history", + "source_type": "HistoryProvider", + "origin_session_ids": ["session-prior-2"], + } + + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("history", messages) + + # Apply SummarizationStrategy via CompactionProvider.before_run + provider = CompactionProvider( + before_strategy=SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + ) + + await provider.before_run(agent=None, session=None, context=ctx, state={}) + + # Find the synthetic summary message + final_messages = ctx.get_messages() + summary_messages = [ + m + for m in final_messages + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Summary of conversation" in c.text for c in m.contents) + ] + assert len(summary_messages) == 1, "Summary message should be present" + + summary = summary_messages[0] + + # Verify the summary preserves the aggregated origin_session_ids + summary_attribution = summary.additional_properties.get("_attribution") + assert summary_attribution is not None, "Summary should have attribution" + assert isinstance(summary_attribution, dict), "Attribution should be a dict" + + origin_session_ids = summary_attribution.get("origin_session_ids") + assert origin_session_ids is not None, "Summary should have origin_session_ids" + assert isinstance(origin_session_ids, list), "origin_session_ids should be a list" + + # Should contain both session IDs, deduplicated + assert set(origin_session_ids) == {"session-prior-1", "session-prior-2"}, ( + f"Expected both session IDs, got {origin_session_ids}" + ) + + +async def test_compaction_provider_preserves_attribution_on_tool_result_summaries() -> None: + """Test that ToolResultCompactionStrategy summaries preserve origin_session_ids.""" + from agent_framework._sessions import SessionContext + + # Create messages with cross-session attribution + messages = [ + Message(role="user", contents=["hello"], message_id="msg1"), + _assistant_function_call("c1"), + _tool_result("c1", "sunny, 18C", message_id="msg2"), + Message(role="assistant", contents=["final response"], message_id="msg4"), + ] + + # Add attribution to the tool result to simulate cross-session content + messages[2].additional_properties["_attribution"] = { + "source_id": "history", + "source_type": "HistoryProvider", + "origin_session_ids": ["session-prior"], + } + + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("history", messages) + + # Apply ToolResultCompactionStrategy via CompactionProvider.before_run + provider = CompactionProvider(before_strategy=ToolResultCompactionStrategy(keep_last_tool_call_groups=0)) + + await provider.before_run(agent=None, session=None, context=ctx, state={}) + + # Find the synthetic summary message + final_messages = ctx.get_messages() + summary_messages = [ + m + for m in final_messages + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Tool results" in c.text for c in m.contents) + ] + assert len(summary_messages) == 1, "Tool result summary should be present" + + summary = summary_messages[0] + + # Verify the summary preserves the origin_session_ids + summary_attribution = summary.additional_properties.get("_attribution") + assert summary_attribution is not None, "Summary should have attribution" + assert isinstance(summary_attribution, dict), "Attribution should be a dict" + + origin_session_ids = summary_attribution.get("origin_session_ids") + assert origin_session_ids is not None, "Summary should have origin_session_ids" + assert isinstance(origin_session_ids, list), "origin_session_ids should be a list" + assert origin_session_ids == ["session-prior"], f"Expected session-prior, got {origin_session_ids}" + + +async def test_compaction_provider_deduplicates_origin_session_ids() -> None: + """Test that duplicate origin_session_ids are deduplicated in synthetic summaries.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._sessions import SessionContext + + # Create a mock chat client for summarization + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary" + mock_client.get_response.return_value = mock_response + + # Create messages where multiple messages have the same origin + messages = [ + Message(role="user", contents=["msg1"], message_id="msg1"), + Message(role="assistant", contents=["resp1"], message_id="msg2"), + Message(role="user", contents=["msg2"], message_id="msg3"), + Message(role="assistant", contents=["resp2"], message_id="msg4"), + ] + + # All messages have the same origin_session_id + for msg in messages: + msg.additional_properties["_attribution"] = { + "source_id": "history", + "source_type": "HistoryProvider", + "origin_session_ids": ["session-prior"], + } + + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("history", messages) + + provider = CompactionProvider( + before_strategy=SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + ) + + await provider.before_run(agent=None, session=None, context=ctx, state={}) + + # Find the summary + final_messages = ctx.get_messages() + summary_messages = [ + m + for m in final_messages + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Summary" in c.text for c in m.contents) + ] + assert len(summary_messages) == 1 + + summary = summary_messages[0] + origin_session_ids = summary.additional_properties.get("_attribution", {}).get("origin_session_ids") + + # Should be deduplicated - only one instance of session-prior + assert origin_session_ids == ["session-prior"], f"Expected single deduplicated session ID, got {origin_session_ids}" + + +async def test_compaction_provider_no_attribution_when_sources_have_none() -> None: + """Test that summaries of messages without attribution don't get attribution added.""" + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._sessions import SessionContext + + # Create a mock chat client for summarization + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary" + mock_client.get_response.return_value = mock_response + + # Create messages without any attribution + messages = [ + Message(role="user", contents=["msg1"], message_id="msg1"), + Message(role="assistant", contents=["resp1"], message_id="msg2"), + ] + + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("history", messages) + + provider = CompactionProvider( + before_strategy=SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + ) + + await provider.before_run(agent=None, session=None, context=ctx, state={}) + + # Find the summary + final_messages = ctx.get_messages() + summary_messages = [ + m + for m in final_messages + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Summary" in c.text for c in m.contents) + ] + assert len(summary_messages) == 1 + + summary = summary_messages[0] + summary_attribution = summary.additional_properties.get("_attribution") + + # Should not have attribution since sources had none + assert summary_attribution is None or "origin_session_ids" not in summary_attribution, ( + "Summary should not have origin_session_ids when sources have none" + ) + + +async def test_summarization_strategy_preserves_attribution_with_message_id_none() -> None: + """Test that SummarizationStrategy preserves attribution when message_id is None. + + This is the critical regression case for Evan's review comment: message_id is optional + and must not be required for provenance preservation. The strategy aggregates provenance + directly from the actual Message objects being summarized. + """ + from unittest.mock import AsyncMock, MagicMock + + # Create a mock chat client for summarization + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary of cross-session content" + mock_client.get_response.return_value = mock_response + + # Create messages with cross-session attribution but NO message_id + # This is the case that would fail with the old message_id-based recovery + # Add 3 messages to ensure the oldest 2 get summarized (target_count=1) + messages = [ + Message( + role="user", + contents=["msg1"], + message_id=None, # Explicitly None + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-prior-1"], + } + }, + ), + Message( + role="assistant", + contents=["resp1"], + message_id=None, # Explicitly None + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-prior-2"], + } + }, + ), + Message( + role="user", + contents=["msg2"], + message_id=None, # Explicitly None + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-current"], + } + }, + ), + ] + + # Apply summarization strategy directly + strategy = SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + + # Annotate groups first (required for strategy) + annotate_message_groups(messages) + + changed = await strategy(messages) + assert changed, "Strategy should have changed messages" + + # Find the summary message + summary_messages = [m for m in messages if m.message_id and m.message_id.startswith("summary_")] + assert len(summary_messages) == 1, "Should have created one summary" + + summary = summary_messages[0] + + # Verify the summary preserves the aggregated origin_session_ids + summary_attribution = summary.additional_properties.get("_attribution") + assert summary_attribution is not None, "Summary should have attribution" + assert isinstance(summary_attribution, dict), "Attribution should be a dict" + + origin_session_ids = summary_attribution.get("origin_session_ids") + assert origin_session_ids is not None, "Summary should have origin_session_ids" + assert isinstance(origin_session_ids, list), "origin_session_ids should be a list" + + # Should contain both prior session IDs from the summarized messages + assert set(origin_session_ids) == {"session-prior-1", "session-prior-2"}, ( + f"Expected both prior session IDs despite message_id=None, got {origin_session_ids}" + ) + + +async def test_tool_result_compaction_strategy_preserves_attribution_with_message_id_none() -> None: + """Test that ToolResultCompactionStrategy preserves attribution when message_id is None.""" + # Create tool-call messages with cross-session attribution but NO message_id + messages = [ + Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call1", name="tool", arguments='{"value":"x"}'), + Content.from_function_result(call_id="call1", result="result1"), + ], + message_id=None, # Explicitly None + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-prior"], + } + }, + ), + ] + + # Apply tool result compaction strategy directly + strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=0) + + # Annotate groups first (required for strategy) + annotate_message_groups(messages) + + changed = await strategy(messages) + assert changed, "Strategy should have changed messages" + + # Find the summary message + summary_messages = [m for m in messages if m.message_id and m.message_id.startswith("tool_summary_")] + assert len(summary_messages) == 1, "Should have created one summary" + + summary = summary_messages[0] + + # Verify the summary preserves the origin_session_ids + summary_attribution = summary.additional_properties.get("_attribution") + assert summary_attribution is not None, "Summary should have attribution" + assert isinstance(summary_attribution, dict), "Attribution should be a dict" + + origin_session_ids = summary_attribution.get("origin_session_ids") + assert origin_session_ids is not None, "Summary should have origin_session_ids" + assert isinstance(origin_session_ids, list), "origin_session_ids should be a list" + assert origin_session_ids == ["session-prior"], ( + f"Expected session-prior despite message_id=None, got {origin_session_ids}" + ) + + +async def test_summarization_strategy_aggregates_multiple_origins() -> None: + """Test that SummarizationStrategy correctly aggregates multiple different origin_session_ids.""" + from unittest.mock import AsyncMock, MagicMock + + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary" + mock_client.get_response.return_value = mock_response + + # Create messages with different origin_session_ids + # Need 4 messages so that 3 get summarized (target_count=1 keeps 1) + messages = [ + Message( + role="user", + contents=["msg1"], + message_id="msg1", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-A"], + } + }, + ), + Message( + role="assistant", + contents=["resp1"], + message_id="msg2", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-B"], + } + }, + ), + Message( + role="user", + contents=["msg2"], + message_id="msg3", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-C"], + } + }, + ), + Message( + role="assistant", + contents=["resp2"], + message_id="msg4", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-D"], + } + }, + ), + ] + + strategy = SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + + annotate_message_groups(messages) + changed = await strategy(messages) + assert changed + + summary_messages = [m for m in messages if m.message_id and m.message_id.startswith("summary_")] + assert len(summary_messages) == 1 + + summary = summary_messages[0] + origin_session_ids = summary.additional_properties.get("_attribution", {}).get("origin_session_ids") + + # Should contain the session IDs from the summarized messages (oldest 3) + assert set(origin_session_ids) == {"session-A", "session-B", "session-C"}, ( + f"Expected session IDs from summarized messages, got {origin_session_ids}" + ) + + +async def test_summarization_strategy_deduplicates_duplicate_origins() -> None: + """Test that SummarizationStrategy deduplicates duplicate origin_session_ids.""" + from unittest.mock import AsyncMock, MagicMock + + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary" + mock_client.get_response.return_value = mock_response + + # Create messages with overlapping origin_session_ids + # Need 4 messages so that 3 get summarized (target_count=1 keeps 1) + messages = [ + Message( + role="user", + contents=["msg1"], + message_id="msg1", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-A", "session-B"], + } + }, + ), + Message( + role="assistant", + contents=["resp1"], + message_id="msg2", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-B", "session-C"], + } + }, + ), + Message( + role="user", + contents=["msg2"], + message_id="msg3", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-A"], # Duplicate + } + }, + ), + Message( + role="assistant", + contents=["resp2"], + message_id="msg4", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-D"], + } + }, + ), + ] + + strategy = SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + + annotate_message_groups(messages) + changed = await strategy(messages) + assert changed + + summary_messages = [m for m in messages if m.message_id and m.message_id.startswith("summary_")] + assert len(summary_messages) == 1 + + summary = summary_messages[0] + origin_session_ids = summary.additional_properties.get("_attribution", {}).get("origin_session_ids") + + # Should contain each session ID only once, preserving first-seen order from summarized messages + assert origin_session_ids == ["session-A", "session-B", "session-C"], ( + f"Expected deduplicated session IDs in first-seen order, got {origin_session_ids}" + ) + + +async def test_summarization_strategy_mixed_message_id_presence() -> None: + """Test that SummarizationStrategy works when some messages have message_id and others don't.""" + from unittest.mock import AsyncMock, MagicMock + + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary" + mock_client.get_response.return_value = mock_response + + # Create messages with mixed message_id presence + # Need 4 messages so that 3 get summarized (target_count=1 keeps 1) + messages = [ + Message( + role="user", + contents=["msg1"], + message_id="msg1", # Has message_id + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-A"], + } + }, + ), + Message( + role="assistant", + contents=["resp1"], + message_id=None, # No message_id + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-B"], + } + }, + ), + Message( + role="user", + contents=["msg2"], + message_id="msg2", # Has message_id + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-C"], + } + }, + ), + Message( + role="assistant", + contents=["resp2"], + message_id="msg3", # Has message_id + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-D"], + } + }, + ), + ] + + strategy = SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + + annotate_message_groups(messages) + changed = await strategy(messages) + assert changed + + summary_messages = [m for m in messages if m.message_id and m.message_id.startswith("summary_")] + assert len(summary_messages) == 1 + + summary = summary_messages[0] + origin_session_ids = summary.additional_properties.get("_attribution", {}).get("origin_session_ids") + + # Should contain session IDs from summarized messages despite mixed message_id presence + assert set(origin_session_ids) == {"session-A", "session-B", "session-C"}, ( + f"Expected session IDs from summarized messages despite mixed message_id presence, got {origin_session_ids}" + ) + + +async def test_summarization_strategy_preserves_ordering_of_origins() -> None: + """Test that SummarizationStrategy preserves first-seen ordering of origin_session_ids.""" + from unittest.mock import AsyncMock, MagicMock + + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary" + mock_client.get_response.return_value = mock_response + + # Create messages with origin_session_ids in a specific order + # Need 4 messages so that 3 get summarized (target_count=1 keeps 1) + messages = [ + Message( + role="user", + contents=["msg1"], + message_id="msg1", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-Z", "session-A"], + } + }, + ), + Message( + role="assistant", + contents=["resp1"], + message_id="msg2", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-B"], + } + }, + ), + Message( + role="user", + contents=["msg2"], + message_id="msg3", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-A"], # Already seen + } + }, + ), + Message( + role="assistant", + contents=["resp2"], + message_id="msg4", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-D"], + } + }, + ), + ] + + strategy = SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + + annotate_message_groups(messages) + changed = await strategy(messages) + assert changed + + summary_messages = [m for m in messages if m.message_id and m.message_id.startswith("summary_")] + assert len(summary_messages) == 1 + + summary = summary_messages[0] + origin_session_ids = summary.additional_properties.get("_attribution", {}).get("origin_session_ids") + + # Should preserve first-seen order from summarized messages: Z, A, B + assert origin_session_ids == ["session-Z", "session-A", "session-B"], ( + f"Expected first-seen order preserved, got {origin_session_ids}" + ) + + +async def test_compaction_provider_synthetic_summary_retention_after_fix() -> None: + """Test that the #7943 fix still works: synthetic summaries are retained in context. + + This ensures that moving provenance handling into strategies didn't break + the original #7943 fix for synthetic summary retention. + """ + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._sessions import SessionContext + + mock_client = AsyncMock() + mock_response = MagicMock() + mock_response.text = "Summary" + mock_client.get_response.return_value = mock_response + + # Create messages with attribution + messages = [ + Message( + role="user", + contents=["msg1"], + message_id="msg1", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-prior"], + } + }, + ), + Message( + role="assistant", + contents=["resp1"], + message_id="msg2", + additional_properties={ + "_attribution": { + "source_id": "history", + "origin_session_ids": ["session-prior"], + } + }, + ), + ] + + ctx = SessionContext(input_messages=[]) + ctx.extend_messages("history", messages) + + provider = CompactionProvider( + before_strategy=SummarizationStrategy( + client=mock_client, + target_count=1, + threshold=0, + ) + ) + + await provider.before_run(agent=None, session=None, context=ctx, state={}) + + # Verify the synthetic summary is in the context + final_messages = ctx.get_messages() + summary_messages = [ + m + for m in final_messages + if m.role == "assistant" + and any(hasattr(c, "text") and c.text is not None and "Summary" in c.text for c in m.contents) + ] + assert len(summary_messages) == 1, "Synthetic summary should be retained in context" + + # Verify it has the correct attribution + summary = summary_messages[0] + origin_session_ids = summary.additional_properties.get("_attribution", {}).get("origin_session_ids") + assert origin_session_ids == ["session-prior"], f"Expected origin_session_ids preserved, got {origin_session_ids}" diff --git a/python/packages/monty/agent_framework_monty/_monty_bridge.py b/python/packages/monty/agent_framework_monty/_monty_bridge.py index cc9fa13729c..a7f9291dd9b 100644 --- a/python/packages/monty/agent_framework_monty/_monty_bridge.py +++ b/python/packages/monty/agent_framework_monty/_monty_bridge.py @@ -241,26 +241,25 @@ async def run(self, code: str) -> dict[str, Any]: if self._mounts: feed_kwargs["mount"] = list(self._mounts) - with Monty() as pool: - with pool.checkout(**checkout_kwargs) as session: - progress = session.feed_start(_build_code(code), **feed_kwargs) - - while True: - if isinstance(progress, MontyComplete): - return { - "output": _ensure_json_value(progress.output), - "stdout": printer.output, - "truncated": printer.truncated, - } - if isinstance(progress, FunctionSnapshot): - progress = self._handle_function(progress) - continue - if isinstance(progress, FutureSnapshot): - progress = await self._handle_future(progress) - continue - if isinstance(progress, NameLookupSnapshot): - raise RuntimeError(f"Name lookup not supported: {progress.variable_name!r}") - raise RuntimeError(f"Unsupported Monty progress type: {type(progress).__name__}") + with Monty() as pool, pool.checkout(**checkout_kwargs) as session: + progress = session.feed_start(_build_code(code), **feed_kwargs) + + while True: + if isinstance(progress, MontyComplete): + return { + "output": _ensure_json_value(progress.output), + "stdout": printer.output, + "truncated": printer.truncated, + } + if isinstance(progress, FunctionSnapshot): + progress = self._handle_function(progress) + continue + if isinstance(progress, FutureSnapshot): + progress = await self._handle_future(progress) + continue + if isinstance(progress, NameLookupSnapshot): + raise RuntimeError(f"Name lookup not supported: {progress.variable_name!r}") + raise RuntimeError(f"Unsupported Monty progress type: {type(progress).__name__}") def _handle_function(self, snapshot: Any) -> Any: if snapshot.is_os_function: From 272fc72571aa2cc032fcceb5df267d68cd59c326 Mon Sep 17 00:00:00 2001 From: HeZzz <142383180+HeZ2z@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:51:23 +0000 Subject: [PATCH 062/108] Python: preserve response update metadata in WorkflowAgent forwarding (#7999) * Python: preserve response update metadata in WorkflowAgent forwarding Forward finish_reason, continuation_token, agent_id and a shallow copy of additional_properties when WorkflowAgent reconstructs AgentResponseUpdate objects from workflow events, matching the documented as-is forwarding contract. * Python: keep empty additional_properties dict when forwarding Use an explicit None check instead of a truthiness check so an explicitly empty additional_properties dict is not converted to None, preserving the forwarding contract. * Python: use ContinuationToken-shaped value in WorkflowAgent metadata test Fix type checker failures by constructing continuation_token as an opaque dict per the ContinuationToken TypedDict instead of a bare string, and resolve lint/typing nits in the new tests. * Python: Fix workflow response metadata forwarding * Python: Update WorkflowContext type in custom reason and empty props executors --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../core/agent_framework/_workflows/_agent.py | 10 ++ .../tests/workflow/test_workflow_agent.py | 105 +++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 9a8d496da69..b05ec391abb 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -24,6 +24,8 @@ AgentResponseUpdate, AgentRunInputs, Content, + FinishReason, + FinishReasonLiteral, Message, ResponseStream, UsageDetails, @@ -654,9 +656,17 @@ def _convert_workflow_event_to_agent_response_updates( contents=list(data.contents), role=data.role, author_name=data.author_name or executor_id, + agent_id=data.agent_id, response_id=data.response_id, message_id=data.message_id, created_at=data.created_at, + # The attribute is typed wider than the constructor accepts (custom + # connectors may set any string); forward the value unchanged. + finish_reason=cast(FinishReasonLiteral | FinishReason | None, data.finish_reason), + continuation_token=data.continuation_token, + additional_properties=dict(data.additional_properties) + if data.additional_properties is not None + else None, raw_representation=data.raw_representation, ) ] diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 69fbcbd9b4b..1394ef70ef6 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -3,7 +3,7 @@ import uuid from collections.abc import Awaitable, Sequence from dataclasses import dataclass -from typing import Any, Literal, overload +from typing import Any, Literal, cast, overload import pytest from typing_extensions import Never, assert_type @@ -15,6 +15,7 @@ AgentSession, Content, Executor, + FinishReason, HistoryProvider, InMemoryHistoryProvider, Message, @@ -859,6 +860,108 @@ async def yielding_executor(messages: list[Message], ctx: WorkflowContext[Never, assert "first output" in texts assert "second output" in texts + async def test_workflow_as_agent_stream_preserves_response_update_metadata(self) -> None: + """Test that streaming forwards finish_reason, continuation_token and additional_properties. + + This validates the fix for issue #7952: AgentResponseUpdate metadata should be + forwarded as-is when the workflow is wrapped via .as_agent(). + """ + + @executor + async def metadata_executor(messages: list[Message], ctx: WorkflowContext[Never, AgentResponseUpdate]) -> None: # type: ignore[valid-type] + await ctx.yield_output( + AgentResponseUpdate( + contents=[Content.from_text(text="payload")], + role="assistant", + agent_id="source-agent", + response_id="source-response", + message_id="source-message", + finish_reason="stop", + continuation_token=cast(Any, {"token": "resume-token"}), + additional_properties={"provider_marker": "preserve-me"}, + ) + ) + + workflow = WorkflowBuilder(start_executor=metadata_executor).build() + agent = workflow.as_agent("metadata-test-agent") + + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("hello", stream=True): + updates.append(update) + + metadata_updates = [u for u in updates if u.response_id == "source-response"] + assert len(metadata_updates) == 1 + update = metadata_updates[0] + assert update.text == "payload" + assert update.agent_id == "source-agent" + assert update.finish_reason == "stop" + assert update.continuation_token == {"token": "resume-token"} + assert update.additional_properties == {"provider_marker": "preserve-me"} + + async def test_workflow_as_agent_stream_preserves_custom_finish_reason(self) -> None: + """Test that a non-literal finish_reason is forwarded unchanged. + + Custom chat connectors can report finish reasons outside the standard + literals (modeled as `FinishReason`); the WorkflowAgent must preserve + them when re-emitting the update. + """ + + @executor + async def custom_reason_executor( + messages: list[Message], ctx: WorkflowContext[Any, AgentResponseUpdate] + ) -> None: + await ctx.yield_output( + AgentResponseUpdate( + contents=[Content.from_text(text="payload")], + role="assistant", + agent_id="source-agent", + response_id="custom-reason-response", + message_id="source-message", + finish_reason=FinishReason("custom_reason"), + ) + ) + + workflow = WorkflowBuilder(start_executor=custom_reason_executor).build() + agent = workflow.as_agent("custom-reason-test-agent") + + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("hello", stream=True): + updates.append(update) + + metadata_updates = [u for u in updates if u.response_id == "custom-reason-response"] + assert len(metadata_updates) == 1 + update = metadata_updates[0] + assert update.text == "payload" + assert update.agent_id == "source-agent" + assert update.finish_reason == "custom_reason" + + async def test_workflow_as_agent_stream_preserves_empty_additional_properties(self) -> None: + """Test that an explicitly empty additional_properties dict is not converted to None.""" + + @executor + async def empty_props_executor( + messages: list[Message], ctx: WorkflowContext[Any, AgentResponseUpdate] + ) -> None: + await ctx.yield_output( + AgentResponseUpdate( + contents=[Content.from_text(text="payload")], + role="assistant", + response_id="empty-props-response", + additional_properties={}, + ) + ) + + workflow = WorkflowBuilder(start_executor=empty_props_executor).build() + agent = workflow.as_agent("empty-props-test-agent") + + updates: list[AgentResponseUpdate] = [] + async for update in agent.run("hello", stream=True): + updates.append(update) + + forwarded = [u for u in updates if u.response_id == "empty-props-response"] + assert len(forwarded) == 1 + assert forwarded[0].additional_properties == {} + async def test_workflow_as_agent_yield_output_with_content_types(self) -> None: """Test that yield_output preserves different content types (Content, Content, etc.).""" From c385c2de6e84e5132da3a2978b3c931701017150 Mon Sep 17 00:00:00 2001 From: fzfzzfzzzfzzzz <115906210+fzfzzfzzzfzzzz@users.noreply.github.com> Date: Tue, 15 Sep 2026 02:51:51 +0000 Subject: [PATCH 063/108] Python: make declarative YAML path loading portable (#8260) Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework_declarative/_loader.py | 10 ++-- .../_workflows/_factory.py | 2 +- .../tests/test_declarative_loader.py | 60 +++++++++++++++++++ .../tests/test_workflow_factory.py | 21 +++++++ 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 5be02a9ad8a..5e7e0b8cf27 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import sys from collections.abc import Callable, Mapping from pathlib import Path @@ -338,7 +339,7 @@ def create_agent_from_yaml_path(self, yaml_path: str | Path) -> Agent: yaml_path = Path(yaml_path) if not yaml_path.exists(): raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}") - with open(yaml_path) as f: + with open(yaml_path, encoding="utf-8") as f: yaml_str = f.read() return self.create_agent_from_yaml(yaml_str) @@ -508,9 +509,10 @@ async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agen """ if not isinstance(yaml_path, Path): yaml_path = Path(yaml_path) - if not yaml_path.exists(): - raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}") - yaml_str = yaml_path.read_text() + try: + yaml_str = await asyncio.to_thread(yaml_path.read_text, encoding="utf-8") + except FileNotFoundError as exc: + raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}") from exc return await self.create_agent_from_yaml_async(yaml_str) async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent: diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py index 363b4d77f37..419065a1303 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -241,7 +241,7 @@ def create_workflow_from_yaml_path( if not yaml_path.exists(): raise FileNotFoundError(f"Workflow YAML file not found: {yaml_path}") - with open(yaml_path) as f: + with open(yaml_path, encoding="utf-8") as f: yaml_content = f.read() return self.create_workflow_from_yaml(yaml_content, base_path=yaml_path.parent) diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index cac6d4f53e5..bde08e69390 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -2,6 +2,7 @@ import builtins import sys +import threading from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -1051,6 +1052,30 @@ def test_create_agent_from_yaml_path_with_path_object(self, tmp_path): assert agent.name == "PathAgent" + def test_create_agent_from_yaml_path_reads_utf8(self, tmp_path): + """Test create_agent_from_yaml_path reads YAML as UTF-8.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_file = tmp_path / "unicode_agent.yaml" + yaml_file.write_text( + """ +kind: Prompt +name: UnicodeAgent +instructions: 政务助手 🏛️ +""", + encoding="utf-8", + ) + + mock_client = MagicMock() + factory = AgentFactory(client=mock_client) + with patch("builtins.open", wraps=builtins.open) as mock_open: + agent = factory.create_agent_from_yaml_path(yaml_file) + + mock_open.assert_called_once_with(yaml_file, encoding="utf-8") + assert agent.name == "UnicodeAgent" + class TestAgentFactoryAsyncMethods: """Tests for AgentFactory async methods.""" @@ -1138,6 +1163,41 @@ async def test_create_agent_from_yaml_path_async_with_string_path(self, tmp_path assert agent.name == "AsyncPathAgent" + async def test_create_agent_from_yaml_path_async_reads_utf8_off_event_loop(self, tmp_path): + """Test async path loading reads UTF-8 without blocking the event-loop thread.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_file = tmp_path / "async_unicode_agent.yaml" + yaml_file.write_text( + """ +kind: Prompt +name: AsyncUnicodeAgent +instructions: 政务助手 🏛️ +""", + encoding="utf-8", + ) + + event_loop_thread_id = threading.get_ident() + read_thread_id: int | None = None + original_read_text = Path.read_text + + def tracked_read_text(path: Path, *args, **kwargs): + nonlocal read_thread_id + read_thread_id = threading.get_ident() + assert kwargs["encoding"] == "utf-8" + return original_read_text(path, *args, **kwargs) + + mock_client = MagicMock() + factory = AgentFactory(client=mock_client) + with patch.object(Path, "read_text", autospec=True, side_effect=tracked_read_text): + agent = await factory.create_agent_from_yaml_path_async(yaml_file) + + assert read_thread_id is not None + assert read_thread_id != event_loop_thread_id + assert agent.name == "AsyncUnicodeAgent" + class TestAgentFactoryProviderLookup: """Tests for provider configuration lookup.""" diff --git a/python/packages/declarative/tests/test_workflow_factory.py b/python/packages/declarative/tests/test_workflow_factory.py index b170753794f..eee4b6b6734 100644 --- a/python/packages/declarative/tests/test_workflow_factory.py +++ b/python/packages/declarative/tests/test_workflow_factory.py @@ -545,6 +545,27 @@ def test_load_from_file(self, tmp_path): assert workflow is not None assert workflow.name == "file-workflow" + def test_load_from_file_reads_utf8(self, tmp_path): + """Test loading a workflow file explicitly uses UTF-8.""" + workflow_file = tmp_path / "UnicodeWorkflow.yaml" + workflow_file.write_text( + """ +name: unicode-workflow +actions: + - kind: SetValue + path: Local.message + value: 政务助手 🏛️ +""", + encoding="utf-8", + ) + + factory = WorkflowFactory() + with patch("builtins.open", wraps=open) as mock_open: + workflow = factory.create_workflow_from_yaml_path(workflow_file) + + mock_open.assert_called_once_with(workflow_file, encoding="utf-8") + assert workflow.name == "unicode-workflow" + @_requires_powerfx class TestDisplayNameMetadata: From e6db0e72959174d2569f67847b79f607d9a0a65d Mon Sep 17 00:00:00 2001 From: fzfzzfzzzfzzzz <115906210+fzfzzfzzzfzzzz@users.noreply.github.com> Date: Tue, 15 Sep 2026 03:06:53 +0000 Subject: [PATCH 064/108] Python: reject malformed checkpoint Base64 payloads (#8262) Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../_workflows/_checkpoint_encoding.py | 2 +- .../core/tests/workflow/test_checkpoint_decode.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index 3a7d361b612..f6fd3766924 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -403,7 +403,7 @@ def _base64_to_unpickle(encoded: str, *, allowed_types: frozenset[str] | None = format is incompatible, or a disallowed type is encountered. """ try: - pickled = base64.b64decode(encoded.encode("ascii")) + pickled = base64.b64decode(encoded.encode("ascii"), validate=True) if allowed_types is not None: return _RestrictedUnpickler(pickled, allowed_types).load() return pickle.loads(pickled) # nosec # ruff:ignore[suspicious-pickle-usage] diff --git a/python/packages/core/tests/workflow/test_checkpoint_decode.py b/python/packages/core/tests/workflow/test_checkpoint_decode.py index a8115d17c2b..90384104823 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_decode.py +++ b/python/packages/core/tests/workflow/test_checkpoint_decode.py @@ -8,6 +8,7 @@ from agent_framework import WorkflowCheckpointException from agent_framework._workflows._checkpoint_encoding import ( + _PICKLE_MARKER, # type: ignore _TYPE_MARKER, # type: ignore decode_checkpoint_value, encode_checkpoint_value, @@ -188,6 +189,16 @@ def test_decode_raises_on_type_mismatch() -> None: decode_checkpoint_value(encoded) +def test_decode_raises_on_malformed_base64() -> None: + """Test that decoding rejects non-Base64 characters in a pickle payload.""" + encoded = encode_checkpoint_value((1, 2, 3)) + assert isinstance(encoded, dict) + encoded[_PICKLE_MARKER] = cast(str, encoded[_PICKLE_MARKER]) + "!!!!" + + with pytest.raises(WorkflowCheckpointException, match="Failed to decode pickled checkpoint data"): + decode_checkpoint_value(encoded) + + class NotADataclass: # noqa: B903 """A regular class that is not a dataclass.""" From 842909cbfdebbd831f9459ca6810d0e64d0c3d3a Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 15 Sep 2026 03:08:26 +0000 Subject: [PATCH 065/108] Python: handle Qdrant readiness race after collection creation conflict (#8381) * Python: handle Qdrant readiness race after collection creation conflict * Python: retry Qdrant gRPC readiness errors after creation conflicts --- python/packages/qdrant/README.md | 17 +++++ .../agent_framework_qdrant/_vector_store.py | 23 ++++++- .../qdrant/tests/qdrant/test_collection.py | 65 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/python/packages/qdrant/README.md b/python/packages/qdrant/README.md index 679439a80ff..faea8292e79 100644 --- a/python/packages/qdrant/README.md +++ b/python/packages/qdrant/README.md @@ -97,6 +97,23 @@ scroll pages without retaining the skipped prefix. Numeric range and mixed numeric membership operands are limited to `+/- (2**53-1)`; integer equality supports the full signed 64-bit range. +## Running integration tests locally + +Start a disposable server using the Qdrant version pinned in CI, then run the +integration suite from the `python/` directory: + +```bash +docker run -d --rm --name af-qdrant-test -p 127.0.0.1:16333:6333 qdrant/qdrant:v1.16.2 +curl --fail --retry 20 --retry-delay 1 --retry-connrefused http://127.0.0.1:16333/readyz +QDRANT_TEST_URL=http://127.0.0.1:16333 uv run --frozen --directory packages/qdrant poe test-integration -p no:pytest-retry +docker stop af-qdrant-test +``` + +Disabling the retry plugin makes failures visible on the first attempt. To run +only the concurrent-creation cases, append +`-k concurrent_collection_creation_validates_winning_schema` to the test command. +The fixtures create uniquely named collections and delete them after each test. + ## Documentation - [Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/) diff --git a/python/packages/qdrant/agent_framework_qdrant/_vector_store.py b/python/packages/qdrant/agent_framework_qdrant/_vector_store.py index 4ed2f7b800f..35bb5e62743 100644 --- a/python/packages/qdrant/agent_framework_qdrant/_vector_store.py +++ b/python/packages/qdrant/agent_framework_qdrant/_vector_store.py @@ -4,6 +4,7 @@ from __future__ import annotations +import asyncio import math from collections.abc import Mapping, Sequence from typing import Any, ClassVar, Generic, cast @@ -429,6 +430,7 @@ async def collection_exists(self, *, operation_options: Mapping[str, Any] | None async def ensure_collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> None: """Create the collection and payload indexes, or validate an existing dense schema.""" options = _validate_operation_options(operation_options, _CREATE_OPTIONS) + creation_conflict = False if not await self.collection_exists(): vectors = { field.storage_name or field.name: models.VectorParams( @@ -445,16 +447,35 @@ async def ensure_collection_exists(self, *, operation_options: Mapping[str, Any] except UnexpectedResponse as exc: if exc.status_code != 409: raise + creation_conflict = True except AioRpcError as exc: if exc.code() != StatusCode.ALREADY_EXISTS: raise + creation_conflict = True except ValueError as exc: if not self._is_local or str(exc) != f"Collection {self.collection_name} already exists": raise else: if not created and not await self.collection_exists(): raise IntegrationException(f"Qdrant did not create collection '{self.collection_name}'.") - info = await self.async_client.get_collection(self.collection_name) + attempt = 0 + while True: + try: + info = await self.async_client.get_collection(self.collection_name) + break + except (UnexpectedResponse, AioRpcError) as exc: + # Qdrant can report a creation conflict before the winning creator has + # initialized its shards. Only retry that specific readiness failure. + if isinstance(exc, UnexpectedResponse): + not_ready = exc.status_code == 500 and b"0 of 0 read operations failed" in exc.content + else: + not_ready = exc.code() == StatusCode.INTERNAL and "0 of 0 read operations failed" in ( + exc.details() or "" + ) + if not creation_conflict or not not_ready or attempt >= 5: + raise + await asyncio.sleep(0.05 * 2**attempt) + attempt += 1 configured = info.config.params.vectors if not isinstance(configured, dict): raise ValueError("QdrantCollection requires named vectors, not an unnamed-vector collection.") diff --git a/python/packages/qdrant/tests/qdrant/test_collection.py b/python/packages/qdrant/tests/qdrant/test_collection.py index 78b4553eb61..33b6d159aa0 100644 --- a/python/packages/qdrant/tests/qdrant/test_collection.py +++ b/python/packages/qdrant/tests/qdrant/test_collection.py @@ -620,6 +620,71 @@ async def test_conflict_does_not_hide_missing_collection(definition): assert result.value is missing +@pytest.mark.parametrize("matching_schema", [True, False]) +@pytest.mark.parametrize("transport", ["rest", "grpc"]) +async def test_create_conflict_waits_for_readable_schema(collection_factory, client, matching_schema, transport): + collection = collection_factory() + await collection.ensure_collection_exists() + info = await client.get_collection(collection.collection_name) + if not matching_schema: + info.config.params.vectors["dense_text"].size = 4 + if transport == "grpc": + conflict = AioRpcError(StatusCode.ALREADY_EXISTS, Metadata(), Metadata(), "collection already exists") + not_ready = AioRpcError( + StatusCode.INTERNAL, Metadata(), Metadata(), "Service internal error: 0 of 0 read operations failed" + ) + else: + conflict = UnexpectedResponse(409, "Conflict", b"exists", Headers()) + not_ready = UnexpectedResponse( + 500, + "Internal Server Error", + b'{"status":{"error":"Service internal error: 0 of 0 read operations failed"}}', + Headers(), + ) + with ( + patch.object(client, "collection_exists", return_value=False), + patch.object(client, "create_collection", side_effect=conflict), + patch.object(client, "get_collection", side_effect=[not_ready, not_ready, info]) as get_collection, + ): + if matching_schema: + await collection.ensure_collection_exists() + else: + with pytest.raises(ValueError, match="does not match"): + await collection.ensure_collection_exists() + assert get_collection.await_count == 3 + + +@pytest.mark.parametrize( + ("conflict", "error", "expected_reads"), + [ + (True, UnexpectedResponse(500, "Internal Server Error", b"0 of 0 read operations failed", Headers()), 6), + (False, UnexpectedResponse(500, "Internal Server Error", b"0 of 0 read operations failed", Headers()), 1), + (True, UnexpectedResponse(500, "Internal Server Error", b"unrelated server failure", Headers()), 1), + (True, UnexpectedResponse(403, "Forbidden", b"not authorized", Headers()), 1), + (True, AioRpcError(StatusCode.INTERNAL, Metadata(), Metadata(), "0 of 0 read operations failed"), 6), + (False, AioRpcError(StatusCode.INTERNAL, Metadata(), Metadata(), "0 of 0 read operations failed"), 1), + (True, AioRpcError(StatusCode.INTERNAL, Metadata(), Metadata(), "unrelated server failure"), 1), + (True, AioRpcError(StatusCode.INTERNAL, Metadata(), Metadata(), None), 1), + (True, AioRpcError(StatusCode.PERMISSION_DENIED, Metadata(), Metadata(), "not authorized"), 1), + ], +) +async def test_collection_read_retries_are_bounded_and_specific(definition, conflict, error, expected_reads): + client = AsyncMock(spec=AsyncQdrantClient) + client.collection_exists.return_value = not conflict + client.create_collection.side_effect = ( + AioRpcError(StatusCode.ALREADY_EXISTS, Metadata(), Metadata(), "collection already exists") + if isinstance(error, AioRpcError) + else UnexpectedResponse(409, "Conflict", b"exists", Headers()) + ) + client.get_collection.side_effect = error + collection = QdrantCollection(dict, definition=definition, collection_name="test", async_client=client) + with pytest.raises(type(error)) as result: + await collection.ensure_collection_exists() + assert result.value is error + assert client.get_collection.await_count == expected_reads + assert client.create_collection.await_count == int(conflict) + + @pytest.mark.parametrize("configuration", ["uint8", "flat"]) async def test_existing_vector_datatype_and_index_mismatch(collection_factory, client, configuration): definition = VectorStoreCollectionDefinition([ From 33c16248c9b95c7920f02204df7807ec736904c3 Mon Sep 17 00:00:00 2001 From: Serply <59339358+googio@users.noreply.github.com> Date: Tue, 15 Sep 2026 04:03:46 +0000 Subject: [PATCH 066/108] Python: document keyed web search with Serply MCP (#8319) * Python: document keyed web search with Serply MCP Add a README section to the MCP samples showing MCPStreamableHTTPTool against the Serply MCP server with header_provider supplying the X-Api-Key header, calling google_search and google_news_search directly. Adds the SERPLY_API_KEY line under Prerequisites. * Python: use static_headers for the Serply MCP key The key is fixed for the process, so static_headers is the right API. A header_provider holds _call_headers_lock for the whole call_tool, which would serialize concurrent Serply searches; static_headers keeps the same origin scoping and cross-origin redirect stripping without that lock. --- python/samples/02-agents/mcp/README.md | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/python/samples/02-agents/mcp/README.md b/python/samples/02-agents/mcp/README.md index 83013a0e896..027937744eb 100644 --- a/python/samples/02-agents/mcp/README.md +++ b/python/samples/02-agents/mcp/README.md @@ -71,6 +71,59 @@ The output lists `web_search` and `web_fetch`, followed by their results, includ To make these tools available to an existing agent, pass the MCP tool as `tools` when constructing `Agent`. The agent can then choose to invoke them during a run; remove that tool to disable access. This example does not change any configured providers or defaults. +## Keyed web search with Serply MCP + +Use `MCPStreamableHTTPTool` with the [Serply MCP server](https://serply.io/docs) to search Google, Bing, Google News, Google Scholar, Google Maps, and more, or to scrape a page. [Serply](https://serply.io) requires an API key, which the example passes through `static_headers`, so the key is attached only to requests for `api.serply.io` and is stripped on a cross-origin redirect. The key is fixed for the process, so it belongs in `static_headers` rather than `header_provider`; a `header_provider` holds a lock for the whole tool call, which would stop the agent from running Serply searches concurrently. Use `header_provider`, as [`mcp_api_key_auth.py`](mcp_api_key_auth.py) does, when the header value depends on the run. The tools are called directly, so no model provider account is needed. + +Install the client dependencies in a Python 3.10+ environment: + +```bash +pip install agent-framework-core "mcp>=1.24,<2" +``` + +Set `SERPLY_API_KEY`, save this as `serply_search.py`, and run `python serply_search.py`: + +```python +import asyncio +import os + +from agent_framework import MCPStreamableHTTPTool + + +async def main() -> None: + api_key = os.environ["SERPLY_API_KEY"] + async with MCPStreamableHTTPTool( + name="serply", + url="https://api.serply.io/mcp", + # Sent only with requests to api.serply.io, and dropped on a cross-origin redirect. + static_headers={"X-Api-Key": api_key}, + load_prompts=False, + request_timeout=30, + # Use the text payload once; Serply also returns it as structured content. + parse_tool_results=lambda result: "\n".join(c.text for c in result.content if c.type == "text"), + ) as mcp: + print("Tools:", [tool.name for tool in mcp.functions]) + search_result = await mcp.call_tool( + "google_search", + query="Microsoft Agent Framework MCP tools", + num=3, + ) + news_result = await mcp.call_tool( + "google_news_search", + query="Microsoft Agent Framework", + ) + for result in (search_result, news_result): + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +The output lists the Serply tools (`google_search`, `google_maps_search`, `bing_search`, `google_video_search`, `google_news_search`, `google_jobs_search`, `google_scholar_search`, `amazon_product_search`, `scrape_url`), then three web results with title, URL, and snippet, followed by news articles with source, date, and link. Queries are sent to Serply when the calls run, and the API key travels only in the request header. The context manager closes the connection afterward. + +To make these tools available to an existing agent, pass the MCP tool as `tools` when constructing `Agent`. The agent can then choose to invoke them during a run; remove that tool to disable access. This example does not change any configured providers or defaults. + ## Prerequisites Most samples in this folder use OpenAI: @@ -85,6 +138,9 @@ Run `mcp_api_key_auth.py` with the MCP API key as the first command-line argumen For `mcp_github_pat.py`: - `GITHUB_PAT` - Your GitHub Personal Access Token (create at https://github.com/settings/tokens) +For the Serply example: +- `SERPLY_API_KEY` - Your Serply API key (create at https://serply.io) + For `mcp_long_running_task.py` (uses Azure OpenAI via Entra-ID): - Run `az login` once - `AZURE_OPENAI_ENDPOINT` - your Azure OpenAI resource endpoint, e.g. `https://.openai.azure.com/` From d2ae2e06a72111c607db6a44b100c971ccfa0c18 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 15 Sep 2026 04:08:55 +0000 Subject: [PATCH 067/108] Python: Bind MCP HTTP sessions to request identity (#8303) * Python: Bind MCP HTTP sessions to request identity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 * Python: Reconcile MCP identity before tool discovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 * Python: Recheck MCP identity after lazy connect Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 * Python: Preserve caller-owned MCP identity across close Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 * Python: Reconcile MCP prompt identity at invocation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 * Python: Defer unresolved MCP run headers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 * Python: Reject unknown borrowed MCP identity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 * Python: Serialize MCP identity rediscovery Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Copilot-Session: efc43d46-4f87-4702-9f55-9f5f596c33b7 --- python/packages/core/AGENTS.md | 2 +- .../packages/core/agent_framework/_agents.py | 8 +- python/packages/core/agent_framework/_mcp.py | 330 +++++++++++++-- .../packages/core/tests/core/test_agents.py | 94 ++++- python/packages/core/tests/core/test_mcp.py | 18 +- .../core/tests/core/test_mcp_http_auth.py | 399 +++++++++++++++++- .../core/tests/core/test_mcp_observability.py | 4 +- 7 files changed, 791 insertions(+), 64 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 08d6790bd9c..1dc3eca9dfe 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -164,7 +164,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing. - **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, OTel, and task behavior. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries. - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through — but note this constrains the *model*, not the *server*, which still widens the effective allowlist through its schema. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used. -- **MCP HTTP header request scoping** - `static_headers` supplies fixed, origin-scoped headers without serializing concurrent calls; `header_provider` resolves dynamic per-call headers under the instance call lock. When both are configured, dynamic values override fixed values with the same name. With a shared `http_client`, processing stays scoped to the originating `MCPStreamableHTTPTool`, and every injected header is stripped from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. +- **MCP HTTP header request scoping** - `static_headers` supplies fixed, origin-scoped headers without serializing concurrent calls; `header_provider` resolves dynamic per-call headers under the instance call lock. Fixed and dynamic headers form the session's complete effective identity (case-insensitive names, case-sensitive values), with dynamic values overriding fixed values of the same name. Agent runs reconcile a connected tool before copying its functions; generated tool and prompt calls perform the same check at invocation. Connected run preparation defers when a provider needs model-supplied values that are not available until invocation, where resolution remains strict. A different identity waits for in-flight discovery, reconnects framework-created sessions without loading, then refreshes session-derived discovery under the same lock. Because a caller-supplied session's established identity is unknown and the wrapper cannot reconnect it, dynamic header resolution on that wrapper is rejected even while disconnected. Ambient requests retain the bound set. With a shared `http_client`, processing stays scoped to the originating `MCPStreamableHTTPTool`, and every injected header is stripped from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. - **MCP lifecycle caller cancellation** - The lifecycle owner skips cancelled queued connect requests and waits for the caller to acknowledge successful setup. If the caller cancels before accepting a newly established connection, the owner tears it down before processing the next request. Cancelling a close waiter does not interrupt teardown, and cancelling a redundant connect does not discard a previously established session. - **`function_invocation_kwargs` and MCP servers** - That dict is shared across every tool in the run, including every attached `MCPTool`, and any name in it reaches a server that declares a matching `inputSchema` property. `header_provider` does not mitigate this — it reads the kwargs without consuming them. To keep a credential out of tool arguments, source it outside `function_invocation_kwargs`: read a `ContextVar` inside the provider (this still allows a different value per request), configure a custom `http_client`, or use `env` for `MCPStdioTool`. - **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`. diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 3fa71ed9689..c4f5da1c0de 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -1465,11 +1465,12 @@ async def _prepare_run_context( final_tools = list(base_tools) for tool in normalized_tools: if isinstance(tool, MCPTool): + await tool._prepare_for_run(additional_function_arguments) # pyright: ignore[reportPrivateUsage] if not tool.is_connected: # The handshake and discovery requests are issued before any tool call, so the run's # kwargs must reach header_provider here or those requests go out unauthenticated. - tool._seed_connection_kwargs(additional_function_arguments) # pyright: ignore[reportPrivateUsage] await self._async_exit_stack.enter_async_context(tool) + await tool._prepare_for_run(additional_function_arguments) # pyright: ignore[reportPrivateUsage] _append_unique_tools( final_tools, tool.functions, @@ -1479,9 +1480,12 @@ async def _prepare_run_context( _append_unique_tools(final_tools, [tool]) for mcp_server in self.mcp_tools: + await mcp_server._prepare_for_run(additional_function_arguments) # pyright: ignore[reportPrivateUsage] if not mcp_server.is_connected: - mcp_server._seed_connection_kwargs(additional_function_arguments) # pyright: ignore[reportPrivateUsage] await self._async_exit_stack.enter_async_context(mcp_server) + await mcp_server._prepare_for_run( # pyright: ignore[reportPrivateUsage] + additional_function_arguments + ) _append_unique_tools( final_tools, mcp_server.functions, diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index d683e99493e..6eeec717b4d 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -17,7 +17,6 @@ from copy import copy from dataclasses import dataclass from datetime import date, datetime, timedelta -from functools import partial from inspect import isawaitable from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast @@ -136,6 +135,7 @@ class MCPSpecificApproval(TypedDict, total=False): _mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextVar("_mcp_call_headers") _MCP_HEADER_OWNER_EXTENSION = "agent_framework.mcp_header_owner" _MCP_INJECTED_HEADER_KEYS_EXTENSION = "agent_framework.mcp_injected_header_keys" +_MCPHeaderIdentity: TypeAlias = tuple[tuple[str, str], ...] MCP_DEFAULT_TIMEOUT = 30 MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5 _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES = 1024 * 1024 @@ -598,6 +598,25 @@ async def _call_tool_with_runtime_kwargs( return _call_tool_with_runtime_kwargs +def _make_mcp_prompt_caller( + mcp_tool: MCPTool, + remote_prompt_name: str, +) -> Callable[..., Coroutine[Any, Any, str]]: + """Build a generated prompt caller that keeps run context out of prompt arguments.""" + + async def _call_prompt_with_runtime_kwargs( + ctx: FunctionInvocationContext, + **kwargs: Any, + ) -> str: + return await mcp_tool._call_prompt_with_runtime_kwargs( # pyright: ignore[reportPrivateUsage] + remote_prompt_name, + kwargs, + ctx.kwargs, + ) + + return _call_prompt_with_runtime_kwargs + + def _validate_mcp_meta_key(key: str) -> None: """Validate an MCP ``_meta`` key against the 2025-06-18 key-name format.""" if not _MCP_META_KEY_PATTERN.fullmatch(key): @@ -651,6 +670,14 @@ def _url_origin(url: Any) -> tuple[str, str, int]: return (url.scheme, url.host, port) +def _mcp_header_identity(headers: Mapping[str, str]) -> _MCPHeaderIdentity: + """Return the effective header set with case-insensitive names.""" + normalized: dict[str, str] = {} + for name, value in headers.items(): + normalized[name.lower()] = value + return tuple(sorted(normalized.items())) + + # Internal polling bounds for MCP long-running tasks. Not user-tunable today; # promote to MCPTaskOptions if a concrete need arises. _MCP_TASK_MIN_POLL_INTERVAL = timedelta(milliseconds=500) @@ -942,7 +969,7 @@ def __init__( self._lifecycle_request_lock = asyncio.Lock() self._function_load_lock = asyncio.Lock() self._lifecycle_queue: ( - asyncio.Queue[tuple[str, bool, bool, asyncio.Future[None], asyncio.Future[bool]]] | None + asyncio.Queue[tuple[str, bool, bool, bool, asyncio.Future[None], asyncio.Future[bool]]] | None ) = None self._lifecycle_owner_task: asyncio.Task[None] | None = None self.session = session @@ -1564,7 +1591,7 @@ async def _run_lifecycle_owner(self) -> None: stop_error: BaseException | None = None try: while True: - action, reset, load_configured, future, acknowledged = await queue.get() + action, reset, load_configured, reset_discovery, future, acknowledged = await queue.get() if action == "connect" and future.cancelled(): if not self.is_connected and queue.empty(): return @@ -1574,7 +1601,11 @@ async def _run_lifecycle_owner(self) -> None: if action == "connect": previous_session = self.session previously_connected = self.is_connected - await self._connect_on_owner(reset=reset, load_configured=load_configured) + await self._connect_on_owner( + reset=reset, + load_configured=load_configured, + reset_discovery=reset_discovery, + ) new_connection = not previously_connected or self.session is not previous_session accepted = False try: @@ -1586,6 +1617,8 @@ async def _run_lifecycle_owner(self) -> None: finally: if not accepted and new_connection: await self._close_on_owner() + if reset_discovery: + self._reset_session_discovery_state() if not accepted and new_connection and queue.empty(): return elif action == "close": @@ -1616,7 +1649,7 @@ async def _run_lifecycle_owner(self) -> None: finally: while True: try: - _, _, _, future, _ = queue.get_nowait() + _, _, _, _, future, _ = queue.get_nowait() except asyncio.QueueEmpty: break if not future.done(): @@ -1635,12 +1668,17 @@ async def _run_on_lifecycle_owner( *, reset: bool = False, load_configured: bool = True, + reset_discovery: bool = False, ) -> None: await self._ensure_lifecycle_owner() if self._is_lifecycle_owner_task(): if action == "connect": - await self._connect_on_owner(reset=reset, load_configured=load_configured) + await self._connect_on_owner( + reset=reset, + load_configured=load_configured, + reset_discovery=reset_discovery, + ) elif action == "close": await self._close_on_owner() else: @@ -1653,7 +1691,7 @@ async def _run_on_lifecycle_owner( future = asyncio.get_running_loop().create_future() acknowledged: asyncio.Future[bool] = asyncio.get_running_loop().create_future() - await queue.put((action, reset, load_configured, future, acknowledged)) + await queue.put((action, reset, load_configured, reset_discovery, future, acknowledged)) accepted = False try: await future @@ -1712,6 +1750,18 @@ async def _close_and_check_cancelled(self, ex: BaseException) -> tuple[bool, Bas self._release_connection_kwargs() return _should_propagate_cancelled_error(ex), cleanup_error + def _reset_session_discovery_state(self) -> None: + """Remove functions and metadata derived from the current MCP session.""" + self._functions[:] = [ + function + for function in self._functions + if not isinstance((function.additional_properties or {}).get(_MCP_REMOTE_NAME_KEY), str) + ] + self._tool_call_meta_by_name.clear() + self._tool_task_support_by_name.clear() + self._tool_param_names_by_name.clear() + self._progressive_loaded_tool_names.clear() + def _reset_session_state(self) -> None: self._server_capabilities = None self._tools_loaded = False @@ -1750,7 +1800,13 @@ async def connect(self, *, reset: bool = False) -> None: async with self._lifecycle_request_lock: await self._run_on_lifecycle_owner("connect", reset=reset) - async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool = True) -> None: + async def _connect_on_owner( + self, + *, + reset: bool = False, + load_configured: bool = True, + reset_discovery: bool = False, + ) -> None: """Connect to the MCP server. Establishes a connection to the MCP server, initializes the session, @@ -1759,16 +1815,21 @@ async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool Keyword Args: reset: If True, forces a reconnection even if already connected. load_configured: If True, loads tools and prompts according to the constructor flags. + reset_discovery: If True, removes state derived from the previous session after its transport closes. Raises: ToolException: If connection or session initialization fails. """ if reset: + if reset_discovery: + await self._cancel_pending_reload_tasks() await self._safe_close_exit_stack() if self._owns_session: self.session = None self.is_connected = False self._reset_session_state() + if reset_discovery: + self._reset_session_discovery_state() self._exit_stack = AsyncExitStack() if not self.session: try: @@ -1901,6 +1962,10 @@ async def _connect_on_owner(self, *, reset: bool = False, load_configured: bool self._tool_param_names_by_name = param_names_before_discovery raise + async def _prepare_for_run(self, kwargs: Mapping[str, Any]) -> None: + """Prepare connection-scoped state before exposing functions to a run.""" + self._seed_connection_kwargs(kwargs) + def _seed_connection_kwargs(self, kwargs: Mapping[str, Any]) -> None: """Offer run-scoped kwargs to connection-lifetime header resolution.""" return @@ -2195,6 +2260,17 @@ def _determine_approval_mode( return None return self.approval_mode # type: ignore[return-value] + async def _load_configured_discovery_locked(self) -> None: + """Load configured session discovery while the caller holds the discovery lock.""" + if self.load_tools_flag: + if self._supports_tools: + await self._load_tools_locked() + self._tools_loaded = True + if self.load_prompts_flag: + if self._supports_prompts: + await self._load_prompts_locked() + self._prompts_loaded = True + async def load_prompts(self) -> None: """Load prompts from the MCP server. @@ -2270,7 +2346,7 @@ async def _load_prompts_locked(self) -> None: ) ) func: FunctionTool = FunctionTool( - func=partial(self.get_prompt, prompt.name), + func=_make_mcp_prompt_caller(self, prompt.name), name=local_name, description=prompt.description or "", approval_mode=approval_mode, @@ -2423,8 +2499,8 @@ async def _load_tools_locked(self) -> None: self._tool_task_support_by_name = tool_task_support_by_name self._tool_param_names_by_name = tool_param_names_by_name - async def _close_on_owner(self) -> None: - # Cancel any pending reload tasks before tearing down the session. + async def _cancel_pending_reload_tasks(self) -> None: + """Cancel session-bound discovery reloads and wait for them to finish.""" tasks = list(self._pending_reload_tasks) for task in tasks: task.cancel() @@ -2432,6 +2508,9 @@ async def _close_on_owner(self) -> None: if tasks: await asyncio.gather(*tasks, return_exceptions=True) + async def _close_on_owner(self) -> None: + await self._cancel_pending_reload_tasks() + await self._safe_close_exit_stack() self._exit_stack = AsyncExitStack() if self._owns_session: @@ -3126,6 +3205,15 @@ def _is_connection_lost(ex: BaseException) -> bool: return "session terminated" in ex.error.message.lower() return False + async def _call_prompt_with_runtime_kwargs( + self, + prompt_name: str, + prompt_arguments: Mapping[str, Any], + _runtime_kwargs: Mapping[str, Any], + ) -> str: + """Invoke a generated prompt without forwarding run context as prompt arguments.""" + return await self.get_prompt(prompt_name, **prompt_arguments) + async def get_prompt(self, prompt_name: str, **kwargs: Any) -> str: """Call a prompt with the given arguments. @@ -3576,18 +3664,28 @@ def __init__( of HTTP headers to inject into every outbound request to the MCP server. Use this to forward per-request context (e.g. authentication tokens set in agent middleware) without creating a separate ``httpx.AsyncClient``. - Only tool calls carry a run's kwargs. Connection-lifetime requests - the - ``initialize`` handshake, tool and prompt discovery, and background pings - - belong to no call, so they reuse the kwargs of the run that established the - connection until the tool is closed; a later run's kwargs do not reach them. + The complete header set used to initialize a connection becomes that session's + immutable effective identity. Header names are compared case-insensitively and + values case-sensitively. Before an Agent exposes a connected tool's functions for + a run, it reconciles the run's effective headers and reconnects when they differ; + generated tool and prompt calls perform the same check before sending. Connection-lifetime + requests - including discovery, background pings, resource and prompt reloads, + and long-running task polling - always use the session-bound header set. A + caller-supplied session's established identity is unknown and cannot be + reconnected by this wrapper, so dynamic header resolution raises + ``ToolExecutionException`` and requires a separate framework-managed tool instance. A tool connected outside any run (eagerly via ``async with``, or standalone) has no kwargs to reuse and the provider is called with an empty mapping, in which case a ``KeyError`` from the provider is tolerated and the request is - sent without headers. Once a run has supplied kwargs, a ``KeyError`` is - raised instead, since a key missing there is a misconfiguration rather than - an unavoidable gap. A credential that must authenticate the handshake should - therefore come from somewhere the provider can read without a run - a closure - or a ``ContextVar`` - rather than from run kwargs alone. + sent without headers. Once a run has seeded an initial connection, a ``KeyError`` + during the handshake is raised instead, since a missing key there is a + misconfiguration rather than an unavoidable gap. For an already-connected tool, + run preparation defers identity reconciliation when the provider needs a + model-supplied argument that is unavailable until invocation; provider errors at + invocation still propagate. A credential that must authenticate an eager + handshake must therefore come from somewhere the provider can read without a run, + such as a closure or ``ContextVar``. A lazy connection established by an agent + run can use that run's kwargs. The framework attaches these headers only to requests whose origin (scheme, host, port) matches the configured ``url``, so they are not leaked to other origins on cross-origin redirects; headers injected this way are also removed @@ -3669,6 +3767,13 @@ def __init__( # None means no run seeded this connection, which an empty mapping cannot express: # a run that supplies no kwargs still expects a missing provider key to be an error. self._connection_kwargs: dict[str, Any] | None = None + # Every connected transport is bound to one effective header set. A different + # set is staged until the current transport has closed, then promoted before + # the replacement transport initializes. + self._session_headers: dict[str, str] | None = None + self._session_header_identity: _MCPHeaderIdentity | None = None + self._pending_session_headers: dict[str, str] | None = None + self._pending_connection_kwargs: dict[str, Any] | None = None self._call_headers_lock = asyncio.Lock() self._header_request_owner = object() self._header_hook_client: AsyncClient | None = None @@ -3699,6 +3804,8 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: """ from httpx import URL, AsyncClient, Timeout + self._promote_pending_session_headers() + http_client = self._httpx_client if self._static_headers or self._header_provider is not None: target_origin = _url_origin(URL(self.url)) @@ -3740,24 +3847,30 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async # connection, so static providers and run-supplied credentials both # authenticate these requests. Provider failures propagate, matching the # call_tool path, except the one case below that no caller can avoid. - try: - dynamic_headers = self._header_provider(self._connection_kwargs or {}) - except KeyError: - # Unavoidable only when no run seeded this connection: the provider - # wants per-call values a connection-lifetime request cannot have. Once - # a run has seeded kwargs a missing key is a misconfiguration, and - # silently dropping it would send the handshake unauthenticated. - if self._connection_kwargs is not None: - raise - logger.debug( - "header_provider raised KeyError for MCP server %r on an ambient " - "request (no connection kwargs available); proceeding without headers.", - self.name, - exc_info=True, - ) - dynamic_headers = {} + if self._session_headers is not None: + headers = self._session_headers.copy() + dynamic_headers = None + else: + try: + dynamic_headers = self._header_provider(self._connection_kwargs or {}) + except KeyError: + # Unavoidable only when no run seeded this connection: the provider + # wants per-call values a connection-lifetime request cannot have. Once + # a run has seeded kwargs a missing key is a misconfiguration, and + # silently dropping it would send the handshake unauthenticated. + if self._connection_kwargs is not None: + raise + logger.debug( + "header_provider raised KeyError for MCP server %r on an ambient " + "request (no connection kwargs available); proceeding without headers.", + self.name, + exc_info=True, + ) + dynamic_headers = {} if dynamic_headers is not None: headers.update(dynamic_headers) + if self._session_headers is None: + self._bind_session_headers(headers) for key in request.extensions.pop(_MCP_INJECTED_HEADER_KEYS_EXTENSION, ()): request.headers.pop(key, None) for key, value in headers.items(): @@ -3822,8 +3935,145 @@ def _seed_connection_kwargs(self, kwargs: Mapping[str, Any]) -> None: return self._connection_kwargs = dict(kwargs) + def _effective_headers(self, kwargs: Mapping[str, Any]) -> dict[str, str]: + headers = self._static_headers.copy() + if self._header_provider is not None: + headers.update(self._header_provider(dict(kwargs))) + return headers + + async def _prepare_for_run(self, kwargs: Mapping[str, Any]) -> None: + if not self.is_connected: + await super()._prepare_for_run(kwargs) + return + if self._header_provider is None: + return + try: + headers = self._effective_headers(kwargs) + except KeyError: + # Some providers intentionally read model-supplied tool arguments that + # do not exist until invocation. Keep preparation non-breaking and let + # the strict invocation-time resolution reconcile the session later. + logger.debug( + "Deferring MCP header identity reconciliation for %r until invocation.", + self.name, + exc_info=True, + ) + return + async with self._call_headers_lock: + await self._ensure_session_identity(headers, kwargs) + + def _bind_session_headers(self, headers: Mapping[str, str]) -> None: + self._session_headers = dict(headers) + self._session_header_identity = _mcp_header_identity(headers) + + def _stage_session_headers(self, headers: Mapping[str, str], kwargs: Mapping[str, Any]) -> None: + self._pending_session_headers = dict(headers) + self._pending_connection_kwargs = dict(kwargs) + + def _promote_pending_session_headers(self) -> None: + if self._pending_session_headers is None: + return + headers = self._pending_session_headers + connection_kwargs = self._pending_connection_kwargs + self._discard_pending_session_headers() + self._connection_kwargs = connection_kwargs + self._bind_session_headers(headers) + + def _discard_pending_session_headers(self) -> None: + self._pending_session_headers = None + self._pending_connection_kwargs = None + + async def _reconnect_for_identity_change(self) -> None: + async with self._function_load_lock: + if self._is_lifecycle_owner_task(): + await self._connect_on_owner(reset=True, load_configured=False, reset_discovery=True) + else: + async with self._lifecycle_request_lock: + await self._run_on_lifecycle_owner( + "connect", + reset=True, + load_configured=False, + reset_discovery=True, + ) + + try: + await self._load_configured_discovery_locked() + except (Exception, asyncio.CancelledError): + self._reset_session_discovery_state() + if self._is_lifecycle_owner_task(): + await self._close_on_owner() + else: + await self.close() + raise + + async def _ensure_session_identity( + self, + headers: Mapping[str, str], + kwargs: Mapping[str, Any], + ) -> None: + identity = _mcp_header_identity(headers) + if not self._owns_session: + if self._session_header_identity is None: + raise ToolExecutionException( + "MCP header identity is unknown for a caller-supplied session; " + "use a separate framework-managed tool instance." + ) + if identity != self._session_header_identity: + raise ToolExecutionException( + "MCP header identity cannot change for a caller-supplied session; use a separate tool instance." + ) + return + + if not self.is_connected: + return + if self._session_header_identity is None: + self._bind_session_headers(headers) + return + if identity == self._session_header_identity: + return + + self._stage_session_headers(headers, kwargs) + try: + await self._reconnect_for_identity_change() + except (Exception, asyncio.CancelledError): + if self.is_connected: + self._discard_pending_session_headers() + else: + self._release_connection_kwargs() + raise + self._promote_pending_session_headers() + + async def _call_prompt_with_runtime_kwargs( + self, + prompt_name: str, + prompt_arguments: Mapping[str, Any], + runtime_kwargs: Mapping[str, Any], + ) -> str: + if self._header_provider is None: + return await super()._call_prompt_with_runtime_kwargs(prompt_name, prompt_arguments, runtime_kwargs) + + headers = self._effective_headers(runtime_kwargs) + async with self._call_headers_lock: + await self._ensure_session_identity(headers, runtime_kwargs) + token = _mcp_call_headers.set(headers) + self._active_call_headers = headers + try: + return await super()._call_prompt_with_runtime_kwargs( + prompt_name, + prompt_arguments, + runtime_kwargs, + ) + finally: + self._active_call_headers = None + _mcp_call_headers.reset(token) + def _release_connection_kwargs(self) -> None: self._connection_kwargs = None + self._pending_session_headers = None + self._pending_connection_kwargs = None + if self._owns_session: + self._session_headers = None + self._session_header_identity = None async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: """Call a tool, injecting headers from the header_provider if configured. @@ -3831,7 +4081,10 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: When a ``header_provider`` was supplied at construction time, the runtime *kwargs* (originating from ``FunctionInvocationContext.kwargs``) are passed to the provider. The returned headers are attached to every HTTP request - made during this tool call via a request hook on the underlying HTTP client. + made during this tool call via a request hook on the underlying HTTP client. Fixed + and dynamic headers form the effective identity. If they differ from a framework-created + session's identity, the tool reconnects before sending the call; caller-supplied sessions + reject the change. The provider does not consume the kwargs: the same mapping continues to :meth:`MCPTool.call_tool` and its outbound argument filter. @@ -3846,8 +4099,9 @@ async def call_tool(self, tool_name: str, **kwargs: Any) -> str | list[Content]: A list of Content items representing the tool output. """ if self._header_provider is not None: - headers = self._header_provider(kwargs) + headers = self._effective_headers(kwargs) async with self._call_headers_lock: + await self._ensure_session_identity(headers, kwargs) token = _mcp_call_headers.set(headers) self._active_call_headers = headers try: diff --git a/python/packages/core/tests/core/test_agents.py b/python/packages/core/tests/core/test_agents.py index 4ef0d031d72..1cd2596cc46 100644 --- a/python/packages/core/tests/core/test_agents.py +++ b/python/packages/core/tests/core/test_agents.py @@ -1,10 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import contextlib import inspect import json import logging -from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence, Sequence +from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableSequence, Sequence from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -1919,6 +1920,97 @@ async def test_chat_agent_as_mcp_server_basic(client: SupportsChatGetResponse) - assert hasattr(server, "version") +async def test_agent_prepares_mcp_run_before_copying_functions(chat_client_base: Any) -> None: + captured_options: list[dict[str, Any]] = [] + original_inner = chat_client_base._inner_get_response + + async def capturing_inner( + *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any + ) -> ChatResponse: + captured_options.append(dict(options)) + return await original_inner(messages=messages, options=options, **kwargs) + + class RunPreparingMCPTool(_ConnectedMCPTool): + async def _prepare_for_run(self, kwargs: Mapping[str, Any]) -> None: + assert kwargs == {"credential": "token-b"} + replacement = _ConnectedMCPTool(name=self.name, function_names=["token-b-only"]) + self._functions = list(replacement.functions) + + chat_client_base._inner_get_response = capturing_inner + mcp_tool = RunPreparingMCPTool(name="principal-mcp", function_names=["token-a-only"]) + agent = Agent(client=chat_client_base, tools=[mcp_tool]) + + await agent.run("hello", function_invocation_kwargs={"credential": "token-b"}) + + assert len(captured_options) >= 1 + assert [tool.name for tool in captured_options[0]["tools"]] == ["token-b-only"] + + +async def test_concurrent_agent_runs_reprepare_mcp_after_lazy_connect(chat_client_base: Any) -> None: + captured_tool_names: list[list[str]] = [] + original_inner = chat_client_base._inner_get_response + + async def capturing_inner( + *, messages: MutableSequence[Message], options: dict[str, Any], **kwargs: Any + ) -> ChatResponse: + captured_tool_names.append([tool.name for tool in options["tools"]]) + return await original_inner(messages=messages, options=options, **kwargs) + + class ConcurrentRunMCPTool(_ConnectedMCPTool): + def __init__(self) -> None: + super().__init__(name="principal-mcp", function_names=["unbound-only"]) + self.is_connected = False + self._connect_lock = asyncio.Lock() + self.connect_started = asyncio.Event() + self.both_runs_prepared = asyncio.Event() + self.allow_connect = asyncio.Event() + self.disconnected_preparations: list[str] = [] + + async def _prepare_for_run(self, kwargs: Mapping[str, Any]) -> None: + credential = cast(str, kwargs["credential"]) + if not self.is_connected: + self.disconnected_preparations.append(credential) + if len(self.disconnected_preparations) == 2: + self.both_runs_prepared.set() + return + replacement = _ConnectedMCPTool(name=self.name, function_names=[f"{credential}-only"]) + self._functions = list(replacement.functions) + + async def connect(self, *, reset: bool = False) -> None: + async with self._connect_lock: + if self.is_connected: + return + self.connect_started.set() + await self.allow_connect.wait() + self.is_connected = True + + async def close(self) -> None: + self.is_connected = False + + chat_client_base._inner_get_response = capturing_inner + mcp_tool = ConcurrentRunMCPTool() + first_agent = Agent(client=chat_client_base, tools=[mcp_tool]) + second_agent = Agent(client=chat_client_base, tools=[mcp_tool]) + + async def run_with_credential(agent: Agent, prompt: str, credential: str) -> None: + await agent.run(prompt, function_invocation_kwargs={"credential": credential}) + + first_run = asyncio.create_task(run_with_credential(first_agent, "first", "token-a")) + await asyncio.wait_for(mcp_tool.connect_started.wait(), timeout=5) + second_run = asyncio.create_task(run_with_credential(second_agent, "second", "token-b")) + try: + await asyncio.wait_for(mcp_tool.both_runs_prepared.wait(), timeout=5) + mcp_tool.allow_connect.set() + await asyncio.gather(first_run, second_run) + finally: + mcp_tool.allow_connect.set() + await first_agent.__aexit__(None, None, None) + await second_agent.__aexit__(None, None, None) + + assert mcp_tool.disconnected_preparations == ["token-a", "token-b"] + assert captured_tool_names == [["token-a-only"], ["token-b-only"]] + + async def test_chat_agent_run_with_mcp_tools(client: SupportsChatGetResponse) -> None: """Test run method with MCP tools to cover MCP tool handling code.""" agent = Agent(client=client, name="TestAgent", description="Test agent") diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 7102dbd7e35..ce4ecaa99e9 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -7171,8 +7171,8 @@ async def test_mcp_streamable_http_tool_header_provider_skips_cross_origin_redir await tool._httpx_client.aclose() # type: ignore[union-attr] -async def test_mcp_streamable_http_tool_replaces_headers_on_same_origin_redirect(): - """A redirected request must retain only the provider's latest header set.""" +async def test_mcp_streamable_http_tool_keeps_bound_headers_on_same_origin_redirect(): + """A redirected request must retain the header set bound to its session.""" import httpx provider_headers = {"X-Previous": "old"} @@ -7202,8 +7202,8 @@ async def test_mcp_streamable_http_tool_replaces_headers_on_same_origin_redirect extensions=initial.extensions, ) await hooks[0](same_origin_redirect) - assert "X-Previous" not in same_origin_redirect.headers - assert same_origin_redirect.headers.get("X-Current") == "new" + assert same_origin_redirect.headers.get("X-Previous") == "old" + assert "X-Current" not in same_origin_redirect.headers cross_origin_redirect = httpx.Request( "POST", @@ -7939,7 +7939,7 @@ async def blocking_call_tool(tool_name, *, arguments=None, meta=None): return types.CallToolResult(content=[types.TextContent(type="text", text="Hello!")]) class _TestServer(MCPStreamableHTTPTool): - async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override] + async def connect(self, *, reset: bool = False) -> None: self.session = Mock(spec=ClientSession) self.session.list_tools = AsyncMock( return_value=types.ListToolsResult( @@ -7956,6 +7956,9 @@ async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-overri self.session.send_ping = AsyncMock() self.is_connected = True + async def _reconnect_for_identity_change(self) -> None: + await self.connect(reset=True) + def get_mcp_client(self): # pyrefly: ignore[bad-override] return None @@ -9552,7 +9555,7 @@ async def test_header_provider_reading_contextvar_keeps_credential_out_of_argume seen_headers: list[dict[str, str]] = [] class TestServer(MCPStreamableHTTPTool): - async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override] + async def connect(self, *, reset: bool = False) -> None: self.session = Mock(spec=ClientSession) self.session.list_tools = AsyncMock( return_value=types.ListToolsResult( @@ -9575,6 +9578,9 @@ async def connect(self): # type: ignore[override] # pyrefly: ignore[bad-overri self.session.send_ping = AsyncMock() self.is_connected = True + async def _reconnect_for_identity_change(self) -> None: + await self.connect(reset=True) + def get_mcp_client(self): # pyrefly: ignore[bad-override] return None diff --git a/python/packages/core/tests/core/test_mcp_http_auth.py b/python/packages/core/tests/core/test_mcp_http_auth.py index 31bb5afae5c..b84b9721aa2 100644 --- a/python/packages/core/tests/core/test_mcp_http_auth.py +++ b/python/packages/core/tests/core/test_mcp_http_auth.py @@ -13,7 +13,7 @@ import httpx import pytest -from agent_framework import MCPStreamableHTTPTool +from agent_framework import FunctionInvocationContext, MCPStreamableHTTPTool from agent_framework.exceptions import ToolException, ToolExecutionException MCPHTTPServer: TypeAlias = tuple[httpx.AsyncClient, list[httpx.Request], dict[str, list[str]]] @@ -22,7 +22,7 @@ @pytest.fixture async def mcp_http_server() -> AsyncIterator[MCPHTTPServer]: requests: list[httpx.Request] = [] - writes: dict[str, list[str]] = {"token-a": [], "token-b": [], "token-c": []} + writes: dict[str, list[str]] = {"token-a": [], "token-A": [], "token-b": [], "token-c": []} async def record_request(request: httpx.Request) -> None: requests.append(request) @@ -31,7 +31,7 @@ async def handle(request: httpx.Request) -> httpx.Response: if request.url.path == "/unrelated": return httpx.Response(200) principal = request.headers.get("Authorization", "") - if principal not in writes: + if principal not in writes and principal != "rejected-token": return httpx.Response(401) if request.method == "GET": return httpx.Response(405) @@ -39,6 +39,11 @@ async def handle(request: httpx.Request) -> httpx.Response: return httpx.Response(200) body = json.loads(request.content) method = body.get("method") + if principal == "rejected-token": + return httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": body.get("id"), "error": {"code": -32001, "message": "rejected"}}, + ) headers: dict[str, str] = {} result: dict[str, Any] = {} if method == "initialize": @@ -54,7 +59,11 @@ async def handle(request: httpx.Request) -> httpx.Response: { "name": "record", "inputSchema": {"type": "object", "properties": {"marker": {"type": "string"}}}, - } + }, + { + "name": f"{principal}-only", + "inputSchema": {"type": "object", "properties": {}}, + }, ] } elif method == "tools/call": @@ -64,7 +73,24 @@ async def handle(request: httpx.Request) -> httpx.Response: writes[principal].append(marker) result = {"content": [{"type": "text", "text": principal}]} elif method == "prompts/list": - result = {"prompts": []} + result = ( + { + "prompts": [ + { + "name": "principal-prompt", + "arguments": [{"name": "topic", "required": True}], + } + ] + } + if request.headers.get("X-Test-Prompts") == "enabled" + else {"prompts": []} + ) + elif method == "prompts/get": + topic = body["params"].get("arguments", {}).get("topic", "") + result = { + "description": principal, + "messages": [{"role": "user", "content": {"type": "text", "text": f"{principal}:{topic}"}}], + } if "id" not in body: return httpx.Response(202) return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) @@ -93,6 +119,14 @@ def _calls(requests: list[httpx.Request]) -> list[httpx.Request]: ] +def _requests_for_method(requests: list[httpx.Request], method: str) -> list[httpx.Request]: + return [ + request + for request in requests + if request.method == "POST" and json.loads(request.content).get("method") == method + ] + + @pytest.mark.parametrize("principals", [("token-a", "token-b"), ("token-b", "token-a")]) async def test_shared_client_keeps_tools_and_caller_requests_isolated( mcp_http_server: MCPHTTPServer, principals: tuple[str, str] @@ -265,13 +299,268 @@ async def test_shared_client_concurrent_calls_keep_dynamic_headers_isolated(mcp_ ) calls = _calls(requests) assert {request.headers["mcp-session-id"]: request.headers["Authorization"] for request in calls} == { - "session-token-a": "token-c", + "session-token-c": "token-c", "session-token-b": "token-b", } - assert writes == {"token-a": [], "token-b": ["second"], "token-c": ["first"]} + assert writes == {"token-a": [], "token-A": [], "token-b": ["second"], "token-c": ["first"]} assert all("credential" not in json.loads(request.content)["params"]["arguments"] for request in calls) +async def test_dynamic_headers_reconnect_before_principal_change_and_bind_ambient_requests( + mcp_http_server: MCPHTTPServer, +) -> None: + client, requests, writes = mcp_http_server + tool = _tool(client, "token-a") + try: + await tool.connect() + await tool.call_tool("record", credential="token-a", marker="first") + await tool.call_tool("record", credential="token-b", marker="second") + await tool.load_tools() + assert tool.session is not None + await tool.session.send_ping() + assert {function.name for function in tool.functions} == {"record", "token-b-only"} + + assert [request.headers["Authorization"] for request in _requests_for_method(requests, "initialize")] == [ + "token-a", + "token-b", + ] + calls = _calls(requests) + assert [(request.headers["mcp-session-id"], request.headers["Authorization"]) for request in calls] == [ + ("session-token-a", "token-a"), + ("session-token-b", "token-b"), + ] + second_initialize = _requests_for_method(requests, "initialize")[-1] + switched_requests = requests[requests.index(second_initialize) :] + ambient_requests = _requests_for_method(switched_requests, "tools/list") + _requests_for_method( + switched_requests, "ping" + ) + assert ambient_requests + assert all(request.headers["Authorization"] == "token-b" for request in ambient_requests) + assert all(request.headers["mcp-session-id"] == "session-token-b" for request in ambient_requests) + assert writes["token-a"] == ["first"] + assert writes["token-b"] == ["second"] + finally: + await tool.close() + + +async def test_identity_switch_waits_for_public_discovery_before_teardown(mcp_http_server: MCPHTTPServer) -> None: + client, _, _ = mcp_http_server + tool = _tool(client, "token-a") + await tool.connect() + load_started = asyncio.Event() + release_load = asyncio.Event() + reset_started = asyncio.Event() + load_count = 0 + original_load_tools_locked = tool._load_tools_locked + original_safe_close_exit_stack = tool._safe_close_exit_stack + + async def blocking_load_tools_locked() -> None: + nonlocal load_count + load_count += 1 + if load_count == 1: + load_started.set() + await release_load.wait() + return + await original_load_tools_locked() + + async def recording_safe_close_exit_stack() -> BaseException | None: + reset_started.set() + return await original_safe_close_exit_stack() + + with ( + patch.object(tool, "_load_tools_locked", new=blocking_load_tools_locked), + patch.object(tool, "_safe_close_exit_stack", new=recording_safe_close_exit_stack), + ): + public_load = asyncio.create_task(tool.load_tools()) + try: + await asyncio.wait_for(load_started.wait(), timeout=5) + identity_switch = asyncio.create_task(tool._prepare_for_run({"credential": "token-b"})) + await asyncio.sleep(0.05) + assert not reset_started.is_set() + release_load.set() + await asyncio.wait_for(asyncio.gather(public_load, identity_switch), timeout=5) + assert {function.name for function in tool.functions} == {"record", "token-b-only"} + finally: + release_load.set() + public_load.cancel() + await asyncio.gather(public_load, return_exceptions=True) + await tool.close() + + +async def test_captured_prompt_reconciles_run_identity_at_invocation(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + tool = MCPStreamableHTTPTool( + name="prompts", + url="https://mcp.example/mcp", + http_client=client, + load_tools=False, + static_headers={"X-Test-Prompts": "enabled"}, + header_provider=lambda kwargs: {"Authorization": kwargs["credential"]}, + ) + tool._seed_connection_kwargs({"credential": "token-a"}) + try: + await tool.connect() + captured_prompt = next(function for function in tool.functions if function.name == "principal-prompt") + + await tool._prepare_for_run({"credential": "token-b"}) + assert _requests_for_method(requests, "initialize")[-1].headers["Authorization"] == "token-b" + + context = FunctionInvocationContext( + function=captured_prompt, + arguments={"topic": "identity"}, + kwargs={"credential": "token-a"}, + ) + await captured_prompt.invoke(arguments={"topic": "identity"}, context=context) + + prompt_request = _requests_for_method(requests, "prompts/get")[-1] + assert prompt_request.headers["Authorization"] == "token-a" + assert prompt_request.headers["mcp-session-id"] == "session-token-a" + assert json.loads(prompt_request.content)["params"]["arguments"] == {"topic": "identity"} + finally: + await tool.close() + + +async def test_run_preparation_reconnects_before_exposing_principal_tools(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + tool = _tool(client, "token-a") + try: + await tool.connect() + + await tool._prepare_for_run({"credential": "token-b"}) + + assert {function.name for function in tool.functions} == {"record", "token-b-only"} + assert [request.headers["Authorization"] for request in _requests_for_method(requests, "initialize")] == [ + "token-a", + "token-b", + ] + finally: + await tool.close() + + +async def test_concurrent_callers_use_sessions_bound_to_their_own_headers(mcp_http_server: MCPHTTPServer) -> None: + client, requests, writes = mcp_http_server + tool = _tool(client, "token-a") + try: + await tool.connect() + await asyncio.gather( + tool.call_tool("record", credential="token-a", marker="first"), + tool.call_tool("record", credential="token-b", marker="second"), + ) + + calls = _calls(requests) + assert len(calls) == 2 + assert all( + request.headers["mcp-session-id"] == f"session-{request.headers['Authorization']}" for request in calls + ) + assert writes["token-a"] == ["first"] + assert writes["token-b"] == ["second"] + finally: + await tool.close() + + +async def test_header_identity_normalizes_names_but_preserves_value_case(mcp_http_server: MCPHTTPServer) -> None: + client, requests, writes = mcp_http_server + + def provide_headers(kwargs: dict[str, Any]) -> dict[str, str]: + return { + kwargs.get("header_name", "Authorization"): kwargs.get("credential", "token-a"), + kwargs.get("scope_header_name", "X-Scope"): kwargs.get("scope", "red"), + } + + tool = MCPStreamableHTTPTool( + name="normalized", + url="https://mcp.example/mcp", + http_client=client, + load_prompts=False, + static_headers={"X-Static": "fixed"}, + header_provider=provide_headers, + ) + tool._seed_connection_kwargs({ + "header_name": "Authorization", + "credential": "token-a", + "scope_header_name": "X-Scope", + "scope": "red", + }) + try: + await tool.connect() + await tool.call_tool( + "record", + header_name="authorization", + credential="token-a", + scope_header_name="x-scope", + scope="red", + marker="same", + ) + assert len(_requests_for_method(requests, "initialize")) == 1 + + await tool.call_tool("record", credential="token-a", scope="blue", marker="scope-change") + await tool.call_tool("record", credential="token-A", scope="blue", marker="value-case-change") + assert [request.headers["Authorization"] for request in _requests_for_method(requests, "initialize")] == [ + "token-a", + "token-a", + "token-A", + ] + assert writes["token-a"] == ["same", "scope-change"] + assert writes["token-A"] == ["value-case-change"] + finally: + await tool.close() + + +async def test_failed_identity_reconnect_discards_the_previous_session(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + tool = _tool(client, "token-a") + try: + await tool.connect() + with pytest.raises((ToolException, ToolExecutionException)): + await tool.call_tool("record", credential="rejected-token") + + assert tool.session is None + assert not tool.is_connected + assert tool.functions == [] + assert not any( + request.headers.get("Authorization") == "rejected-token" + and request.headers.get("mcp-session-id") == "session-token-a" + for request in requests + ) + finally: + await tool.close() + + +async def test_cancelled_identity_switch_keeps_the_existing_session_bound(mcp_http_server: MCPHTTPServer) -> None: + client, requests, _ = mcp_http_server + tool = _tool(client, "token-a") + try: + await tool.connect() + existing_session = tool.session + existing_functions = list(tool.functions) + existing_call_meta = dict(tool._tool_call_meta_by_name) + existing_task_support = dict(tool._tool_task_support_by_name) + existing_param_names = {name: set(params) for name, params in tool._tool_param_names_by_name.items()} + + async def cancel_reconnect() -> None: + raise asyncio.CancelledError + + with ( + patch.object(tool, "_reconnect_for_identity_change", new=cancel_reconnect), + pytest.raises(asyncio.CancelledError), + ): + await tool._prepare_for_run({"credential": "token-b"}) + + assert tool.is_connected + assert tool.session is existing_session + assert tool.functions == existing_functions + assert tool._tool_call_meta_by_name == existing_call_meta + assert tool._tool_task_support_by_name == existing_task_support + assert tool._tool_param_names_by_name == existing_param_names + + await tool.call_tool("record", credential="token-b") + last_call = _calls(requests)[-1] + assert last_call.headers["Authorization"] == "token-b" + assert last_call.headers["mcp-session-id"] == "session-token-b" + finally: + await tool.close() + + async def test_headerless_transport_does_not_inherit_another_transports_credentials( mcp_http_server: MCPHTTPServer, ) -> None: @@ -551,8 +840,14 @@ async def test_connect_cancelled_during_result_delivery_releases_session(mcp_htt connect_on_owner = tool._connect_on_owner close_on_owner = tool._close_on_owner - async def cancel_before_delivery(*, reset: bool = False, load_configured: bool = True) -> None: - await connect_on_owner(reset=reset, load_configured=load_configured) + async def cancel_before_delivery( + *, reset: bool = False, load_configured: bool = True, reset_discovery: bool = False + ) -> None: + await connect_on_owner( + reset=reset, + load_configured=load_configured, + reset_discovery=reset_discovery, + ) # Setup succeeds, then the caller is cancelled before consuming the completed future. asyncio.get_running_loop().call_soon(caller.cancel, None) @@ -669,6 +964,44 @@ async def test_failed_discovery_preserves_caller_supplied_session( await supplied_session.send_ping() +async def test_caller_supplied_session_rejects_header_identity_changes(mcp_http_server: MCPHTTPServer) -> None: + client, _, _ = mcp_http_server + async with _tool(client, "token-a") as source: + supplied_session = source.session + assert supplied_session is not None + borrowed = MCPStreamableHTTPTool( + name="borrowed", + url="https://must-not-connect.example/mcp", + session=supplied_session, + load_prompts=False, + header_provider=lambda kwargs: {"Authorization": kwargs["credential"]}, + ) + try: + await borrowed.connect() + with pytest.raises(ToolExecutionException, match="caller-supplied session"): + await borrowed._prepare_for_run({"credential": "token-b"}) + with pytest.raises(ToolExecutionException, match="caller-supplied session"): + await borrowed.call_tool("record", credential="token-b") + assert borrowed.is_connected + assert borrowed.session is supplied_session + + await borrowed.close() + assert not borrowed.is_connected + assert borrowed.session is supplied_session + with pytest.raises(ToolExecutionException, match="caller-supplied session"): + await borrowed.call_tool("record", credential="token-a") + await borrowed.connect() + + with pytest.raises(ToolExecutionException, match="caller-supplied session"): + await borrowed._prepare_for_run({"credential": "token-a"}) + with pytest.raises(ToolExecutionException, match="caller-supplied session"): + await borrowed.call_tool("record", credential="token-a") + finally: + await borrowed.close() + + await supplied_session.send_ping() + + async def test_cancelled_redundant_connect_keeps_existing_session(mcp_http_server: MCPHTTPServer) -> None: client, _, _ = mcp_http_server async with _tool(client, "token-a") as tool: @@ -676,8 +1009,14 @@ async def test_cancelled_redundant_connect_keeps_existing_session(mcp_http_serve original_hooks = list(client.event_hooks["request"]) connect_on_owner = tool._connect_on_owner - async def cancel_before_delivery(*, reset: bool = False, load_configured: bool = True) -> None: - await connect_on_owner(reset=reset, load_configured=load_configured) + async def cancel_before_delivery( + *, reset: bool = False, load_configured: bool = True, reset_discovery: bool = False + ) -> None: + await connect_on_owner( + reset=reset, + load_configured=load_configured, + reset_discovery=reset_discovery, + ) asyncio.get_running_loop().call_soon(caller.cancel, None) with patch.object(tool, "_connect_on_owner", cancel_before_delivery): @@ -770,7 +1109,34 @@ async def test_seeded_connection_kwargs_authenticate_the_handshake( await tool.close() -async def test_connection_kwargs_are_fixed_for_the_connection_and_cleared_on_close( +async def test_connected_run_defers_missing_per_call_header_until_invocation( + mcp_http_server: MCPHTTPServer, +) -> None: + client, requests, _ = mcp_http_server + tool = _kwargs_dependent_tool(client) + tool._seed_connection_kwargs({"credential": "token-a"}) + try: + await tool.connect() + existing_session = tool.session + + await tool._prepare_for_run({"unrelated": "value"}) + + assert tool.is_connected + assert tool.session is existing_session + + await tool.call_tool("record", credential="token-b") + assert [request.headers["Authorization"] for request in _requests_for_method(requests, "initialize")] == [ + "token-a", + "token-b", + ] + call_request = _calls(requests)[-1] + assert call_request.headers["Authorization"] == "token-b" + assert call_request.headers["mcp-session-id"] == "session-token-b" + finally: + await tool.close() + + +async def test_connection_rebinds_to_changed_call_headers_and_clears_kwargs_on_close( mcp_http_server: MCPHTTPServer, ) -> None: client, requests, _ = mcp_http_server @@ -781,15 +1147,18 @@ async def test_connection_kwargs_are_fixed_for_the_connection_and_cleared_on_clo # A later run must not re-authenticate an already-established connection. tool._seed_connection_kwargs({"credential": "token-b"}) assert tool._connection_kwargs == {"credential": "token-a"} - # Call scope resolves independently of the connection scope. + # A call with another effective header identity establishes a matching session. await tool.call_tool("record", credential="token-c") initializes = [ request for request in requests if request.method == "POST" and json.loads(request.content).get("method") == "initialize" ] - assert [request.headers.get("Authorization") for request in initializes] == ["token-a"] - assert [request.headers.get("Authorization") for request in _calls(requests)] == ["token-c"] + assert [request.headers.get("Authorization") for request in initializes] == ["token-a", "token-c"] + assert [ + (request.headers.get("mcp-session-id"), request.headers.get("Authorization")) + for request in _calls(requests) + ] == [("session-token-c", "token-c")] assert tool._connection_kwargs is None # The credential was released on close, so an unseeded reconnect is rejected. with pytest.raises(ToolException): diff --git a/python/packages/core/tests/core/test_mcp_observability.py b/python/packages/core/tests/core/test_mcp_observability.py index 7a7c740fde6..59b4e608465 100644 --- a/python/packages/core/tests/core/test_mcp_observability.py +++ b/python/packages/core/tests/core/test_mcp_observability.py @@ -124,7 +124,9 @@ async def test_mcp_initialize_span(span_exporter: InMemorySpanExporter): with pytest.MonkeyPatch.context() as m: m.setattr(tool, "get_mcp_client", lambda: mock_transport) - async def patched_connect(self_: Any, *, reset: bool = False, load_configured: bool = True) -> None: + async def patched_connect( + self_: Any, *, reset: bool = False, load_configured: bool = True, reset_discovery: bool = False + ) -> None: # Simulate _connect_on_owner: create initialize span and call session.initialize() from agent_framework._mcp import create_mcp_client_span from agent_framework.observability import OtelAttr From 36053be91a685de24168a3f4dbbd2a6c564693e1 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 15 Sep 2026 04:09:56 +0000 Subject: [PATCH 068/108] Python: Tighten security label enforcement (#8238) * Python: tighten security label enforcement Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: add identity security setup sample Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: address security review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: refine identity policy internals Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../packages/core/agent_framework/security.py | 515 ++++++++-- .../tests/core/test_harness_tool_approval.py | 112 +++ python/packages/core/tests/test_security.py | 942 +++++++++++++++++- .../tests/hosting_a2a/test_conversion.py | 33 + .../security/FIDES_DEVELOPER_GUIDE.md | 147 ++- python/samples/02-agents/security/README.md | 20 + .../security/email_security_example.py | 10 +- .../user_identity_security_example.py | 172 ++++ 8 files changed, 1842 insertions(+), 109 deletions(-) create mode 100644 python/samples/02-agents/security/user_identity_security_example.py diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index e0e50166714..9278ff3b292 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -26,9 +26,10 @@ from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from contextvars import ContextVar, Token from copy import copy, deepcopy +from dataclasses import dataclass, replace from datetime import datetime, timedelta from enum import Enum -from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, NoReturn, cast +from typing import TYPE_CHECKING, Annotated, Any, NoReturn, cast from pydantic import BaseModel, Field @@ -51,6 +52,7 @@ from ._mcp import MCPTool __all__ = [ + "PRINCIPAL_METADATA_KEY", "SECURITY_TOOL_INSTRUCTIONS", "ConfidentialityLabel", "ContentLabel", @@ -83,10 +85,16 @@ _WHOLE_VAR_REF_RE = re.compile(rf"\s*(?:\[\s*(?P{_BRACKETED_VAR_ID})\s*\]|(?P{_BARE_VAR_ID}))\s*") _BARE_REFERENCE_WARNING = "Expanded a bare variable reference in a tool argument; models should use [var_] instead." _UNRESOLVED = object() -_AUTHORITATIVE_CONFIDENTIALITY = "_security_label_authoritative_confidentiality" +_AUTHORITATIVE_SECURITY_LABEL = "_security_label_authoritative" _INSPECT_VARIABLE_ERROR = "_inspect_variable_error" _INTERNAL_RESULT_MARKER = object() +_MAX_VARIABLE_REFERENCE_DEPTH = 16 +_MAX_VARIABLE_REFERENCE_COUNT = 100 +_LEGACY_AUTHORITATIVE_CONFIDENTIALITY = "_security_label_authoritative_confidentiality" +PRINCIPAL_METADATA_KEY = "agent_framework.security.principals" +_INTERNAL_SECURITY_TOOL = "_agent_framework_internal_security_tool" +_INTERNAL_SECURITY_TOOL_MARKER = object() # Tools that consume variable IDs literally (as opaque references) and therefore # must NOT have ``var_xxx`` arguments expanded to stored content before execution. # ``inspect_variable`` looks the ID up itself; ``quarantined_llm`` resolves the @@ -101,7 +109,48 @@ def _get_additional_properties(obj: Any) -> dict[str, Any]: return cast(dict[str, Any], props) if isinstance(props, dict) else {} -def _parse_content_label(label_data: MutableMapping[str, Any], *, source: str) -> ContentLabel: +@dataclass(frozen=True, order=True, slots=True) +class _Principal: + """Canonical tenant/user identity used internally for comparisons.""" + + tenant_id: str + user_id: str + + +def _canonical_principals(value: Any, *, source: str) -> tuple[_Principal, ...]: + """Validate and canonicalize a principal-set declaration.""" + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)) or not value: + raise ValueError(f"{source} principals must be a non-empty sequence") + + principals: set[_Principal] = set() + for item in cast(Sequence[Any], value): + if not isinstance(item, Mapping): + raise ValueError(f"{source} principals must contain mappings") + principal = cast(Mapping[str, Any], item) + if set(principal) != {"tenant_id", "user_id"}: + raise ValueError(f"{source} principal fields must be tenant_id and user_id") + tenant_id = principal.get("tenant_id") + user_id = principal.get("user_id") + if type(tenant_id) is not str or not tenant_id.strip() or type(user_id) is not str or not user_id.strip(): + raise ValueError(f"{source} principal identifiers must be non-empty strings") + principals.add(_Principal(tenant_id=tenant_id, user_id=user_id)) + return tuple(sorted(principals)) + + +def _principal_list(principals: Sequence[_Principal]) -> list[dict[str, str]]: + """Return the serialized canonical representation of a principal set.""" + return [{"tenant_id": principal.tenant_id, "user_id": principal.user_id} for principal in principals] + + +def _principal_binding_key(value: Any, *, source: str) -> str: + """Return a stable approval-binding fragment for a valid principal set.""" + principals = _principal_list(_canonical_principals(value, source=source)) + return json.dumps(principals, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _parse_content_label( + label_data: MutableMapping[str, Any], *, source: str, allow_principals: bool = False +) -> ContentLabel: """Parse explicit label fields while ignoring malformed optional metadata.""" integrity = label_data.get("integrity") confidentiality = label_data.get("confidentiality") @@ -112,10 +161,19 @@ def _parse_content_label(label_data: MutableMapping[str, Any], *, source: str) - if metadata is not None and not isinstance(metadata, dict): logger.warning("Ignoring malformed metadata from %s security label", source) metadata = {} + parsed_metadata = dict(cast(dict[str, Any], metadata)) if isinstance(metadata, dict) else {} + if not allow_principals: + parsed_metadata.pop(PRINCIPAL_METADATA_KEY, None) + elif confidentiality == ConfidentialityLabel.USER_IDENTITY.value: + parsed_metadata[PRINCIPAL_METADATA_KEY] = _principal_list( + _canonical_principals(parsed_metadata.get(PRINCIPAL_METADATA_KEY), source=source) + ) + else: + parsed_metadata.pop(PRINCIPAL_METADATA_KEY, None) return ContentLabel( integrity=IntegrityLabel(integrity), confidentiality=ConfidentialityLabel(confidentiality), - metadata=cast(dict[str, Any], metadata) if isinstance(metadata, dict) else None, + metadata=parsed_metadata or None, ) @@ -181,7 +239,11 @@ class ContentLabel(SerializationMixin): user_label = ContentLabel( integrity=IntegrityLabel.TRUSTED, confidentiality=ConfidentialityLabel.USER_IDENTITY, - metadata={"user_id": "user-123"}, + metadata={ + PRINCIPAL_METADATA_KEY: [ + {"tenant_id": "tenant-123", "user_id": "user-123"}, + ] + }, ) """ @@ -251,7 +313,8 @@ def combine_labels(*labels: ContentLabel) -> ContentLabel: The combined label will be: - UNTRUSTED if any input is UNTRUSTED - Most restrictive confidentiality level (USER_IDENTITY > PRIVATE > PUBLIC) - - Merged metadata from all labels + - The union of canonical principal sets for USER_IDENTITY labels + - Merged non-principal metadata from all labels Args: *labels: Variable number of ContentLabel instances to combine. @@ -289,11 +352,33 @@ def combine_labels(*labels: ContentLabel) -> ContentLabel: confidentiality = max((label.confidentiality for label in labels), key=lambda c: confidentiality_priority[c]) - # Merge metadata + # Preserve ordinary metadata behavior while treating identity ownership as a set. merged_metadata: dict[str, Any] = {} for label in labels: if label.metadata: - merged_metadata.update(label.metadata) + merged_metadata.update({ + key: value for key, value in label.metadata.items() if key != PRINCIPAL_METADATA_KEY + }) + + if confidentiality == ConfidentialityLabel.USER_IDENTITY: + principal_sets: list[tuple[_Principal, ...]] = [] + principals_valid = True + for label in labels: + if label.confidentiality != ConfidentialityLabel.USER_IDENTITY: + continue + try: + principal_sets.append( + _canonical_principals( + label.metadata.get(PRINCIPAL_METADATA_KEY), + source="USER_IDENTITY label", + ) + ) + except ValueError: + principals_valid = False + break + if principals_valid and principal_sets: + combined_principals = sorted({principal for principals in principal_sets for principal in principals}) + merged_metadata[PRINCIPAL_METADATA_KEY] = _principal_list(combined_principals) return ContentLabel( integrity=integrity, confidentiality=confidentiality, metadata=merged_metadata if merged_metadata else None @@ -303,6 +388,8 @@ def combine_labels(*labels: ContentLabel) -> ContentLabel: def check_confidentiality_allowed( context_label: ContentLabel, max_allowed: ConfidentialityLabel, + *, + authorized_principals: Sequence[Mapping[str, str]] | None = None, ) -> bool: """Check if writing data with context_label to a destination with max_allowed confidentiality is permitted. @@ -310,12 +397,15 @@ def check_confidentiality_allowed( cannot be written to less secure destinations. For example, it blocks PRIVATE data from being sent to PUBLIC endpoints. - The check passes if context_label.confidentiality <= max_allowed in the hierarchy: + The rank check uses this hierarchy: PUBLIC (0) < PRIVATE (1) < USER_IDENTITY (2) + USER_IDENTITY data additionally requires a valid source principal set that is a subset + of the destination's authorized principals. Args: context_label: The label tracking the confidentiality of data in the current context. max_allowed: The maximum confidentiality level accepted by the destination. + authorized_principals: Principals accepted by a USER_IDENTITY destination. Returns: True if the write is allowed, False if it would be a data exfiltration. @@ -352,7 +442,19 @@ def send_message(destination: str, message: str, context_label: ContentLabel): ConfidentialityLabel.USER_IDENTITY: 2, } - return conf_hierarchy[context_label.confidentiality] <= conf_hierarchy[max_allowed] + if conf_hierarchy[context_label.confidentiality] > conf_hierarchy[max_allowed]: + return False + if context_label.confidentiality != ConfidentialityLabel.USER_IDENTITY: + return True + + try: + source_principals = set( + _canonical_principals(context_label.metadata.get(PRINCIPAL_METADATA_KEY), source="source label") + ) + destination_principals = set(_canonical_principals(authorized_principals, source="destination")) + except ValueError: + return False + return source_principals.issubset(destination_principals) @experimental(feature_id=ExperimentalFeature.FIDES) @@ -1161,8 +1263,8 @@ class LabelTrackingFunctionMiddleware(FunctionMiddleware, _SecurityScopeBinding) +----------+------------------------------------------+----------------------------+ | Priority | Source | When used | +==========+==========================================+============================+ - | Tier 1 | Per-item embedded labels in the result | Always wins if present | - | | (additional_properties.security_label) | | + | Tier 1 | Per-item embedded labels in the result | May only restrict fallback | + | | (additional_properties.security_label) | unless framework-stamped | +----------+------------------------------------------+----------------------------+ | Tier 2 | Tool's source_integrity declaration | No embedded labels | +----------+------------------------------------------+----------------------------+ @@ -1179,10 +1281,11 @@ class LabelTrackingFunctionMiddleware(FunctionMiddleware, _SecurityScopeBinding) 1. Extracts labels from tool input arguments (tier 3 input) 2. Checks tool's source_integrity declaration (tier 2) 3. Executes the tool - 4. Checks for per-item embedded labels in the result (tier 1 — highest priority) + 4. Checks per-item embedded labels against the invocation fallback 5. Falls back to tier 2 or tier 3 when no embedded labels exist - 6. Maintains confidentiality labels based on tool declarations - 7. Automatically hides untrusted content using variable indirection + 6. Accepts complete labels only from identity-stamped framework producers + 7. Maintains confidentiality labels based on tool declarations + 8. Automatically hides untrusted content using variable indirection Attributes: default_integrity: Default integrity for tools without source_integrity declaration. @@ -1318,28 +1421,76 @@ def _extract_primary_tool_content(expanded_content: Any, *, from_quarantined_llm def _resolve_variable_references(self, value: Any) -> tuple[Any, list[ContentLabel]]: """Recursively resolve owned variable references and return their stored labels.""" labels: list[ContentLabel] = [] - return self._resolve_value(value, labels), labels + resolved = self._resolve_value( + value, + labels, + depth=0, + active_variables=set(), + reference_count=[0], + ) + return resolved, labels - def _lookup_variable(self, variable_id: str, labels: list[ContentLabel]) -> Any: + def _lookup_variable( + self, + variable_id: str, + labels: list[ContentLabel], + *, + depth: int, + active_variables: set[str], + reference_count: list[int], + ) -> Any: + if variable_id in active_variables: + raise ValueError("Variable reference cycle detected") + if depth >= _MAX_VARIABLE_REFERENCE_DEPTH: + raise ValueError("Variable reference depth limit exceeded") try: stored_content, stored_label = self.get_variable_store().retrieve(variable_id) except KeyError: return _UNRESOLVED + + reference_count[0] += 1 + if reference_count[0] > _MAX_VARIABLE_REFERENCE_COUNT: + raise ValueError("Variable reference count limit exceeded") labels.append(stored_label) metadata = self.get_variable_metadata(variable_id) - return self._extract_primary_tool_content( + expanded_content = self._extract_primary_tool_content( stored_content, from_quarantined_llm=metadata is not None and metadata.get("function_name") == "quarantined_llm", ) + active_variables.add(variable_id) + try: + return self._resolve_value( + expanded_content, + labels, + depth=depth + 1, + active_variables=active_variables, + reference_count=reference_count, + ) + finally: + active_variables.remove(variable_id) - def _resolve_string(self, value: str, labels: list[ContentLabel]) -> Any: + def _resolve_string( + self, + value: str, + labels: list[ContentLabel], + *, + depth: int, + active_variables: set[str], + reference_count: list[int], + ) -> Any: if not _EMBEDDED_VAR_REF_RE.search(value): return value whole = _WHOLE_VAR_REF_RE.fullmatch(value) if whole is not None: variable_id = whole.group("bracketed") or whole.group("bare") - resolved = self._lookup_variable(variable_id, labels) + resolved = self._lookup_variable( + variable_id, + labels, + depth=depth, + active_variables=active_variables, + reference_count=reference_count, + ) if resolved is _UNRESOLVED: return value if whole.group("bare"): @@ -1348,7 +1499,13 @@ def _resolve_string(self, value: str, labels: list[ContentLabel]) -> Any: def replace(match: re.Match[str]) -> str: variable_id = match.group("bracketed") or match.group("bare") - resolved = self._lookup_variable(variable_id, labels) + resolved = self._lookup_variable( + variable_id, + labels, + depth=depth, + active_variables=active_variables, + reference_count=reference_count, + ) if resolved is _UNRESOLVED: return match.group(0) if match.group("bare"): @@ -1357,18 +1514,59 @@ def replace(match: re.Match[str]) -> str: return _EMBEDDED_VAR_REF_RE.sub(replace, value) - def _resolve_value(self, value: Any, labels: list[ContentLabel]) -> Any: + def _resolve_value( + self, + value: Any, + labels: list[ContentLabel], + *, + depth: int, + active_variables: set[str], + reference_count: list[int], + ) -> Any: if isinstance(value, str): - return self._resolve_string(value, labels) + return self._resolve_string( + value, + labels, + depth=depth, + active_variables=active_variables, + reference_count=reference_count, + ) if isinstance(value, BaseModel): - return self._resolve_value(value.model_dump(), labels) + value = value.model_dump() if isinstance(value, dict): value_dict = cast(dict[str, Any], value) - return {key: self._resolve_value(item, labels) for key, item in value_dict.items()} + return { + key: self._resolve_value( + item, + labels, + depth=depth, + active_variables=active_variables, + reference_count=reference_count, + ) + for key, item in value_dict.items() + } if isinstance(value, list): - return [self._resolve_value(item, labels) for item in cast(list[Any], value)] + return [ + self._resolve_value( + item, + labels, + depth=depth, + active_variables=active_variables, + reference_count=reference_count, + ) + for item in cast(list[Any], value) + ] if isinstance(value, tuple): - return tuple(self._resolve_value(item, labels) for item in cast(tuple[Any, ...], value)) + return tuple( + self._resolve_value( + item, + labels, + depth=depth, + active_variables=active_variables, + reference_count=reference_count, + ) + for item in cast(tuple[Any, ...], value) + ) return value def _expand_variable_references_in_context(self, context: FunctionInvocationContext) -> list[ContentLabel]: @@ -1377,10 +1575,27 @@ def _expand_variable_references_in_context(self, context: FunctionInvocationCont return [] labels: list[ContentLabel] = [] + active_variables: set[str] = set() + reference_count = [0] if context.arguments: - context.arguments = self._resolve_value(context.arguments, labels) + context.arguments = self._resolve_value( + context.arguments, + labels, + depth=0, + active_variables=active_variables, + reference_count=reference_count, + ) if context.kwargs: - context.kwargs = cast(dict[str, Any], self._resolve_value(context.kwargs, labels)) + context.kwargs = cast( + dict[str, Any], + self._resolve_value( + context.kwargs, + labels, + depth=0, + active_variables=active_variables, + reference_count=reference_count, + ), + ) return labels def _get_input_labels(self, context: FunctionInvocationContext) -> list[ContentLabel]: @@ -1572,11 +1787,16 @@ async def process( argument_labels = [*input_labels, *resolved_labels] argument_label = combine_labels(*argument_labels) if argument_labels else ContentLabel() - # Integrity may be declared by the source, but a transformer cannot - # implicitly declassify data derived from its inputs. - result_confidentiality = combine_labels( - ContentLabel(confidentiality=confidentiality), argument_label - ).confidentiality + declared_confidentiality_label = ContentLabel( + confidentiality=confidentiality, + metadata=self._get_function_principal_metadata(context, confidentiality), + ) + result_policy_label = combine_labels(declared_confidentiality_label, argument_label) + result_confidentiality = result_policy_label.confidentiality + + fallback_metadata: dict[str, Any] = {"function_name": function_name} + if PRINCIPAL_METADATA_KEY in result_policy_label.metadata: + fallback_metadata[PRINCIPAL_METADATA_KEY] = result_policy_label.metadata[PRINCIPAL_METADATA_KEY] # Step 3: Build tiered fallback_label # This label is used for result items that have NO embedded labels. @@ -1585,20 +1805,20 @@ async def process( fallback_label = ContentLabel( integrity=declared_source_integrity, confidentiality=result_confidentiality, - metadata={"source": "source_integrity", "function_name": function_name}, + metadata={**fallback_metadata, "source": "source_integrity"}, ) elif argument_labels: combined = combine_labels(*argument_labels) fallback_label = ContentLabel( integrity=combined.integrity, confidentiality=result_confidentiality, - metadata={"source": "input_labels_join", "function_name": function_name}, + metadata={**fallback_metadata, "source": "input_labels_join"}, ) else: fallback_label = ContentLabel( integrity=self.default_integrity, confidentiality=result_confidentiality, - metadata={"source": "default", "function_name": function_name}, + metadata={**fallback_metadata, "source": "default"}, ) context_label = self._context_label @@ -1656,18 +1876,24 @@ def _label_result( # may affect integrity taint. Confidentiality still reflects the most # restrictive label across the entire tool result, including hidden items. if visible_result_label is None: - if result_label.confidentiality != self._context_label.confidentiality: + if ( + result_label.confidentiality != self._context_label.confidentiality + or result_label.confidentiality == ConfidentialityLabel.USER_IDENTITY + ): old_conf = self._context_label.confidentiality hidden_label = ContentLabel( integrity=self._context_label.integrity, confidentiality=result_label.confidentiality, + metadata=result_label.metadata, ) self._update_context_label(hidden_label) logger.info( - f"Result from '{function_name}' hidden (integrity clean) but " - f"confidentiality updated: {old_conf.value} -> " - f"{result_label.confidentiality.value}" + "Result from '%s' hidden (integrity clean) but confidentiality scope updated: %s -> %s", + function_name, + old_conf.value, + result_label.confidentiality.value, ) + logger.debug("Hidden result security metadata merged for '%s'", function_name) else: logger.info( f"Result from '{function_name}' fully hidden - context label " @@ -1680,6 +1906,7 @@ def _label_result( exposed_label = ContentLabel( integrity=visible_result_label.integrity, confidentiality=result_label.confidentiality, + metadata=result_label.metadata, ) self._update_context_label(exposed_label) logger.info( @@ -1712,6 +1939,25 @@ def _get_function_confidentiality(self, context: FunctionInvocationContext) -> C return self.default_confidentiality + def _get_function_principal_metadata( + self, + context: FunctionInvocationContext, + confidentiality: ConfidentialityLabel, + ) -> dict[str, Any]: + """Read a locally declared source principal set.""" + if confidentiality != ConfidentialityLabel.USER_IDENTITY: + return {} + function_props = _get_additional_properties(context.function) + try: + principals = _canonical_principals( + function_props.get(PRINCIPAL_METADATA_KEY), + source=f"tool {context.function.name}", + ) + except ValueError as exc: + logger.warning("Invalid USER_IDENTITY source principals for tool '%s': %s", context.function.name, exc) + return {} + return {PRINCIPAL_METADATA_KEY: _principal_list(principals)} + def _process_result_with_embedded_labels( self, items: list[Content], @@ -1720,11 +1966,12 @@ def _process_result_with_embedded_labels( ) -> tuple[list[Content], ContentLabel, ContentLabel | None]: """Process Content items, respecting per-item embedded labels. - This implements the first tier of the label propagation priority: - items with embedded labels (``additional_properties.security_label``) - use those labels directly. Items without embedded labels fall back to - ``fallback_label``, which is either the tool's ``source_integrity`` - declaration (tier 2) or the join of input argument labels (tier 3). + Generic embedded labels (``additional_properties.security_label``) can + only restrict the invocation fallback. A framework-owned producer can + identity-stamp a complete authoritative label after applying local policy. + Items without embedded labels use ``fallback_label``, which is either the + tool's ``source_integrity`` declaration (tier 2) or the join of input + argument labels (tier 3). Each item's own label is attached to its ``additional_properties`` during processing, preserving per-item granularity. @@ -1784,30 +2031,55 @@ def _extract_content_label( The resolved ContentLabel for this item. """ additional_props = _get_additional_properties(item) - authoritative_marker = additional_props.pop(_AUTHORITATIVE_CONFIDENTIALITY, None) + authoritative_marker = additional_props.pop(_AUTHORITATIVE_SECURITY_LABEL, None) + additional_props.pop(_LEGACY_AUTHORITATIVE_CONFIDENTIALITY, None) inspect_error_marker = additional_props.pop(_INSPECT_VARIABLE_ERROR, None) - authoritative_confidentiality = authoritative_marker is _INTERNAL_RESULT_MARKER + authoritative_label = authoritative_marker is _INTERNAL_RESULT_MARKER inspect_error = function_name == "inspect_variable" and inspect_error_marker is _INTERNAL_RESULT_MARKER label_data = additional_props.get("security_label") if label_data and isinstance(label_data, dict): + label_map = cast(dict[str, Any], label_data) try: embedded_label = _parse_content_label( - cast(dict[str, Any], label_data), + label_map, source="embedded", + allow_principals=authoritative_label, ) + if authoritative_label: + return embedded_label combined_label = combine_labels(fallback_label, embedded_label) - return ContentLabel( - integrity=embedded_label.integrity, - confidentiality=( - embedded_label.confidentiality - if authoritative_confidentiality - else combined_label.confidentiality - ), - metadata=combined_label.metadata, - ) + if ( + combined_label.confidentiality == ConfidentialityLabel.USER_IDENTITY + and fallback_label.confidentiality == ConfidentialityLabel.USER_IDENTITY + ): + try: + fallback_principals = _canonical_principals( + fallback_label.metadata.get(PRINCIPAL_METADATA_KEY), + source="fallback label", + ) + except ValueError: + combined_label.metadata.pop(PRINCIPAL_METADATA_KEY, None) + else: + combined_label.metadata[PRINCIPAL_METADATA_KEY] = _principal_list(fallback_principals) + return combined_label except (TypeError, ValueError) as exc: logger.warning("Failed to parse security_label from Content: %s", exc) + if authoritative_label: + integrity = IntegrityLabel.UNTRUSTED + confidentiality = fallback_label.confidentiality + raw_integrity = label_map.get("integrity") + raw_confidentiality = label_map.get("confidentiality") + if isinstance(raw_integrity, str): + with contextlib.suppress(ValueError): + integrity = IntegrityLabel(raw_integrity) + if isinstance(raw_confidentiality, str): + with contextlib.suppress(ValueError): + confidentiality = ConfidentialityLabel(raw_confidentiality) + return combine_labels( + fallback_label, + ContentLabel(integrity=integrity, confidentiality=confidentiality), + ) if inspect_error: integrity = ( @@ -1965,7 +2237,8 @@ def get_current_middleware() -> LabelTrackingFunctionMiddleware | None: return _current_middleware.get() -class _PendingPolicyApproval(NamedTuple): +@dataclass(frozen=True, slots=True) +class _PendingPolicyApproval: """Immutable binding record for a pending policy-violation approval. Captures every dimension a granted approval is bound to so a reused ``call_id`` cannot @@ -1975,8 +2248,9 @@ class _PendingPolicyApproval(NamedTuple): approval granted while a placeholder resolved to one payload cannot authorize a replay in which it resolves to something else (or no longer resolves at all); ``label_key`` the conversation label shown for review and ``effective_label_key`` the label of everything the - invocation acts on, including hidden arguments; ``session_key`` the session the approval was - requested in; and ``disclosed_violations`` the canonical risks shown to the user. + invocation acts on, including hidden arguments; ``destination_principal_key`` the locally + declared recipients; ``session_key`` the session the approval was requested in; and + ``disclosed_violations`` the canonical risks shown to the user. ``created_at`` is a wall-clock timestamp so TTL expiration survives session serialization and process restarts. Records remain isolated in the session-scoped security state. """ @@ -1985,6 +2259,7 @@ class _PendingPolicyApproval(NamedTuple): resolved_signature: str label_key: str effective_label_key: str + destination_principal_key: str session_key: str disclosed_violations: tuple[str, ...] request_id: str @@ -1996,21 +2271,23 @@ def to_state(self) -> dict[str, Any]: "resolved_signature": self.resolved_signature, "label_key": self.label_key, "effective_label_key": self.effective_label_key, + "destination_principal_key": self.destination_principal_key, "session_key": self.session_key, "disclosed_violations": list(self.disclosed_violations), "request_id": self.request_id, "created_at": self.created_at, } - def binding_key(self) -> tuple[str, str, str, str, str, tuple[str, ...]]: - """Return every authorization dimension except lifecycle metadata.""" + def matches_binding(self, other: _PendingPolicyApproval) -> bool: + """Return whether another record represents the same reviewed authorization.""" return ( - self.body_signature, - self.resolved_signature, - self.label_key, - self.effective_label_key, - self.session_key, - self.disclosed_violations, + self.body_signature == other.body_signature + and self.resolved_signature == other.resolved_signature + and self.label_key == other.label_key + and self.effective_label_key == other.effective_label_key + and self.destination_principal_key == other.destination_principal_key + and self.session_key == other.session_key + and self.disclosed_violations == other.disclosed_violations ) @classmethod @@ -2025,6 +2302,7 @@ def from_state(cls, payload: Any) -> _PendingPolicyApproval | None: record["resolved_signature"], record["label_key"], record["effective_label_key"], + record["destination_principal_key"], record["session_key"], ) violations = record["disclosed_violations"] @@ -2043,13 +2321,14 @@ def from_state(cls, payload: Any) -> _PendingPolicyApproval | None: return None if type(created_at) not in (int, float) or not math.isfinite(created_at): return None - typed_values = cast(tuple[str, str, str, str, str], values) + typed_values = cast(tuple[str, str, str, str, str, str], values) return cls( body_signature=typed_values[0], resolved_signature=typed_values[1], label_key=typed_values[2], effective_label_key=typed_values[3], - session_key=typed_values[4], + destination_principal_key=typed_values[4], + session_key=typed_values[5], disclosed_violations=tuple(cast(list[str], violation_items)), request_id=request_id, created_at=float(created_at), @@ -2252,12 +2531,31 @@ def _effective_label_key(self, context: FunctionInvocationContext) -> str: @staticmethod def _label_key(label_data: Any) -> str: if isinstance(label_data, ContentLabel): - return f"{label_data.integrity.value}/{label_data.confidentiality.value}" + principal_key = ( + _principal_binding_key(label_data.metadata.get(PRINCIPAL_METADATA_KEY), source="approval label") + if label_data.confidentiality == ConfidentialityLabel.USER_IDENTITY + else "" + ) + return f"{label_data.integrity.value}/{label_data.confidentiality.value}/{principal_key}" if isinstance(label_data, dict): label_dict = cast(dict[str, Any], label_data) - return f"{label_dict.get('integrity', '')}/{label_dict.get('confidentiality', '')}" + confidentiality = label_dict.get("confidentiality", "") + metadata = label_dict.get("metadata") + principal_key = "" + if confidentiality == ConfidentialityLabel.USER_IDENTITY.value: + principal_value = ( + cast(dict[str, Any], metadata).get(PRINCIPAL_METADATA_KEY) if isinstance(metadata, dict) else None + ) + principal_key = _principal_binding_key(principal_value, source="approval label") + return f"{label_dict.get('integrity', '')}/{confidentiality}/{principal_key}" return "/" + def _destination_principal_key(self, context: FunctionInvocationContext) -> str: + function_props = _get_additional_properties(context.function) + if function_props.get("max_allowed_confidentiality") != ConfidentialityLabel.USER_IDENTITY.value: + return "" + return _principal_binding_key(function_props.get(PRINCIPAL_METADATA_KEY), source="approval destination") + def _session_key(self, context: FunctionInvocationContext) -> str: return context.session.session_id if context.session is not None else "" @@ -2274,6 +2572,7 @@ def _pending_record( resolved_signature=self._resolved_call_signature(context), label_key=self._context_label_key(context), effective_label_key=self._effective_label_key(context), + destination_principal_key=self._destination_principal_key(context), session_key=self._session_key(context), disclosed_violations=self._violation_set_key(violations), request_id=self._get_approval_id(context), @@ -2325,7 +2624,7 @@ def _matches_pending_approval( and approval_response.approved is True ): return False - return current_binding.binding_key() == pending.binding_key() and self._response_matches_pending( + return current_binding.matches_binding(pending) and self._response_matches_pending( approval_response, approval_id, call_id, pending.body_signature, pending.request_id ) @@ -2399,7 +2698,7 @@ def _request_policy_violation_approval( ) request_id = f"{approval_id}:replacement:{uuid.uuid4().hex}" if is_replacement else approval_id if approval_id: - self._store_pending_approval(approval_id, binding._replace(request_id=request_id)) + self._store_pending_approval(approval_id, replace(binding, request_id=request_id)) additional_properties: dict[str, Any] = { _APPROVAL_REQUEST_ID_KEY: request_id, "_replacement_approval_request": is_replacement, @@ -2493,6 +2792,7 @@ async def _process_in_scope( argument_label = self._resolve_label(context.metadata.get("argument_label")) effective_label = combine_labels(context_label, argument_label) + context.metadata["effective_invocation_label"] = effective_label function_props = _get_additional_properties(context.function) accepts_untrusted = ( function_name in self.allow_untrusted_tools or function_props.get("accepts_untrusted") is True @@ -2669,6 +2969,48 @@ def _check_confidentiality_policy_detailed( } except ValueError: logger.warning(f"Invalid max_allowed_confidentiality: {max_allowed_conf}") + if label.confidentiality == ConfidentialityLabel.USER_IDENTITY: + return { + "passed": False, + "failure_type": "principal_mismatch", + "reason": "USER_IDENTITY destination policy is invalid", + } + + if label.confidentiality == ConfidentialityLabel.USER_IDENTITY: + try: + source_principals = set( + _canonical_principals( + label.metadata.get(PRINCIPAL_METADATA_KEY), + source="source label", + ) + ) + if function_props.get(_INTERNAL_SECURITY_TOOL) is _INTERNAL_SECURITY_TOOL_MARKER: + destination_principals = source_principals + else: + if max_allowed_conf != ConfidentialityLabel.USER_IDENTITY.value: + return { + "passed": False, + "failure_type": "principal_mismatch", + "reason": "USER_IDENTITY destination does not declare an authorized principal set", + } + destination_principals = set( + _canonical_principals( + function_props.get(PRINCIPAL_METADATA_KEY), + source=f"tool {context.function.name}", + ) + ) + except ValueError: + return { + "passed": False, + "failure_type": "principal_mismatch", + "reason": "USER_IDENTITY source or destination principals are missing or invalid", + } + if not source_principals.issubset(destination_principals): + return { + "passed": False, + "failure_type": "principal_mismatch", + "reason": "USER_IDENTITY source principals are not authorized for the destination", + } return {"passed": True, "failure_type": None, "reason": None} @@ -3133,24 +3475,31 @@ def _quarantined_llm_result_parser(result: Any) -> list[Content]: logger.warning("quarantined_llm result label is missing confidentiality") return contents + parsed_metadata: dict[str, Any] = {} try: parsed_label = _parse_content_label( typed_label_data, source="quarantined_llm result", + allow_principals=True, ) + parsed_confidentiality = parsed_label.confidentiality + parsed_metadata = parsed_label.metadata except (TypeError, ValueError) as exc: logger.warning("Failed to parse quarantined_llm result label: %s", exc) - return contents + try: + parsed_confidentiality = ConfidentialityLabel(confidentiality) + except ValueError: + parsed_confidentiality = ConfidentialityLabel.PRIVATE quarantine_label = ContentLabel( integrity=IntegrityLabel.UNTRUSTED, - confidentiality=parsed_label.confidentiality, - metadata=parsed_label.metadata, + confidentiality=parsed_confidentiality, + metadata=parsed_metadata, ) first = contents[0] props = first.additional_properties or {} props["security_label"] = quarantine_label.to_dict() - props[_AUTHORITATIVE_CONFIDENTIALITY] = _INTERNAL_RESULT_MARKER + props[_AUTHORITATIVE_SECURITY_LABEL] = _INTERNAL_RESULT_MARKER first.additional_properties = props return contents @@ -3169,6 +3518,7 @@ def _quarantined_llm_result_parser(result: Any) -> list[Content]: "confidentiality": "private", "accepts_untrusted": True, "source_integrity": "untrusted", + _INTERNAL_SECURITY_TOOL: _INTERNAL_SECURITY_TOOL_MARKER, # source_integrity is declared as UNTRUSTED because this tool # processes external/untrusted data. The middleware uses this # (Tier 2) to label the output UNTRUSTED and auto-hide it via @@ -3440,6 +3790,7 @@ def _inspect_variable_result_parser(result: Any) -> list[Content]: label = cast(dict[str, Any], result).get("security_label") if isinstance(result, dict) else None if label: props["security_label"] = label + props[_AUTHORITATIVE_SECURITY_LABEL] = _INTERNAL_RESULT_MARKER if isinstance(result, dict) and "error" in cast(dict[str, Any], result): props[_INSPECT_VARIABLE_ERROR] = _INTERNAL_RESULT_MARKER first.additional_properties = props @@ -3457,6 +3808,7 @@ def _inspect_variable_result_parser(result: Any) -> list[Content]: result_parser=_inspect_variable_result_parser, additional_properties={ "confidentiality": _INSPECT_VARIABLE_CONFIDENTIALITY.value, + _INTERNAL_SECURITY_TOOL: _INTERNAL_SECURITY_TOOL_MARKER, # No source_integrity declared: output inherits the label of the # inspected content via Tier 3. The variable store is just a # container — the data inside it is untrusted external content. @@ -3886,14 +4238,15 @@ def _stamp_mcp_content_labels( if not isinstance(item, Content): continue props = item.additional_properties or {} - props.pop(_AUTHORITATIVE_CONFIDENTIALITY, None) + props.pop(_AUTHORITATIVE_SECURITY_LABEL, None) + props.pop(_LEGACY_AUTHORITATIVE_CONFIDENTIALITY, None) server_meta = props.pop(_MCP_RESULT_META_KEY, None) dynamic = _label_from_mcp_meta(server_meta) if server_meta else None if dynamic is None: label = local_label elif trust_server_ifc: label = dynamic - props[_AUTHORITATIVE_CONFIDENTIALITY] = _INTERNAL_RESULT_MARKER + props[_AUTHORITATIVE_SECURITY_LABEL] = _INTERNAL_RESULT_MARKER else: label = combine_labels(local_label, dynamic) props["security_label"] = label.to_dict() diff --git a/python/packages/core/tests/core/test_harness_tool_approval.py b/python/packages/core/tests/core/test_harness_tool_approval.py index c36eacc1855..7f6d61d8056 100644 --- a/python/packages/core/tests/core/test_harness_tool_approval.py +++ b/python/packages/core/tests/core/test_harness_tool_approval.py @@ -35,6 +35,8 @@ ) from agent_framework._feature_stage import ExperimentalWarning from agent_framework.security import ( + PRINCIPAL_METADATA_KEY, + ConfidentialityLabel, ContentLabel, IntegrityLabel, LabelTrackingFunctionMiddleware, @@ -494,6 +496,116 @@ async def capture_response( assert not any(content.type.startswith("function_approval_") for content in model_contents) +@pytest.mark.parametrize("streaming", [False, True], ids=["non-streaming", "streaming"]) +async def test_principal_change_requires_visible_second_approval( + chat_client_base: MockBaseChatClient, + streaming: bool, +) -> None: + """A principal change persists one replacement request before execution.""" + received: list[str] = [] + + def principals(user_id: str) -> list[dict[str, str]]: + return [{"tenant_id": "tenant-a", "user_id": user_id}] + + @tool( + name="identity_sink", + additional_properties={ + "max_allowed_confidentiality": "user_identity", + PRINCIPAL_METADATA_KEY: principals("user-c"), + }, + ) + def identity_sink(value: str) -> str: + received.append(value) + return "sent" + + tracker = LabelTrackingFunctionMiddleware() + policy = PolicyEnforcementFunctionMiddleware(approval_on_violation=True) + session = AgentSession(session_id=f"principal-change-{streaming}") + tracker._scope_for_session(session).context_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata={PRINCIPAL_METADATA_KEY: principals("user-a")}, + ) + agent = Agent( + client=chat_client_base, + tools=[identity_sink], + middleware=[tracker, policy], + context_providers=[InMemoryHistoryProvider()], + ) + function_call = Content.from_function_call( + call_id="principal-call", + name="identity_sink", + arguments={"value": "payload"}, + id="principal-occurrence", + ) + + if streaming: + chat_client_base.streaming_responses = [ + [ChatResponseUpdate(role="assistant", contents=[function_call])], + [ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")])], + ] + first_stream = agent.run("send identity data", stream=True, session=session) + _ = [update async for update in first_stream] + first = await first_stream.get_final_response() + else: + chat_client_base.run_responses = [ + ChatResponse(messages=Message(role="assistant", contents=[function_call])), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + first = await agent.run("send identity data", session=session) + + original_request = first.user_input_requests[0] + tracker._scope_for_session(session).context_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata={PRINCIPAL_METADATA_KEY: principals("user-b")}, + ) + + if streaming: + stale_stream = agent.run( + original_request.to_function_approval_response(True), + stream=True, + session=session, + ) + stale_updates = [update async for update in stale_stream] + stale = await stale_stream.get_final_response() + assert [(update.role, [content.type for content in update.contents]) for update in stale_updates] == [ + ("assistant", ["function_approval_request"]), + ] + else: + stale = await agent.run(original_request.to_function_approval_response(True), session=session) + + assert received == [] + assert chat_client_base.call_count == 1 + replacement = stale.user_input_requests[0] + assert replacement.id != original_request.id + assert replacement.function_call is not None + assert original_request.function_call is not None + assert replacement.function_call.id == original_request.function_call.id + pending = session.state["tool_approval"]["pending_approval_requests"] + assert [snapshot["id"] for snapshot in pending] == [replacement.id] + + if streaming: + approved_stream = agent.run( + replacement.to_function_approval_response(True), + stream=True, + session=session, + ) + approved_updates = [update async for update in approved_stream] + approved = await approved_stream.get_final_response() + assert [(update.role, [content.type for content in update.contents]) for update in approved_updates] == [ + ("tool", ["function_result"]), + ("assistant", ["text"]), + ] + else: + approved = await agent.run(replacement.to_function_approval_response(True), session=session) + + assert received == ["payload"] + assert chat_client_base.call_count == 2 + assert [(message.role, [content.type for content in message.contents]) for message in approved.messages] == [ + ("tool", ["function_result"]), + ("assistant", ["text"]), + ] + + async def test_replacement_approval_preserves_unanswered_reused_call_id_sibling( chat_client_base: MockBaseChatClient, ) -> None: diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 51d9421b01a..6813e172eae 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -7,7 +7,7 @@ import logging import math from datetime import timedelta -from types import SimpleNamespace +from types import MappingProxyType, SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock @@ -49,6 +49,38 @@ store_untrusted_content, ) +_PRINCIPALS_KEY = "agent_framework.security.principals" +_AUTHORITY_MARKER_KEY = "_security_label_authoritative" + + +def _principal_metadata(user_id: str, tenant_id: str = "tenant-a") -> dict[str, Any]: + return { + _PRINCIPALS_KEY: [ + {"tenant_id": tenant_id, "user_id": user_id}, + ] + } + + +def _identity_destination(principals: Any | None) -> FunctionTool: + class DestinationArgs(BaseModel): + value: str = "value" + + async def destination(value: str = "value") -> str: + return value + + additional_properties: dict[str, Any] = { + "max_allowed_confidentiality": ConfidentialityLabel.USER_IDENTITY.value, + } + if principals is not None: + additional_properties[_PRINCIPALS_KEY] = principals + return FunctionTool( + fn=destination, + name="identity_destination", + description="Identity-scoped destination", + args_schema=DestinationArgs, + additional_properties=additional_properties, + ) + class TestContentLabel: """Tests for ContentLabel class.""" @@ -164,6 +196,23 @@ def test_combine_metadata_merged(self): assert result.metadata["key1"] == "value1" assert result.metadata["key2"] == "value2" + def test_combine_user_identity_principals_uses_set_union(self) -> None: + label_a = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + label_b = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-b"), + ) + + result = combine_labels(label_a, label_b) + + assert result.metadata[_PRINCIPALS_KEY] == [ + {"tenant_id": "tenant-a", "user_id": "user-a"}, + {"tenant_id": "tenant-a", "user_id": "user-b"}, + ] + class TestContentVariableStore: """Tests for ContentVariableStore.""" @@ -299,6 +348,21 @@ def test_store_default_label(self): class TestLabelTrackingMiddleware: """Tests for LabelTrackingFunctionMiddleware.""" + @staticmethod + def _variable_sink() -> FunctionTool: + class SinkArgs(BaseModel): + payload: Any + + async def sink(payload: Any) -> Any: + return payload + + return FunctionTool( + fn=sink, + name="variable_sink", + description="Receive expanded variables", + args_schema=SinkArgs, + ) + @pytest.fixture def middleware(self): """Create middleware instance.""" @@ -361,6 +425,38 @@ async def next_fn(): label = context.metadata["result_label"] assert label.integrity == IntegrityLabel.TRUSTED + async def test_local_source_declaration_preserves_user_identity_principal(self, middleware) -> None: + class SourceArgs(BaseModel): + pass + + async def source() -> str: + return "identity data" + + function = FunctionTool( + fn=source, + name="identity_source", + description="Locally declared identity source", + args_schema=SourceArgs, + additional_properties={ + "source_integrity": "trusted", + "confidentiality": "user_identity", + _PRINCIPALS_KEY: _principal_metadata("user-a")[_PRINCIPALS_KEY], + }, + ) + context = FunctionInvocationContext(function=function, arguments={}) + + async def next_fn() -> None: + context.result = [Content.from_text("identity data")] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].metadata[_PRINCIPALS_KEY] == [ + {"tenant_id": "tenant-a", "user_id": "user-a"} + ] + assert middleware.get_context_label().metadata[_PRINCIPALS_KEY] == [ + {"tenant_id": "tenant-a", "user_id": "user-a"} + ] + @pytest.mark.asyncio async def test_tool_without_source_integrity_defaults_untrusted(self, middleware, mock_function): """Test that tools without source_integrity declaration default to UNTRUSTED.""" @@ -498,6 +594,119 @@ async def next_fn() -> None: await middleware.process(context, next_fn) + async def test_transitive_variable_reference_expands_and_collects_all_labels(self, middleware) -> None: + store = middleware.get_variable_store() + leaf_id = store.store( + "expanded value", + ContentLabel(integrity=IntegrityLabel.UNTRUSTED), + ) + root_id = store.store( + f"[{leaf_id}]", + ContentLabel(integrity=IntegrityLabel.TRUSTED), + ) + function = self._variable_sink() + context = FunctionInvocationContext(function=function, arguments={"payload": f"[{root_id}]"}) + + async def next_fn() -> None: + assert context.arguments == {"payload": "expanded value"} + assert context.metadata["argument_label"].integrity == IntegrityLabel.UNTRUSTED + context.result = [Content.from_text("done")] + + await middleware.process(context, next_fn) + + async def test_three_level_variable_reference_chain_expands(self, middleware) -> None: + store = middleware.get_variable_store() + third_id = store.store("third", ContentLabel(integrity=IntegrityLabel.TRUSTED)) + second_id = store.store(f"[{third_id}]", ContentLabel(integrity=IntegrityLabel.TRUSTED)) + first_id = store.store(f"[{second_id}]", ContentLabel(integrity=IntegrityLabel.TRUSTED)) + function = self._variable_sink() + context = FunctionInvocationContext(function=function, arguments={"payload": f"[{first_id}]"}) + + async def next_fn() -> None: + assert context.arguments == {"payload": "third"} + context.result = [Content.from_text("done")] + + await middleware.process(context, next_fn) + + @pytest.mark.parametrize("indirect", [False, True], ids=["direct", "indirect"]) + async def test_variable_reference_cycle_fails_before_tool_execution(self, middleware, indirect: bool) -> None: + store = middleware.get_variable_store() + first_id = store.store("", ContentLabel(integrity=IntegrityLabel.UNTRUSTED)) + first_entry = cast(dict[str, Any], middleware._scope.variables[first_id]) + if indirect: + second_id = store.store("", ContentLabel(integrity=IntegrityLabel.UNTRUSTED)) + second_entry = cast(dict[str, Any], middleware._scope.variables[second_id]) + first_entry["content"] = f"[{second_id}]" + second_entry["content"] = f"[{first_id}]" + else: + first_entry["content"] = f"[{first_id}]" + + function = self._variable_sink() + context = FunctionInvocationContext(function=function, arguments={"payload": f"[{first_id}]"}) + + async def next_fn() -> None: + pytest.fail("A cyclic variable graph must not execute the destination tool") + + with pytest.raises(ValueError, match="cycle"): + await middleware.process(context, next_fn) + + async def test_nested_mixed_trust_references_expand_and_taint_arguments(self, middleware) -> None: + store = middleware.get_variable_store() + trusted_id = store.store("trusted", ContentLabel(integrity=IntegrityLabel.TRUSTED)) + untrusted_id = store.store("untrusted", ContentLabel(integrity=IntegrityLabel.UNTRUSTED)) + function = self._variable_sink() + context = FunctionInvocationContext( + function=function, + arguments={ + "payload": { + "trusted": f"[{trusted_id}]", + "nested": [f"[{untrusted_id}]", (f"[{trusted_id}]",)], + } + }, + ) + + async def next_fn() -> None: + assert context.arguments == { + "payload": { + "trusted": "trusted", + "nested": ["untrusted", ("trusted",)], + } + } + assert context.metadata["argument_label"].integrity == IntegrityLabel.UNTRUSTED + context.result = [Content.from_text("done")] + + await middleware.process(context, next_fn) + + async def test_variable_reference_depth_limit_fails_before_tool_execution(self, middleware) -> None: + store = middleware.get_variable_store() + value = "leaf" + for _ in range(17): + variable_id = store.store(value, ContentLabel(integrity=IntegrityLabel.TRUSTED)) + value = f"[{variable_id}]" + + function = self._variable_sink() + context = FunctionInvocationContext(function=function, arguments={"payload": value}) + + async def next_fn() -> None: + pytest.fail("An over-depth variable graph must not execute the destination tool") + + with pytest.raises(ValueError, match="depth"): + await middleware.process(context, next_fn) + + async def test_variable_reference_count_limit_fails_before_tool_execution(self, middleware) -> None: + store = middleware.get_variable_store() + references = [ + f"[{store.store(str(index), ContentLabel(integrity=IntegrityLabel.TRUSTED))}]" for index in range(101) + ] + function = self._variable_sink() + context = FunctionInvocationContext(function=function, arguments={"payload": references}) + + async def next_fn() -> None: + pytest.fail("An over-limit variable graph must not execute the destination tool") + + with pytest.raises(ValueError, match="count"): + await middleware.process(context, next_fn) + @pytest.mark.asyncio async def test_json_string_variable_reference_expands_only_response_before_call_next(self, middleware): """JSON-serialized hidden payloads should expose only the response text to tools.""" @@ -1482,7 +1691,9 @@ async def stop_before_execute() -> None: arguments=mock_function.args_schema(arg="test"), ) escalated_context.metadata["context_label"] = ContentLabel( - integrity=IntegrityLabel.UNTRUSTED, confidentiality=ConfidentialityLabel.USER_IDENTITY + integrity=IntegrityLabel.UNTRUSTED, + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), ) escalated_context.metadata["call_id"] = "call-label" escalated_context.metadata["approval_response"] = approval_request.to_function_approval_response(True) @@ -1737,6 +1948,7 @@ async def test_replay_same_violation_type_worse_risk_requires_fresh_approval(sel label = ContentLabel( integrity=IntegrityLabel.TRUSTED, confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), ) request_context = FunctionInvocationContext( function=mock_function, @@ -1822,6 +2034,179 @@ async def execute() -> None: assert isinstance(replay_context.result, Content) assert replay_context.result.type == "function_approval_request" + async def test_approval_for_one_principal_cannot_authorize_another(self) -> None: + middleware = PolicyEnforcementFunctionMiddleware(approval_on_violation=True) + function = _identity_destination(_principal_metadata("user-c")[_PRINCIPALS_KEY]) + request_context = FunctionInvocationContext( + function=function, + arguments=function.args_schema(value="value"), # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ) + request_context.metadata["context_label"] = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + request_context.metadata["call_id"] = "principal-approval" + + async def stop_before_approval() -> None: + pytest.fail("A mismatched principal flow must require approval") + + with pytest.raises(MiddlewareTermination): + await middleware.process(request_context, stop_before_approval) + + approval_request = request_context.result + assert isinstance(approval_request, Content) + assert approval_request.type == "function_approval_request" + + replay_context = FunctionInvocationContext( + function=function, + arguments=function.args_schema(value="value"), # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ) + replay_context.metadata["context_label"] = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-b"), + ) + replay_context.metadata["call_id"] = "principal-approval" + replay_context.metadata["approval_response"] = approval_request.to_function_approval_response(True) + executed = False + + async def execute() -> None: + nonlocal executed + executed = True + + with pytest.raises(MiddlewareTermination): + await middleware.process(replay_context, execute) + + assert executed is False + assert isinstance(replay_context.result, Content) + assert replay_context.result.type == "function_approval_request" + assert replay_context.result.additional_properties["_replacement_approval_request"] is True + + async def test_approval_is_bound_to_destination_principals(self) -> None: + middleware = PolicyEnforcementFunctionMiddleware(approval_on_violation=True) + function = _identity_destination(_principal_metadata("user-b")[_PRINCIPALS_KEY]) + source_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + request_context = FunctionInvocationContext( + function=function, + arguments=function.args_schema(value="value"), # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ) + request_context.metadata["context_label"] = source_label + request_context.metadata["call_id"] = "destination-principal-approval" + + async def stop_before_approval() -> None: + pytest.fail("A mismatched principal flow must require approval") + + with pytest.raises(MiddlewareTermination): + await middleware.process(request_context, stop_before_approval) + + approval_request = request_context.result + assert isinstance(approval_request, Content) + assert function.additional_properties is not None + function.additional_properties[_PRINCIPALS_KEY] = _principal_metadata("user-c")[_PRINCIPALS_KEY] + + replay_context = FunctionInvocationContext( + function=function, + arguments=function.args_schema(value="value"), # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ) + replay_context.metadata["context_label"] = source_label + replay_context.metadata["call_id"] = "destination-principal-approval" + replay_context.metadata["approval_response"] = approval_request.to_function_approval_response(True) + executed = False + + async def execute() -> None: + nonlocal executed + executed = True + + with pytest.raises(MiddlewareTermination): + await middleware.process(replay_context, execute) + + assert executed is False + assert isinstance(replay_context.result, Content) + assert replay_context.result.type == "function_approval_request" + assert replay_context.result.additional_properties["_replacement_approval_request"] is True + + @pytest.mark.parametrize("malformed_source", [True, False], ids=["source", "destination"]) + async def test_malformed_principals_cannot_create_approval_authority(self, malformed_source: bool) -> None: + middleware = PolicyEnforcementFunctionMiddleware(approval_on_violation=True) + destination_principals: Any = ( + _principal_metadata("user-b")[_PRINCIPALS_KEY] + if malformed_source + else {"tenant_id": "tenant-a", "user_id": "user-b"} + ) + source_metadata = {} if malformed_source else _principal_metadata("user-a") + function = _identity_destination(destination_principals) + context = FunctionInvocationContext( + function=function, + arguments=function.args_schema(value="value"), # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ) + context.metadata["context_label"] = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=source_metadata, + ) + context.metadata["call_id"] = "invalid-principal-approval" + + async def execute() -> None: + pytest.fail("Malformed principal state must not create approval authority") + + with pytest.raises(MiddlewareTermination): + await middleware.process(context, execute) + + assert isinstance(context.result, dict) + assert context.result["violation_type"] == "unsafe_approval_binding" + + async def test_approval_binds_computed_argument_principals_without_label_tracker(self) -> None: + middleware = PolicyEnforcementFunctionMiddleware(approval_on_violation=True) + function = _identity_destination(_principal_metadata("user-c")[_PRINCIPALS_KEY]) + request_context = FunctionInvocationContext( + function=function, + arguments=function.args_schema(value="value"), # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ) + request_context.metadata.update({ + "context_label": ContentLabel(), + "argument_label": ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ), + "call_id": "argument-principal-approval", + }) + + async def stop_before_approval() -> None: + pytest.fail("A mismatched principal flow must require approval") + + with pytest.raises(MiddlewareTermination): + await middleware.process(request_context, stop_before_approval) + + approval_request = request_context.result + assert isinstance(approval_request, Content) + + replay_context = FunctionInvocationContext( + function=function, + arguments=function.args_schema(value="value"), # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ) + replay_context.metadata.update({ + "context_label": ContentLabel(), + "argument_label": ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-b"), + ), + "call_id": "argument-principal-approval", + "approval_response": approval_request.to_function_approval_response(True), + }) + executed = False + + async def execute() -> None: + nonlocal executed + executed = True + + with pytest.raises(MiddlewareTermination): + await middleware.process(replay_context, execute) + + assert executed is False + assert isinstance(replay_context.result, Content) + assert replay_context.result.additional_properties["_replacement_approval_request"] is True + class TestAutomaticHiding: """Tests for automatic variable hiding functionality.""" @@ -2106,7 +2491,7 @@ async def test_inspect_variable_propagates_user_identity(self, middleware_no_aut ContentLabel( integrity=IntegrityLabel.UNTRUSTED, confidentiality=ConfidentialityLabel.USER_IDENTITY, - metadata={"user_id": "user-123"}, + metadata=_principal_metadata("user-123"), ), ) @@ -2450,6 +2835,81 @@ def get_middleware(self) -> list[FunctionMiddleware]: assert scoped_policy is not config.policy_enforcer assert "post_init_tool" in scoped_policy.allow_untrusted_tools + @pytest.mark.parametrize("tool_name", ["quarantined_llm", "inspect_variable"]) + async def test_provider_security_tools_are_bound_to_active_identity_principals(self, tool_name: str) -> None: + """Framework security tools may process identity data only in their owning scope.""" + from agent_framework.security import set_quarantine_client + + set_quarantine_client(None) + config = SecureAgentConfig() + session = AgentSession(session_id=f"identity-{tool_name}") + tracker, policy = await _get_session_security_middleware(config, session) + identity_label = ContentLabel( + integrity=IntegrityLabel.UNTRUSTED, + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + tracker._context_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + variable_id = tracker.get_variable_store().store("identity data", identity_label) + security_tool = next(tool for tool in config.get_tools() if tool.name == tool_name) + arguments = ( + {"prompt": "Summarize", "variable_ids": [variable_id]} + if tool_name == "quarantined_llm" + else {"variable_id": variable_id, "reason": "identity-scoped inspection"} + ) + context = FunctionInvocationContext(function=security_tool, arguments=arguments, session=session) + + async def execute(current: FunctionInvocationContext) -> list[Content]: + return await security_tool.invoke(arguments=current.arguments, context=current) + + await FunctionMiddlewarePipeline(tracker, policy).execute(context, execute) + + assert isinstance(context.result, list) + assert config.get_audit_log(session) == [] + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.USER_IDENTITY + assert ( + context.metadata["result_label"].metadata[_PRINCIPALS_KEY] == _principal_metadata("user-a")[_PRINCIPALS_KEY] + ) + + @pytest.mark.parametrize("forged_marker", [True, "framework"], ids=["boolean", "string"]) + async def test_same_named_tool_cannot_forge_internal_identity_authority(self, forged_marker: bool | str) -> None: + config = SecureAgentConfig() + session = AgentSession(session_id=f"forged-security-tool-{type(forged_marker).__name__}") + tracker, policy = await _get_session_security_middleware(config, session) + tracker._context_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + + async def forged_inspect_variable() -> str: + return "forged" + + forged_tool = FunctionTool( + fn=forged_inspect_variable, + name="inspect_variable", + description="Same-named custom tool", + additional_properties={ + "_agent_framework_internal_security_tool": forged_marker, + "accepts_untrusted": True, + }, + ) + context = FunctionInvocationContext(function=forged_tool, arguments={}, session=session) + executed = False + + async def execute(_: FunctionInvocationContext) -> list[Content]: + nonlocal executed + executed = True + return [Content.from_text("forged")] + + with pytest.raises(MiddlewareTermination): + await FunctionMiddlewarePipeline(tracker, policy).execute(context, execute) + + assert executed is False + assert context.result["violation_type"] == "principal_mismatch" + async def test_provider_use_requires_session_for_state_accessors(self) -> None: """No-session access remains standalone-only and becomes explicit after provider use.""" config = SecureAgentConfig() @@ -3319,6 +3779,7 @@ async def test_quarantined_llm_publishes_combined_confidentiality(self) -> None: ContentLabel( integrity=IntegrityLabel.TRUSTED, confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), ), ) quarantine_tool = next(tool for tool in middleware.get_security_tools() if tool.name == "quarantined_llm") @@ -3341,10 +3802,35 @@ async def next_fn() -> None: assert hidden_label.integrity == IntegrityLabel.UNTRUSTED assert hidden_label.confidentiality == ConfidentialityLabel.USER_IDENTITY - async def test_quarantined_llm_public_input_remains_public(self) -> None: - """A valid quarantine label overrides the fail-closed PRIVATE fallback.""" - from agent_framework.security import set_quarantine_client - + async def test_quarantined_llm_preserves_user_identity_when_principals_are_missing(self) -> None: + middleware = LabelTrackingFunctionMiddleware() + variable_id = middleware.get_variable_store().store( + "identity secret", + ContentLabel( + integrity=IntegrityLabel.UNTRUSTED, + confidentiality=ConfidentialityLabel.USER_IDENTITY, + ), + ) + quarantine_tool = next(tool for tool in middleware.get_security_tools() if tool.name == "quarantined_llm") + context = FunctionInvocationContext( + function=quarantine_tool, + arguments={"prompt": "Summarize", "variable_ids": [variable_id]}, + ) + + async def next_fn() -> None: + context.result = await quarantine_tool.invoke(arguments=context.arguments, context=context) + + await middleware.process(context, next_fn) + + result_label = context.metadata["result_label"] + assert result_label.integrity == IntegrityLabel.UNTRUSTED + assert result_label.confidentiality == ConfidentialityLabel.USER_IDENTITY + assert _PRINCIPALS_KEY not in result_label.metadata + + async def test_quarantined_llm_public_input_remains_public(self) -> None: + """A valid quarantine label overrides the fail-closed PRIVATE fallback.""" + from agent_framework.security import set_quarantine_client + set_quarantine_client(None) middleware = LabelTrackingFunctionMiddleware() quarantine_tool = next(tool for tool in middleware.get_security_tools() if tool.name == "quarantined_llm") @@ -3887,9 +4373,167 @@ async def mock_fn() -> list: return FunctionTool(fn=mock_fn, name="fetch_items", description="Fetch items", args_schema=MockArgs) + async def test_untrusted_fallback_cannot_be_upgraded_by_embedded_label(self, middleware, mock_function) -> None: + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "remote result", + additional_properties={ + "security_label": {"integrity": "trusted", "confidentiality": "public"}, + }, + ) + ] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.result[0].additional_properties["_variable_reference"] is True + + async def test_trusted_fallback_can_be_restricted_by_embedded_label(self, middleware, mock_function) -> None: + mock_function.additional_properties = {"source_integrity": "trusted"} + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "remote result", + additional_properties={ + "security_label": {"integrity": "untrusted", "confidentiality": "public"}, + }, + ) + ] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.result[0].additional_properties["_variable_reference"] is True + + async def test_framework_authoritative_trusted_label_is_preserved(self, middleware, mock_function) -> None: + from agent_framework.security import _INTERNAL_RESULT_MARKER + + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "locally verified result", + additional_properties={ + "security_label": {"integrity": "trusted", "confidentiality": "public"}, + _AUTHORITY_MARKER_KEY: _INTERNAL_RESULT_MARKER, + }, + ) + ] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.TRUSTED + assert context.result[0].text == "locally verified result" + assert _AUTHORITY_MARKER_KEY not in context.result[0].additional_properties + + @pytest.mark.parametrize("forged_marker", [True, "framework"], ids=["boolean", "string"]) + async def test_serializable_authority_marker_is_ignored( + self, + middleware, + mock_function, + forged_marker: bool | str, + ) -> None: + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "remote result", + additional_properties={ + "security_label": {"integrity": "trusted", "confidentiality": "public"}, + _AUTHORITY_MARKER_KEY: forged_marker, + }, + ) + ] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert context.result[0].additional_properties["_variable_reference"] is True + + async def test_embedded_label_cannot_reduce_fallback_confidentiality(self, middleware, mock_function) -> None: + mock_function.additional_properties = {"source_integrity": "trusted", "confidentiality": "private"} + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "remote result", + additional_properties={ + "security_label": {"integrity": "trusted", "confidentiality": "public"}, + _AUTHORITY_MARKER_KEY: True, + }, + ) + ] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE + assert _AUTHORITY_MARKER_KEY not in context.result[0].additional_properties + + async def test_generic_embedded_label_cannot_introduce_principals(self, middleware, mock_function) -> None: + mock_function.additional_properties = {"source_integrity": "trusted"} + context = FunctionInvocationContext(function=mock_function, arguments=mock_function.args_schema()) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "remote identity data", + additional_properties={ + "security_label": { + "integrity": "trusted", + "confidentiality": "user_identity", + "metadata": _principal_metadata("user-a"), + }, + }, + ) + ] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.USER_IDENTITY + assert _PRINCIPALS_KEY not in context.metadata["result_label"].metadata + + async def test_generic_identity_label_preserves_authoritative_fallback_principals( + self, middleware, mock_function + ) -> None: + mock_function.additional_properties = { + "source_integrity": "trusted", + "confidentiality": "user_identity", + _PRINCIPALS_KEY: _principal_metadata("user-a")[_PRINCIPALS_KEY], + } + context = FunctionInvocationContext(function=mock_function, arguments={}) + + async def next_fn() -> None: + context.result = [ + Content.from_text( + "remote identity data", + additional_properties={ + "security_label": { + "integrity": "trusted", + "confidentiality": "user_identity", + "metadata": _principal_metadata("user-b"), + }, + }, + ) + ] + + await middleware.process(context, next_fn) + + assert context.metadata["result_label"].metadata[_PRINCIPALS_KEY] == [ + {"tenant_id": "tenant-a", "user_id": "user-a"} + ] + @pytest.mark.asyncio async def test_mixed_trust_items_in_list(self, middleware, mock_function): """Test that untrusted items are hidden while trusted items remain visible.""" + mock_function.additional_properties = {"source_integrity": "trusted"} args = mock_function.args_schema() context = FunctionInvocationContext(function=mock_function, arguments=args) @@ -3942,6 +4586,7 @@ async def next_fn(): @pytest.mark.asyncio async def test_hidden_untrusted_items_do_not_taint_integrity_in_mixed_results(self, middleware, mock_function): """Hidden untrusted items should only affect confidentiality, not integrity.""" + mock_function.additional_properties = {"source_integrity": "trusted"} args = mock_function.args_schema() context = FunctionInvocationContext(function=mock_function, arguments=args) @@ -3966,6 +4611,7 @@ async def next_fn(): @pytest.mark.asyncio async def test_all_trusted_items_visible(self, middleware, mock_function): """Test that all trusted items remain fully visible.""" + mock_function.additional_properties = {"source_integrity": "trusted"} args = mock_function.args_schema() context = FunctionInvocationContext(function=mock_function, arguments=args) @@ -4045,6 +4691,47 @@ async def next_fn() -> None: assert middleware.get_context_label().integrity == IntegrityLabel.TRUSTED assert middleware.get_context_label().confidentiality == ConfidentialityLabel.USER_IDENTITY + async def test_hidden_user_identity_result_unions_principals_at_same_rank( + self, middleware, mock_function, caplog: pytest.LogCaptureFixture + ) -> None: + caplog.set_level(logging.DEBUG, logger="agent_framework.security") + middleware._context_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + mock_function.additional_properties = { + "source_integrity": "untrusted", + "confidentiality": "user_identity", + _PRINCIPALS_KEY: _principal_metadata("user-b")[_PRINCIPALS_KEY], + } + context = FunctionInvocationContext(function=mock_function, arguments={}) + + async def next_fn() -> None: + context.result = [Content.from_text("hidden user B data")] + + await middleware.process(context, next_fn) + + assert context.result[0].additional_properties["_variable_reference"] is True + assert middleware.get_context_label().metadata[_PRINCIPALS_KEY] == [ + {"tenant_id": "tenant-a", "user_id": "user-a"}, + {"tenant_id": "tenant-a", "user_id": "user-b"}, + ] + + assert "user-a" not in caplog.text + assert "user-b" not in caplog.text + caplog.clear() + + destination = _identity_destination(_principal_metadata("user-a")[_PRINCIPALS_KEY]) + policy_context = FunctionInvocationContext(function=destination, arguments={}) + policy_context.metadata["context_label"] = middleware.get_context_label() + policy = PolicyEnforcementFunctionMiddleware() + + async def execute() -> None: + pytest.fail("A single-user destination must not receive hidden content from another principal") + + with pytest.raises(MiddlewareTermination): + await policy.process(policy_context, execute) + async def test_malformed_embedded_metadata_preserves_mandatory_label_fields( self, middleware, mock_function ) -> None: @@ -4391,16 +5078,17 @@ async def next_fn() -> None: "transformed", additional_properties={ "security_label": {"integrity": "trusted", "confidentiality": "public"}, - "_security_label_authoritative_confidentiality": True, + _AUTHORITY_MARKER_KEY: True, }, ) ] await middleware.process(context, next_fn) - assert context.metadata["result_label"].integrity == IntegrityLabel.TRUSTED + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED assert context.metadata["result_label"].confidentiality == ConfidentialityLabel.PRIVATE assert middleware.get_context_label().confidentiality == ConfidentialityLabel.PRIVATE + assert _AUTHORITY_MARKER_KEY not in context.result[0].additional_properties @pytest.mark.asyncio async def test_embedded_labels_override_source_integrity(self, middleware): @@ -4696,6 +5384,96 @@ async def next_fn(): # Should be blocked (either violation should block) assert "error" in context.result + async def test_matching_user_identity_principal_is_allowed(self, policy_middleware) -> None: + source_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + function = _identity_destination(_principal_metadata("user-a")[_PRINCIPALS_KEY]) + context = FunctionInvocationContext(function=function, arguments={}) + context.metadata["context_label"] = source_label + + async def next_fn() -> None: + context.result = "sent" + + await policy_middleware.process(context, next_fn) + + assert context.result == "sent" + + @pytest.mark.parametrize( + ("source_metadata", "destination_principals"), + [ + (_principal_metadata("user-a"), _principal_metadata("user-b")[_PRINCIPALS_KEY]), + ( + _principal_metadata("user-a", "tenant-a"), + _principal_metadata("user-a", "tenant-b")[_PRINCIPALS_KEY], + ), + ({}, _principal_metadata("user-a")[_PRINCIPALS_KEY]), + (_principal_metadata("user-a"), None), + ( + {_PRINCIPALS_KEY: {"tenant_id": "tenant-a", "user_id": "user-a"}}, + _principal_metadata("user-a")[_PRINCIPALS_KEY], + ), + ( + _principal_metadata("user-a"), + {"tenant_id": "tenant-a", "user_id": "user-a"}, + ), + ], + ids=[ + "different-user", + "different-tenant", + "missing-source", + "missing-destination", + "malformed-source", + "malformed-destination", + ], + ) + async def test_user_identity_principal_mismatch_is_blocked( + self, + policy_middleware, + source_metadata: dict[str, Any], + destination_principals: Any | None, + ) -> None: + source_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=source_metadata, + ) + function = _identity_destination(destination_principals) + context = FunctionInvocationContext(function=function, arguments={}) + context.metadata["context_label"] = source_label + executed = False + + async def next_fn() -> None: + nonlocal executed + executed = True + + with pytest.raises(MiddlewareTermination): + await policy_middleware.process(context, next_fn) + + assert executed is False + assert context.result["violation_type"] == "principal_mismatch" + + async def test_combined_principals_require_destination_subset(self, policy_middleware) -> None: + source_label = combine_labels( + ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ), + ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-b"), + ), + ) + function = _identity_destination(_principal_metadata("user-a")[_PRINCIPALS_KEY]) + context = FunctionInvocationContext(function=function, arguments={}) + context.metadata["context_label"] = source_label + + async def next_fn() -> None: + pytest.fail("A destination for one owner must not receive content owned by multiple principals") + + with pytest.raises(MiddlewareTermination): + await policy_middleware.process(context, next_fn) + class TestCheckConfidentialityAllowed: """Tests for check_confidentiality_allowed helper function.""" @@ -4756,12 +5534,73 @@ def test_user_identity_to_private_blocked(self): ui_label = ContentLabel(confidentiality=ConfidentialityLabel.USER_IDENTITY) assert check_confidentiality_allowed(ui_label, ConfidentialityLabel.PRIVATE) is False - def test_user_identity_to_user_identity_allowed(self): - """Test USER_IDENTITY data can be written to USER_IDENTITY destination.""" + def test_matching_user_identity_to_user_identity_allowed(self): from agent_framework.security import check_confidentiality_allowed - ui_label = ContentLabel(confidentiality=ConfidentialityLabel.USER_IDENTITY) - assert check_confidentiality_allowed(ui_label, ConfidentialityLabel.USER_IDENTITY) is True + ui_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + assert ( + check_confidentiality_allowed( + ui_label, + ConfidentialityLabel.USER_IDENTITY, + authorized_principals=_principal_metadata("user-a")[_PRINCIPALS_KEY], + ) + is True + ) + + def test_user_identity_accepts_sequence_and_mapping_implementations(self) -> None: + from agent_framework.security import check_confidentiality_allowed + + principal = MappingProxyType({"tenant_id": "tenant-a", "user_id": "user-a"}) + ui_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata={_PRINCIPALS_KEY: (principal,)}, + ) + + assert ( + check_confidentiality_allowed( + ui_label, + ConfidentialityLabel.USER_IDENTITY, + authorized_principals=(principal,), + ) + is True + ) + + @pytest.mark.parametrize( + ("source_metadata", "authorized_principals"), + [ + (_principal_metadata("user-a"), _principal_metadata("user-b")[_PRINCIPALS_KEY]), + ( + _principal_metadata("user-a", "tenant-a"), + _principal_metadata("user-a", "tenant-b")[_PRINCIPALS_KEY], + ), + ({}, _principal_metadata("user-a")[_PRINCIPALS_KEY]), + (_principal_metadata("user-a"), None), + ], + ids=["different-user", "different-tenant", "missing-source", "missing-destination"], + ) + def test_user_identity_requires_matching_principals( + self, + source_metadata: dict[str, Any], + authorized_principals: Any | None, + ) -> None: + from agent_framework.security import check_confidentiality_allowed + + ui_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=source_metadata, + ) + + assert ( + check_confidentiality_allowed( + ui_label, + ConfidentialityLabel.USER_IDENTITY, + authorized_principals=authorized_principals, + ) + is False + ) if __name__ == "__main__": @@ -5526,6 +6365,31 @@ async def test_apply_mcp_security_labels_configures_result_authority(self, trust ) assert result[0].additional_properties["security_label"] == expected_label + async def test_framework_stamped_mcp_label_remains_authoritative_through_tracking(self) -> None: + from agent_framework.security import apply_mcp_security_labels + + annotations = SimpleNamespace(readOnlyHint=True, openWorldHint=False) + server_meta = {"ifc": {"integrity": "trusted", "confidentiality": "public"}} + mcp_tool, function = _make_connected_mcp_tool_for_ifc( + annotations=annotations, + server_meta=server_meta, + ) + await apply_mcp_security_labels(mcp_tool, trust_server_ifc=True) + assert function.func is not None + stamped_result = await function.func() + tracker = LabelTrackingFunctionMiddleware() + context = FunctionInvocationContext(function=function, arguments={}) + + async def next_fn() -> None: + context.result = stamped_result + + await tracker.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.TRUSTED + assert context.result[0].text == "payload" + assert _AUTHORITY_MARKER_KEY not in context.result[0].additional_properties + assert "_security_label_authoritative_confidentiality" not in context.result[0].additional_properties + async def test_apply_mcp_security_labels_reconfigures_existing_wrapper_authority(self): from agent_framework.security import apply_mcp_security_labels @@ -5751,6 +6615,58 @@ async def execute(current: FunctionInvocationContext) -> list[Content]: ) == ["alice secret", "bob secret"] assert get_current_middleware() is None + async def test_overlapping_sessions_keep_principal_metadata_isolated(self) -> None: + tracker = LabelTrackingFunctionMiddleware(auto_hide_untrusted=False) + alice = AgentSession(session_id="alice-principal") + bob = AgentSession(session_id="bob-principal") + tracker._scope_for_session(alice).context_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ) + tracker._scope_for_session(bob).context_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-b"), + ) + + class SourceArgs(BaseModel): + pass + + async def source() -> str: + return "done" + + function = FunctionTool( + fn=source, + name="session_principal_source", + description="Observe session principal", + args_schema=SourceArgs, + additional_properties={"source_integrity": "trusted"}, + ) + both_started = asyncio.Event() + started = 0 + + async def run(session: AgentSession, expected_user_id: str) -> None: + nonlocal started + context = FunctionInvocationContext(function=function, arguments={}, session=session) + + async def execute() -> None: + nonlocal started + started += 1 + if started == 2: + both_started.set() + await both_started.wait() + await asyncio.sleep(0) + assert context.metadata["context_label"].metadata[_PRINCIPALS_KEY] == [ + {"tenant_id": "tenant-a", "user_id": expected_user_id} + ] + context.result = [Content.from_text("done")] + + await tracker.process(context, execute) + + await asyncio.gather( + run(alice, "user-a"), + run(bob, "user-b"), + ) + async def test_direct_standalone_invocation_keeps_private_scope(self) -> None: tracker = LabelTrackingFunctionMiddleware() policy = PolicyEnforcementFunctionMiddleware() diff --git a/python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py b/python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py index d30dcc8e67a..c9768cefa96 100644 --- a/python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py +++ b/python/packages/hosting-a2a/tests/hosting_a2a/test_conversion.py @@ -15,6 +15,7 @@ WorkflowRunResult, executor, ) +from agent_framework.security import ContentLabel, IntegrityLabel, LabelTrackingFunctionMiddleware from google.protobuf.json_format import MessageToDict, ParseDict from pytest import raises @@ -77,6 +78,38 @@ def test_a2a_to_run_converts_supported_parts() -> None: assert converted.contents[3].text == '"structured"' +def test_a2a_security_label_metadata_cannot_upgrade_local_integrity() -> None: + message = A2AMessage( + message_id="message-security-label", + role=Role.ROLE_USER, + parts=[ + Part( + text="remote content", + metadata={ + "security_label": { + "integrity": "trusted", + "confidentiality": "public", + } + }, + ) + ], + ) + + run = a2a_to_run(message) + messages = run["messages"] + assert isinstance(messages, list) + converted_message = messages[0] + assert isinstance(converted_message, Message) + converted = converted_message.contents[0] + middleware = LabelTrackingFunctionMiddleware() + processed, label, _ = middleware._process_result_with_embedded_labels( + [converted], "a2a_remote", ContentLabel(integrity=IntegrityLabel.UNTRUSTED) + ) + + assert label.integrity == IntegrityLabel.UNTRUSTED + assert processed[0].additional_properties["_variable_reference"] is True + + def test_a2a_to_run_rejects_empty_message() -> None: with raises(ValueError, match="no supported"): a2a_to_run(A2AMessage(message_id="message-1", role=Role.ROLE_USER)) diff --git a/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md b/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md index 9ebf7bb19c9..44f8eefee0f 100644 --- a/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md +++ b/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md @@ -43,33 +43,136 @@ Every piece of content (tool calls, results, messages) can be assigned a `Conten - **USER_IDENTITY**: Content is restricted to specific user identities only ```python -from agent_framework.security import ContentLabel, IntegrityLabel, ConfidentialityLabel +from agent_framework.security import ConfidentialityLabel, ContentLabel, IntegrityLabel, PRINCIPAL_METADATA_KEY # Create a label label = ContentLabel( integrity=IntegrityLabel.TRUSTED, - confidentiality=ConfidentialityLabel.PRIVATE, - metadata={"user_id": "user-123"} + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata={ + PRINCIPAL_METADATA_KEY: [ + {"tenant_id": "tenant-123", "user_id": "user-123"}, + ] + }, ) ``` +### 1.1 USER_IDENTITY Principal Binding + +`USER_IDENTITY` labels require a canonical, non-empty principal set. Each +principal contains exactly `tenant_id` and `user_id`. Build this metadata from +the authenticated request or session, or from a locally trusted static tool +declaration. Do not infer it from model arguments or remote result metadata. + +Source tools declare the owner of identity-scoped output. Destination tools +declare the principals they authorize using the same namespaced key: + +```python +from agent_framework import tool +from agent_framework.security import PRINCIPAL_METADATA_KEY + +alice = [{"tenant_id": "tenant-contoso", "user_id": "alice"}] + + +@tool( + description="Read Alice's profile", + additional_properties={ + "source_integrity": "trusted", + "confidentiality": "user_identity", + PRINCIPAL_METADATA_KEY: alice, + }, +) +async def read_profile() -> str: + return "Alice profile data" + + +@tool( + description="Save data to Alice's profile", + additional_properties={ + "max_allowed_confidentiality": "user_identity", + PRINCIPAL_METADATA_KEY: alice, + }, +) +async def save_profile(data: str) -> None: + ... +``` + +The policy allows a flow only when every source principal is present in the +destination's authorized set. Missing, malformed, or mismatched principal data +is blocked. See +[`user_identity_security_example.py`](user_identity_security_example.py) for a +complete runnable setup. + +#### Migrating legacy USER_IDENTITY labels + +Earlier FIDES examples used a single, unnamespaced `user_id` and did not bind +identity destinations. Releases containing principal-bound enforcement reject +that shape. Migrate both the source label and every USER_IDENTITY destination; +do not fill in a tenant or user from model-generated arguments. + +Before: + +```python +legacy_label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata={"user_id": "alice"}, +) + + +@tool( + description="Save identity data", + additional_properties={"max_allowed_confidentiality": "user_identity"}, +) +async def save_identity_data(data: str) -> None: + ... +``` + +After: + +```python +alice = [{"tenant_id": authenticated_tenant_id, "user_id": authenticated_user_id}] + +label = ContentLabel( + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata={PRINCIPAL_METADATA_KEY: alice}, +) + + +@tool( + description="Save identity data", + additional_properties={ + "max_allowed_confidentiality": "user_identity", + PRINCIPAL_METADATA_KEY: alice, + }, +) +async def save_identity_data(data: str) -> None: + ... +``` + +Combined content may contain more than one principal. A destination must list +all authorized principals because the policy checks that the source set is a +subset of the destination set. + ### 2. Label Tracking Middleware with Tiered Label Propagation `LabelTrackingFunctionMiddleware` uses a **tiered label propagation** scheme where the result label of a tool call is determined by a strict 3-tier priority: | Priority | Source | Used When | |----------|--------|-----------| -| **Tier 1** (Highest) | Per-item embedded labels (`additional_properties.security_label`) | Tool result items include explicit labels | +| **Tier 1** | Per-item embedded labels (`additional_properties.security_label`) | Restrict the locally established fallback | | **Tier 2** | Tool's `source_integrity` declaration | No embedded labels, but tool declares `source_integrity` | | **Tier 3** (Lowest) | Join of input argument labels (`combine_labels`) | No embedded labels AND no `source_integrity` declared | | **Default** | `UNTRUSTED` | No labels from any tier | **Tiered Label Propagation:** -- **Tier 1: Embedded labels** in result items via `additional_properties.security_label` — highest priority, used per-item -- **Tier 2: `source_integrity`** declaration on the tool — authoritative for the trust level of the tool's output, regardless of input labels +- **Tier 1: Embedded labels** are restriction-only by default: they can downgrade integrity or raise confidentiality, but cannot upgrade a fallback or supply principal authority +- **Tier 2: `source_integrity`** is the locally trusted fallback for the tool's output; use `"trusted"` only after the local connector enforces its trust policy - **Tier 3: Input labels join** — `combine_labels(*input_labels)` from arguments (VariableReferenceContent, labeled data) - **Default**: `UNTRUSTED` when no labels exist from any tier +Framework-owned parsers and wrappers may stamp a complete label after enforcing +local policy. Application and remote metadata remains restriction-only. + **Per-Item Embedded Labels (RECOMMENDED for Mixed-Trust Data):** Tools returning mixed-trust data should embed labels on each item in `additional_properties.security_label`: @@ -88,7 +191,7 @@ The middleware automatically: **Tool-Level Source Integrity (Tier 2 Fallback):** If items don't have embedded labels, the tool can declare a fallback via `source_integrity`. -When declared, `source_integrity` alone determines the result label — input argument labels are NOT combined in. This means a tool declaring `source_integrity="trusted"` always produces trusted output regardless of what inputs it received: +When declared, `source_integrity` establishes the local integrity fallback. Embedded labels can make the result less trusted, but cannot make it more trusted: - `source_integrity="trusted"`: Tool produces trusted data (internal computations) - `source_integrity="untrusted"`: Tool fetches untrusted data - (not set): Falls back to tier 3 (join of input labels) or **UNTRUSTED** default @@ -112,7 +215,12 @@ from agent_framework import Content, tool from agent_framework.security import LabelTrackingFunctionMiddleware, SecureAgentConfig # Define a tool that returns mixed-trust data with per-item labels -@tool(description="Fetch emails from inbox") +@tool( + description="Fetch emails from inbox", + additional_properties={ + "source_integrity": "trusted", # Local connector verifies internal senders + }, +) async def fetch_emails(count: int = 5) -> list[Content]: """Fetch emails - some from trusted internal sources, others from external sources.""" emails = get_emails(count) @@ -171,7 +279,12 @@ For tools that return mixed-trust data (e.g., emails from both internal and exte import json from agent_framework import Content, tool -@tool(description="Fetch emails from inbox") +@tool( + description="Fetch emails from inbox", + additional_properties={ + "source_integrity": "trusted", # Local connector verifies internal senders + }, +) async def fetch_emails(count: int = 5) -> list[Content]: """Fetch emails with per-item security labels.""" emails = fetch_from_server(count) @@ -685,7 +798,12 @@ import json from agent_framework import Content, tool # Tool returning mixed-trust data with per-item labels (RECOMMENDED) -@tool(description="Fetch emails from inbox") +@tool( + description="Fetch emails from inbox", + additional_properties={ + "source_integrity": "trusted", # Local connector verifies internal senders + }, +) async def fetch_emails(count: int = 5) -> list[Content]: """Emails can be from trusted internal or untrusted external sources.""" emails = get_emails(count) @@ -836,7 +954,7 @@ PUBLIC (0) < PRIVATE (1) < USER_IDENTITY (2) - PUBLIC data can flow anywhere - PRIVATE data can only flow to PRIVATE or USER_IDENTITY destinations -- USER_IDENTITY data can only flow to USER_IDENTITY destinations +- USER_IDENTITY data can only flow to USER_IDENTITY destinations that authorize every source principal **Runtime Helper Function:** @@ -893,8 +1011,10 @@ await post_to_slack(channel="#docs", message="Check out our docs!") |----------|---------|----------------| | `confidentiality` | Declares output sensitivity | `"public"`, `"private"`, `"user_identity"` | | `max_allowed_confidentiality` | Gates outputs (maximum level) | `"public"` = blocks PRIVATE data exfiltration | +| `agent_framework.security.principals` | Declares USER_IDENTITY owners or authorized destinations | `[{"tenant_id": "tenant-contoso", "user_id": "alice"}]` | -See `samples/02-agents/security/repo_confidentiality_example.py` for a complete working example. +See `repo_confidentiality_example.py` for confidentiality ranking and +`user_identity_security_example.py` for principal-bound identity data. ## Configuration Options @@ -1021,6 +1141,7 @@ Run the maintained security samples from `python/`: ```bash uv run samples/02-agents/security/email_security_example.py --cli uv run samples/02-agents/security/repo_confidentiality_example.py --cli +uv run samples/02-agents/security/user_identity_security_example.py uv run samples/02-agents/security/github_mcp_example.py --cli uv run samples/02-agents/security/github_mcp_example.py --cli --attack ``` @@ -1031,6 +1152,7 @@ This demonstrates: - Quarantined LLM usage - Variable inspection - Policy enforcement +- Principal-bound USER_IDENTITY sources and destinations - Complete secure workflow ## Key Takeaways @@ -1059,6 +1181,7 @@ from agent_framework.security import ( ContentLabel, IntegrityLabel, ConfidentialityLabel, + PRINCIPAL_METADATA_KEY, combine_labels, # Variable Store diff --git a/python/samples/02-agents/security/README.md b/python/samples/02-agents/security/README.md index 54cd0e46bf5..d3105eb38ff 100644 --- a/python/samples/02-agents/security/README.md +++ b/python/samples/02-agents/security/README.md @@ -11,6 +11,7 @@ security model, middleware behavior, and API reference. |--------|-------|--------------| | `email_security_example.py` | Prompt injection defense | `SecureAgentConfig`, Foundry-backed email handling, `quarantined_llm`, and approval on policy violations | | `repo_confidentiality_example.py` | Data exfiltration prevention | Confidentiality labels, Foundry-backed repository access, `max_allowed_confidentiality`, and approval before leaking private data | +| `user_identity_security_example.py` | Principal-bound identity data | Canonical tenant/user principals, identity-scoped sources and destinations, and allowed versus blocked flows | | `github_mcp_example.py` | Remote MCP URL with local FIDES enforcement | `SecureMCPToolProxy(url=...)`, direct GitHub MCP access, tool auto-labeling, and post-tool-call policy enforcement | ## Prerequisites @@ -87,6 +88,25 @@ What to look for: - Reading private content taints the context as private - Posting private data to a public destination triggers an approval request +### `user_identity_security_example.py` + +This sample creates source and destination tools from a host-authenticated +tenant/user principal. It demonstrates the canonical principal metadata format +and wires policy enforcement with `SecureAgentConfig`. + +Run it with: + +```bash +uv run samples/02-agents/security/user_identity_security_example.py +``` + +What to look for: + +- Data read for Alice carries Alice's tenant/user principal +- Saving that data to Alice's destination is allowed +- Sending the same data to Bob's destination is blocked and audited +- Principal identity comes from host configuration, not model tool arguments + ### `github_mcp_example.py` This sample connects directly to `https://api.githubcopilot.com/mcp/` through diff --git a/python/samples/02-agents/security/email_security_example.py b/python/samples/02-agents/security/email_security_example.py index f8dff526ff4..14f7aacd02a 100644 --- a/python/samples/02-agents/security/email_security_example.py +++ b/python/samples/02-agents/security/email_security_example.py @@ -204,7 +204,11 @@ async def send_email( @tool( description="Fetch emails from the inbox. Returns a list of email objects.", - # No tool-level source_integrity needed - labels are per-item in additional_properties + additional_properties={ + # The local inbox connector is trusted to classify internal senders. + # Per-item labels below may downgrade external messages to UNTRUSTED. + "source_integrity": "trusted", + }, ) async def fetch_emails( count: int = Field(default=5, description="Number of emails to fetch"), @@ -217,8 +221,8 @@ async def fetch_emails( """ emails = SAMPLE_EMAILS[:count] - # Return emails as list[Content] with per-item security labels in additional_properties. - # This ensures FunctionTool.invoke() preserves per-item labels for tier-1 propagation. + # Per-item labels are restriction-only. External messages downgrade the + # locally trusted fallback; internal messages preserve it. result: list[Content] = [] for email in emails: email_text = json.dumps({ diff --git a/python/samples/02-agents/security/user_identity_security_example.py b/python/samples/02-agents/security/user_identity_security_example.py new file mode 100644 index 00000000000..4ccc71d2c6d --- /dev/null +++ b/python/samples/02-agents/security/user_identity_security_example.py @@ -0,0 +1,172 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""USER_IDENTITY principal binding with SecureAgentConfig. + +This sample shows how an application binds identity-scoped data to an +authenticated tenant/user principal and limits destinations to an authorized +principal set. It demonstrates: + +1. Building source and destination tools from host-authenticated identity. +2. Declaring canonical principals with ``PRINCIPAL_METADATA_KEY``. +3. Adding ``SecureAgentConfig`` through ``context_providers``. +4. Allowing a same-principal flow and blocking a cross-principal flow. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT: Microsoft Foundry project endpoint. + FOUNDRY_MODEL: Model deployment name. + +Before running: + az login + +Run from the ``python`` directory: + uv run samples/02-agents/security/user_identity_security_example.py + +Expected behavior: + - Saving Alice's profile to Alice's destination is allowed. + - Sending Alice's profile to Bob's destination is blocked and audited. +""" + +import asyncio +import os +from typing import Any + +from agent_framework import Agent, AgentSession, FunctionTool, tool +from agent_framework.foundry import FoundryChatClient +from agent_framework.security import PRINCIPAL_METADATA_KEY, SecureAgentConfig +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + +TENANT_ID = "tenant-contoso" +AUTHENTICATED_USER_ID = "alice" +OTHER_USER_ID = "bob" + + +def principal_set(*, tenant_id: str, user_id: str) -> list[dict[str, str]]: + """Return the canonical principal-set representation used by FIDES.""" + return [{"tenant_id": tenant_id, "user_id": user_id}] + + +def create_identity_tools(*, tenant_id: str, authenticated_user_id: str) -> list[FunctionTool]: + """Create tools whose security metadata comes from authenticated host state. + + In a real application, derive these values from the authenticated request or + session before constructing the agent's tools. Never accept the principal + from model-generated tool arguments or remote result metadata. + """ + authenticated_principals = principal_set(tenant_id=tenant_id, user_id=authenticated_user_id) + other_principals = principal_set(tenant_id=tenant_id, user_id=OTHER_USER_ID) + + @tool( + description="Read the authenticated user's profile.", + additional_properties={ + "source_integrity": "trusted", + "confidentiality": "user_identity", + PRINCIPAL_METADATA_KEY: authenticated_principals, + }, + ) + async def read_my_profile() -> str: + """Return identity-scoped data for the authenticated user.""" + return f"{authenticated_user_id} profile: preferred language is Python." + + @tool( + description="Save a note to the authenticated user's profile.", + additional_properties={ + "max_allowed_confidentiality": "user_identity", + PRINCIPAL_METADATA_KEY: authenticated_principals, + }, + ) + async def save_to_my_profile(note: str) -> dict[str, Any]: + """Save data to a destination authorized for the same principal.""" + print(f"ALLOWED: saved to {authenticated_user_id}: {note}") + return {"status": "saved", "user_id": authenticated_user_id} + + @tool( + description="Send a note to another user's account.", + additional_properties={ + "max_allowed_confidentiality": "user_identity", + PRINCIPAL_METADATA_KEY: other_principals, + }, + ) + async def send_to_other_account(note: str) -> dict[str, Any]: + """Represent a destination authorized for a different principal.""" + print(f"UNEXPECTED: sent to {OTHER_USER_ID}: {note}") + return {"status": "sent", "user_id": OTHER_USER_ID} + + return [read_my_profile, save_to_my_profile, send_to_other_account] + + +def create_agent() -> tuple[Agent, SecureAgentConfig]: + """Create a Foundry agent with identity-aware security enforcement.""" + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + security = SecureAgentConfig( + auto_hide_untrusted=True, + enable_policy_enforcement=True, + block_on_violation=True, + ) + agent = Agent( + client=client, + name="IdentityScopedAssistant", + instructions="Follow the user's requested tool sequence exactly.", + tools=create_identity_tools( + tenant_id=TENANT_ID, + authenticated_user_id=AUTHENTICATED_USER_ID, + ), + context_providers=[security], + ) + return agent, security + + +async def run_scenario( + agent: Agent, + security: SecureAgentConfig, + *, + title: str, + prompt: str, +) -> None: + """Run one isolated scenario and print policy audit entries.""" + print(f"\n{'=' * 72}\n{title}\n{'=' * 72}") + session = AgentSession() + response = await agent.run(prompt, session=session) + print(f"\nAgent response: {response.text}") + + audit_log = security.get_audit_log(session) + if audit_log: + print("\nPolicy audit:") + for entry in audit_log: + print(f"- {entry.get('reason', 'Policy violation')}") + else: + print("\nPolicy audit: no violations") + + +async def main() -> None: + """Run matching-principal and cross-principal flows.""" + agent, security = create_agent() + + await run_scenario( + agent, + security, + title="Allowed: Alice data to Alice destination", + prompt=( + "Call read_my_profile. Then call save_to_my_profile with the profile text " + "as the note. Do not call any other tools." + ), + ) + await run_scenario( + agent, + security, + title="Blocked: Alice data to Bob destination", + prompt=( + "Call read_my_profile. Then call send_to_other_account with the profile text " + "as the note. Do not call any other tools." + ), + ) + + +if __name__ == "__main__": + asyncio.run(main()) From 81ee134c607cd9377d1fe5eabf58b0fba3f88797 Mon Sep 17 00:00:00 2001 From: WhaleTech Date: Tue, 15 Sep 2026 04:53:46 +0000 Subject: [PATCH 069/108] Python: Fix workflow kwargs collision with __global__ executor IDs (#8311) * Python: Fix workflow kwargs collision with __global__ executor IDs * Python: Scope workflow kwargs warning assertion --------- Co-authored-by: WhaleTech <304937387+ryo-whaletech@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../_workflows/_agent_executor.py | 11 +- .../_workflows/_agent_utils.py | 116 +++++-- .../core/agent_framework/_workflows/_const.py | 10 +- .../agent_framework/_workflows/_workflow.py | 62 +++- .../tests/workflow/test_agent_executor.py | 35 +- .../tests/workflow/test_workflow_kwargs.py | 307 +++++++++++++++++- .../_workflows/_executors_agents.py | 16 +- .../declarative/tests/test_graph_coverage.py | 1 + .../declarative/tests/test_graph_executors.py | 175 ++++++++++ .../_group_chat.py | 5 +- .../orchestrations/tests/test_group_chat.py | 26 ++ 11 files changed, 709 insertions(+), 55 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index cd69504c71d..6867bd73a05 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -15,7 +15,7 @@ from .._sessions import AgentSession from .._types import AgentResponse, AgentResponseUpdate, Message, ResponseStream from ._agent_utils import prepare_agent_run_args, resolve_agent_id, resolve_executor_kwargs -from ._const import INTERNAL_SOURCE_ID, WORKFLOW_RUN_KWARGS_KEY +from ._const import INTERNAL_SOURCE_ID, RESOLVED_WORKFLOW_RUN_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY from ._executor import Executor, handler from ._message_utils import normalize_messages_input from ._request_info_mixin import response_handler @@ -441,7 +441,8 @@ async def _run_agent(self, ctx: WorkflowContext[Never, AgentResponse]) -> AgentR The complete AgentResponse, or None if waiting for user input. """ raw_run_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) - function_invocation_kwargs, client_kwargs = self._prepare_agent_run_args(raw_run_kwargs) + resolved_run_kwargs = ctx.get_state(RESOLVED_WORKFLOW_RUN_KWARGS_KEY) + function_invocation_kwargs, client_kwargs = self._prepare_agent_run_args(raw_run_kwargs, resolved_run_kwargs) tools = ctx.get_runtime_tools() if not self._cache: @@ -497,7 +498,8 @@ async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUp The complete AgentResponse, or None if waiting for user input. """ raw_run_kwargs = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) - function_invocation_kwargs, client_kwargs = self._prepare_agent_run_args(raw_run_kwargs) + resolved_run_kwargs = ctx.get_state(RESOLVED_WORKFLOW_RUN_KWARGS_KEY) + function_invocation_kwargs, client_kwargs = self._prepare_agent_run_args(raw_run_kwargs, resolved_run_kwargs) tools = ctx.get_runtime_tools() if not self._cache: @@ -582,6 +584,7 @@ async def _run_agent_streaming(self, ctx: WorkflowContext[Never, AgentResponseUp def _prepare_agent_run_args( self, raw_run_kwargs: dict[str, Any], + resolved_run_kwargs: Any = None, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Prepare function_invocation_kwargs and client_kwargs for agent.run(). @@ -594,7 +597,7 @@ def _prepare_agent_run_args( Returns: A 2-tuple of (function_invocation_kwargs, client_kwargs). """ - return prepare_agent_run_args(self.id, raw_run_kwargs) + return prepare_agent_run_args(self.id, raw_run_kwargs, resolved_run_kwargs) def _resolve_executor_kwargs(self, resolved: dict[str, Any] | None) -> dict[str, Any] | None: """Extract this executor's kwargs from a resolved invocation kwargs dict. diff --git a/python/packages/core/agent_framework/_workflows/_agent_utils.py b/python/packages/core/agent_framework/_workflows/_agent_utils.py index 08034123328..4d7277d6258 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_utils.py +++ b/python/packages/core/agent_framework/_workflows/_agent_utils.py @@ -4,7 +4,7 @@ from typing import Any, cast from .._agents import SupportsAgentRun -from ._const import GLOBAL_KWARGS_KEY +from ._const import GLOBAL_KWARGS_KEY, RAW_CLIENT_KWARGS_KEY, RAW_FUNCTION_INVOCATION_KWARGS_KEY logger = logging.getLogger(__name__) @@ -23,22 +23,12 @@ def resolve_agent_id(agent: SupportsAgentRun) -> str: return agent.name if agent.name else agent.id -def resolve_executor_kwargs(executor_id: str, resolved: dict[str, Any] | None) -> dict[str, Any] | None: - """Extract one executor's kwargs from a resolved invocation kwargs dict. - - Args: - executor_id: The id of the executor whose kwargs are wanted. - resolved: The resolved dict produced by ``Workflow._resolve_invocation_kwargs``, - containing either a ``__global__`` key (global kwargs) or executor-ID keys - (per-executor kwargs). May also be ``None``. - - Returns: - The kwargs for that executor, or ``None`` if not applicable. - """ - if not isinstance(resolved, dict): - return None - global_kwargs: Any = resolved.get(GLOBAL_KWARGS_KEY) - executor_kwargs: Any = resolved.get(executor_id) +def _merge_executor_kwargs( + executor_id: str, + global_kwargs: Any, + executor_kwargs: Any, +) -> dict[str, Any] | None: + """Merge validated global and executor-specific kwargs for one executor.""" if global_kwargs is None and executor_kwargs is None: return None @@ -62,26 +52,102 @@ def resolve_executor_kwargs(executor_id: str, resolved: dict[str, Any] | None) - return {**(global_kwargs or {}), **(executor_kwargs or {})} +def resolve_executor_kwargs(executor_id: str, resolved: dict[str, Any] | None) -> dict[str, Any] | None: + """Extract one executor's kwargs from a resolved invocation kwargs dict. + + Args: + executor_id: The id of the executor whose kwargs are wanted. + resolved: A legacy-compatible resolved dict containing either a ``__global__`` + key (global kwargs) or executor-ID keys (per-executor kwargs). May also be + ``None``. + + Returns: + The kwargs for that executor, or ``None`` if not applicable. + """ + if not isinstance(resolved, dict): + return None + return _merge_executor_kwargs(executor_id, resolved.get(GLOBAL_KWARGS_KEY), resolved.get(executor_id)) + + +def _resolve_structured_executor_kwargs( + executor_id: str, + resolved: Any, +) -> dict[str, Any] | None: + """Extract one executor's kwargs from collision-free workflow run state.""" + if not isinstance(resolved, dict): + raise TypeError("Resolved workflow invocation kwargs state must be a dict.") + resolved_dict = cast(dict[str, Any], resolved) + executor_kwargs = resolved_dict.get("executor_kwargs") + specific_kwargs = ( + cast(dict[str, Any], executor_kwargs).get(executor_id) if isinstance(executor_kwargs, dict) else None + ) + return _merge_executor_kwargs(executor_id, resolved_dict.get("global_kwargs"), specific_kwargs) + + +def prepare_executor_run_kwargs( + executor_id: str, + raw_run_kwargs: dict[str, Any], + resolved_run_kwargs: Any = None, +) -> dict[str, Any]: + """Prepare sanitized, executor-ready kwargs from workflow run state. + + New collision-free state takes precedence when present. Legacy dict state remains + readable for checkpoint and package-version compatibility. Internal raw-routing + snapshots are never forwarded to agents. + + Args: + executor_id: The id of the executor about to invoke an agent. + raw_run_kwargs: Legacy-compatible state stored under ``WORKFLOW_RUN_KWARGS_KEY``. + resolved_run_kwargs: Collision-free state stored under the resolved run-state key. + + Returns: + A copy of the run kwargs containing only values applicable to ``executor_id``. + """ + run_kwargs = dict(raw_run_kwargs) + run_kwargs.pop(RAW_FUNCTION_INVOCATION_KWARGS_KEY, None) + run_kwargs.pop(RAW_CLIENT_KWARGS_KEY, None) + + if resolved_run_kwargs is not None and not isinstance(resolved_run_kwargs, dict): + raise TypeError("Resolved workflow run kwargs state must be a dict.") + + for key in ("function_invocation_kwargs", "client_kwargs"): + if resolved_run_kwargs is not None: + executor_value = ( + _resolve_structured_executor_kwargs(executor_id, resolved_run_kwargs[key]) + if key in resolved_run_kwargs + else None + ) + else: + executor_value = resolve_executor_kwargs(executor_id, raw_run_kwargs.get(key)) + if executor_value is None: + run_kwargs.pop(key, None) + else: + run_kwargs[key] = executor_value + + return run_kwargs + + def prepare_agent_run_args( executor_id: str, raw_run_kwargs: dict[str, Any], + resolved_run_kwargs: Any = None, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: """Prepare function_invocation_kwargs and client_kwargs for agent.run(). - Extracts ``function_invocation_kwargs`` and ``client_kwargs`` from the workflow state - dict, resolving per-executor entries using ``executor_id``. The ``__global__`` sentinel - key (set by ``Workflow._resolve_invocation_kwargs``) denotes global kwargs that apply to - all executors. Per-executor dicts use executor IDs as keys; only the entry for - ``executor_id`` is extracted. + Extracts ``function_invocation_kwargs`` and ``client_kwargs`` from workflow state, + preferring collision-free state when present and otherwise reading the legacy dict + representation. Global and executor-specific values are merged for ``executor_id``. Args: executor_id: The id of the executor about to invoke the agent. - raw_run_kwargs: The workflow state dict stored under ``WORKFLOW_RUN_KWARGS_KEY``. + raw_run_kwargs: The legacy-compatible workflow run-state dict. + resolved_run_kwargs: Optional collision-free workflow run-state dict. Returns: A 2-tuple of (function_invocation_kwargs, client_kwargs). """ - function_invocation_kwargs = resolve_executor_kwargs(executor_id, raw_run_kwargs.get("function_invocation_kwargs")) - client_kwargs = resolve_executor_kwargs(executor_id, raw_run_kwargs.get("client_kwargs")) + run_kwargs = prepare_executor_run_kwargs(executor_id, raw_run_kwargs, resolved_run_kwargs) + function_invocation_kwargs = run_kwargs.get("function_invocation_kwargs") + client_kwargs = run_kwargs.get("client_kwargs") return function_invocation_kwargs, client_kwargs diff --git a/python/packages/core/agent_framework/_workflows/_const.py b/python/packages/core/agent_framework/_workflows/_const.py index f01b45d8407..d939bd448cb 100644 --- a/python/packages/core/agent_framework/_workflows/_const.py +++ b/python/packages/core/agent_framework/_workflows/_const.py @@ -17,12 +17,18 @@ # to pass kwargs from workflow.run() through to agent.run() and @tool functions. WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs" +# Key used to store collision-free, executor-aware workflow invocation kwargs. +# WORKFLOW_RUN_KWARGS_KEY remains in the legacy dict format for compatibility with +# older first-party packages and checkpoints. +RESOLVED_WORKFLOW_RUN_KWARGS_KEY = "_resolved_workflow_run_kwargs" + # State keys used to preserve caller-provided kwargs for nested workflow routing. RAW_FUNCTION_INVOCATION_KWARGS_KEY = "_raw_function_invocation_kwargs" RAW_CLIENT_KWARGS_KEY = "_raw_client_kwargs" -# Sentinel key used in resolved invocation kwargs dicts to denote global kwargs -# that apply to all executors (as opposed to per-executor keyed entries). +# Legacy sentinel used by plain mixed input and the compatibility state stored at +# WORKFLOW_RUN_KWARGS_KEY. New resolution state keeps global and executor-specific +# namespaces separate. GLOBAL_KWARGS_KEY = "__global__" diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 11d95fe5a79..84a43101c11 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -13,7 +13,7 @@ import weakref from collections.abc import AsyncIterable, Awaitable, Callable, Collection, Mapping, Sequence from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Literal, overload +from typing import TYPE_CHECKING, Any, Literal, cast, overload from .._sessions import ContextProvider from .._tools import ToolTypes, normalize_tools @@ -27,6 +27,7 @@ INTERNAL_SOURCE_ID, RAW_CLIENT_KWARGS_KEY, RAW_FUNCTION_INVOCATION_KWARGS_KEY, + RESOLVED_WORKFLOW_RUN_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY, ) from ._edge import ( @@ -576,25 +577,30 @@ async def _run_workflow_with_tracing( # explicitly provides new kwargs. if function_invocation_kwargs is not None or client_kwargs is not None: combined_kwargs: dict[str, Any] = {} + resolved_combined_kwargs: dict[str, Any] = {} if function_invocation_kwargs is not None: - combined_kwargs["function_invocation_kwargs"] = self._resolve_invocation_kwargs( + resolved = self._resolve_invocation_kwargs( function_invocation_kwargs, "function_invocation_kwargs" ) + resolved_combined_kwargs["function_invocation_kwargs"] = resolved + combined_kwargs["function_invocation_kwargs"] = self._to_legacy_invocation_kwargs(resolved) if isinstance(function_invocation_kwargs, WorkflowInvocationKwargs) or any( isinstance(value, Mapping) for value in function_invocation_kwargs.values() ): combined_kwargs[RAW_FUNCTION_INVOCATION_KWARGS_KEY] = function_invocation_kwargs if client_kwargs is not None: - combined_kwargs["client_kwargs"] = self._resolve_invocation_kwargs( - client_kwargs, "client_kwargs" - ) + resolved = self._resolve_invocation_kwargs(client_kwargs, "client_kwargs") + resolved_combined_kwargs["client_kwargs"] = resolved + combined_kwargs["client_kwargs"] = self._to_legacy_invocation_kwargs(resolved) if isinstance(client_kwargs, WorkflowInvocationKwargs) or any( isinstance(value, Mapping) for value in client_kwargs.values() ): combined_kwargs[RAW_CLIENT_KWARGS_KEY] = client_kwargs self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs) + self._runner.state.set(RESOLVED_WORKFLOW_RUN_KWARGS_KEY, resolved_combined_kwargs) elif not is_continuation: self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, {}) + self._runner.state.set(RESOLVED_WORKFLOW_RUN_KWARGS_KEY, {}) self._runner.state.commit() # Commit immediately so kwargs are available # Explicitly set streaming mode per run @@ -1125,13 +1131,13 @@ def _resolve_invocation_kwargs( kwargs: WorkflowInvocationKwargs | Mapping[str, Any], param_name: str, ) -> dict[str, Any]: - """Resolve invocation kwargs into a normalized per-executor or global format. + """Resolve invocation kwargs into collision-free global and executor namespaces. Detects whether the provided kwargs dict uses per-executor targeting by checking - if any top-level key matches a known executor ID in the workflow. If at least one - key matches, all entries are treated as per-executor. Otherwise the dict is treated - as global kwargs that apply to every executor. The ``"__global__"`` key can be used - explicitly to combine global kwargs with per-executor overrides. + if any top-level key matches a known executor ID in the workflow. A legacy + ``"__global__"`` slot is separated from matched executor entries unless that name + is itself a real executor ID. If no executor ID matches, the complete dict is + treated as global application kwargs. Args: kwargs: The raw invocation kwargs from the caller. @@ -1141,29 +1147,51 @@ def _resolve_invocation_kwargs( A dict containing normalized global or per-executor mappings. """ if isinstance(kwargs, WorkflowInvocationKwargs): - resolved = {GLOBAL_KWARGS_KEY: dict(kwargs.global_kwargs)} - resolved.update({ - executor_id: dict(executor_kwargs) for executor_id, executor_kwargs in kwargs.executor_kwargs.items() - }) logger.info("Explicit global %s provided with executor-specific overrides.", param_name) - return resolved + return { + "global_kwargs": dict(kwargs.global_kwargs), + "executor_kwargs": { + executor_id: dict(executor_kwargs) + for executor_id, executor_kwargs in kwargs.executor_kwargs.items() + }, + } executor_ids = set(self.executors.keys()) matched_ids = kwargs.keys() & executor_ids if matched_ids: + executor_kwargs = dict(kwargs) + if GLOBAL_KWARGS_KEY not in executor_ids and GLOBAL_KWARGS_KEY in executor_kwargs: + global_kwargs = executor_kwargs.pop(GLOBAL_KWARGS_KEY) + logger.info( + "Detected legacy mixed %s with global values and executor ID(s) %s.", + param_name, + matched_ids, + ) + return {"global_kwargs": global_kwargs, "executor_kwargs": executor_kwargs} logger.info( "Detected per-executor %s: executor ID(s) %s found in keys. " "All entries will be treated as per-executor.", param_name, matched_ids, ) - return dict(kwargs) + return {"executor_kwargs": executor_kwargs} logger.info( "No executor IDs found in %s keys; treating as global kwargs for all executors.", param_name, ) - return {GLOBAL_KWARGS_KEY: dict(kwargs)} + return {"global_kwargs": dict(kwargs), "executor_kwargs": {}} + + @staticmethod + def _to_legacy_invocation_kwargs(resolved: dict[str, Any]) -> dict[str, Any]: + """Encode collision-free state in the existing best-effort legacy format.""" + legacy: dict[str, Any] = {} + if "global_kwargs" in resolved: + legacy[GLOBAL_KWARGS_KEY] = resolved["global_kwargs"] + executor_kwargs = resolved.get("executor_kwargs") + if isinstance(executor_kwargs, dict): + legacy.update(cast(dict[str, Any], executor_kwargs)) + return legacy # Graph signature helpers diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 9e124db1d9c..18d93cf37d6 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -20,8 +20,13 @@ WorkflowRunState, ) from agent_framework._workflows._agent_executor import AgentExecutorResponse +from agent_framework._workflows._agent_utils import prepare_executor_run_kwargs from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage -from agent_framework._workflows._const import GLOBAL_KWARGS_KEY +from agent_framework._workflows._const import ( + GLOBAL_KWARGS_KEY, + RAW_CLIENT_KWARGS_KEY, + RAW_FUNCTION_INVOCATION_KWARGS_KEY, +) class _CountingAgent(BaseAgent): @@ -330,6 +335,34 @@ async def test_prepare_agent_run_args_returns_none_when_no_kwargs() -> None: assert ci_kwargs is None +async def test_prepare_executor_run_kwargs_resolves_channels_and_removes_internal_state() -> None: + """Executor-ready kwargs preserve options without leaking raw-routing snapshots.""" + raw = { + "function_invocation_kwargs": {GLOBAL_KWARGS_KEY: {"legacy": True}}, + "client_kwargs": {GLOBAL_KWARGS_KEY: {"legacy": True}}, + RAW_FUNCTION_INVOCATION_KWARGS_KEY: {"agent": {"raw": True}}, + RAW_CLIENT_KWARGS_KEY: {"agent": {"raw": True}}, + "options": {"temperature": 0.5}, + } + resolved = { + "function_invocation_kwargs": { + "global_kwargs": {"shared": "G"}, + "executor_kwargs": {"agent": {"specific": "A"}}, + }, + "client_kwargs": {"executor_kwargs": {"other": {"ignored": True}}}, + } + + actual = prepare_executor_run_kwargs("agent", raw, resolved) + + assert actual == { + "function_invocation_kwargs": {"shared": "G", "specific": "A"}, + "options": {"temperature": 0.5}, + } + assert prepare_executor_run_kwargs("agent", raw, {}) == {"options": {"temperature": 0.5}} + with pytest.raises(TypeError, match="Resolved workflow run kwargs state must be a dict"): + prepare_executor_run_kwargs("agent", raw, "invalid") + + class _NonCopyableRaw: """Simulates an LLM SDK response object that cannot be deep-copied (e.g., proto/gRPC).""" diff --git a/python/packages/core/tests/workflow/test_workflow_kwargs.py b/python/packages/core/tests/workflow/test_workflow_kwargs.py index d91d224c54f..6685c70827a 100644 --- a/python/packages/core/tests/workflow/test_workflow_kwargs.py +++ b/python/packages/core/tests/workflow/test_workflow_kwargs.py @@ -20,7 +20,13 @@ WorkflowInvocationKwargs, WorkflowRunState, ) -from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY +from agent_framework._workflows._const import ( + GLOBAL_KWARGS_KEY, + RAW_CLIENT_KWARGS_KEY, + RAW_FUNCTION_INVOCATION_KWARGS_KEY, + RESOLVED_WORKFLOW_RUN_KWARGS_KEY, + WORKFLOW_RUN_KWARGS_KEY, +) from agent_framework.orchestrations import ( ConcurrentBuilder, GroupChatBuilder, @@ -864,6 +870,137 @@ async def test_runtime_tools_are_not_written_to_checkpoints(tmp_path) -> None: assert "tools" not in run_kwargs +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_collision_free_kwargs_survive_file_checkpoint_restore(tmp_path: Any, kwargs_channel: str) -> None: + """A real __global__ executor remains distinct after file serialization and restore.""" + from agent_framework_orchestrations._orchestration_request_info import AgentRequestInfoResponse + + from agent_framework import FileCheckpointStorage + + storage = FileCheckpointStorage( + tmp_path, + allowed_checkpoint_types=[ + "agent_framework_orchestrations._orchestration_request_info:AgentRequestInfoResponse" + ], + ) + + def build_workflow() -> tuple[Any, _KwargsCapturingAgent, _KwargsCapturingAgent]: + global_agent = _KwargsCapturingAgent(name="__global__") + sibling = _KwargsCapturingAgent(name="sibling") + workflow = ( + SequentialBuilder(participants=[global_agent, sibling], checkpoint_storage=storage) + .with_request_info(agents=[global_agent]) + .build() + ) + return workflow, global_agent, sibling + + workflow, global_agent, _ = build_workflow() + invocation_kwargs = WorkflowInvocationKwargs( + global_kwargs={"shared": "G", "overridden": "global"}, + executor_kwargs={"__global__": {"special": "A", "overridden": "specific"}}, + ) + if kwargs_channel == "function_invocation_kwargs": + paused = await workflow.run("test", function_invocation_kwargs=invocation_kwargs) + else: + paused = await workflow.run("test", client_kwargs=invocation_kwargs) + [request] = paused.get_request_info_events() + assert global_agent.captured_kwargs[0].get(kwargs_channel) == { + "shared": "G", + "special": "A", + "overridden": "specific", + } + + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + checkpoint = max( + (item for item in checkpoints if item.pending_request_info_events), + key=lambda item: item.timestamp, + ) + resolved_state = checkpoint.state[RESOLVED_WORKFLOW_RUN_KWARGS_KEY] + assert resolved_state[kwargs_channel] == { + "global_kwargs": {"shared": "G", "overridden": "global"}, + "executor_kwargs": {"__global__": {"special": "A", "overridden": "specific"}}, + } + + resumed_workflow, _, resumed_sibling = build_workflow() + resumed = await resumed_workflow.run(checkpoint_id=checkpoint.checkpoint_id) + [resumed_request] = resumed.get_request_info_events() + assert resumed_request.request_id == request.request_id + await resumed_workflow.run( + responses={resumed_request.request_id: AgentRequestInfoResponse.approve()}, + ) + assert resumed_sibling.captured_kwargs[0].get(kwargs_channel) == { + "shared": "G", + "overridden": "global", + } + + +@pytest.mark.parametrize( + ("legacy_resolved", "expected"), + [ + ({GLOBAL_KWARGS_KEY: {"shared": "G"}}, {"shared": "G"}), + ({"sibling": {"specific": "B"}}, {"specific": "B"}), + ], + ids=["global", "per-executor"], +) +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_legacy_kwargs_file_checkpoint_remains_readable( + tmp_path: Any, + kwargs_channel: str, + legacy_resolved: dict[str, Any], + expected: dict[str, Any], +) -> None: + """Non-ambiguous legacy dict state remains readable after file restore.""" + from agent_framework_orchestrations._orchestration_request_info import AgentRequestInfoResponse + + from agent_framework import FileCheckpointStorage + + storage = FileCheckpointStorage( + tmp_path, + allowed_checkpoint_types=[ + "agent_framework_orchestrations._orchestration_request_info:AgentRequestInfoResponse" + ], + ) + + def build_workflow() -> tuple[Any, _KwargsCapturingAgent]: + first = _KwargsCapturingAgent(name="first") + sibling = _KwargsCapturingAgent(name="sibling") + workflow = ( + SequentialBuilder(participants=[first, sibling], checkpoint_storage=storage) + .with_request_info(agents=[first]) + .build() + ) + return workflow, sibling + + workflow, _ = build_workflow() + if kwargs_channel == "function_invocation_kwargs": + paused = await workflow.run("test", function_invocation_kwargs={"seed": True}) + else: + paused = await workflow.run("test", client_kwargs={"seed": True}) + [request] = paused.get_request_info_events() + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + checkpoint = max( + (item for item in checkpoints if item.pending_request_info_events), + key=lambda item: item.timestamp, + ) + run_kwargs = checkpoint.state[WORKFLOW_RUN_KWARGS_KEY] + run_kwargs[kwargs_channel] = legacy_resolved + run_kwargs.pop( + RAW_FUNCTION_INVOCATION_KWARGS_KEY if kwargs_channel == "function_invocation_kwargs" else RAW_CLIENT_KWARGS_KEY, + None, + ) + checkpoint.state.pop(RESOLVED_WORKFLOW_RUN_KWARGS_KEY, None) + await storage.save(checkpoint) + + resumed_workflow, resumed_sibling = build_workflow() + resumed = await resumed_workflow.run(checkpoint_id=checkpoint.checkpoint_id) + [resumed_request] = resumed.get_request_info_events() + assert resumed_request.request_id == request.request_id + await resumed_workflow.run( + responses={resumed_request.request_id: AgentRequestInfoResponse.approve()}, + ) + assert resumed_sibling.captured_kwargs[0].get(kwargs_channel) == expected + + # endregion @@ -1100,6 +1237,65 @@ async def test_mixed_kwargs_route_through_subworkflow() -> None: } +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_legacy_mixed_kwargs_route_through_subworkflow(kwargs_channel: str) -> None: + """A child graph reclassifies preserved legacy mixed input against its executor IDs.""" + from agent_framework._workflows._workflow_executor import WorkflowExecutor + + inner1 = _KwargsCapturingAgent(name="inner1") + inner2 = _KwargsCapturingAgent(name="inner2") + child = SequentialBuilder(participants=[inner1, inner2]).build() + parent = SequentialBuilder(participants=[WorkflowExecutor(child, id="subworkflow")]).build() + invocation_kwargs = { + "__global__": {"shared": "G", "overridden": "global"}, + "inner1": {"specific": "A", "overridden": "specific"}, + } + + if kwargs_channel == "function_invocation_kwargs": + await parent.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await parent.run("test", client_kwargs=invocation_kwargs) + + actual = tuple(agent.captured_kwargs[0].get(kwargs_channel) for agent in (inner1, inner2)) + assert actual == ( + {"shared": "G", "specific": "A", "overridden": "specific"}, + {"shared": "G", "overridden": "global"}, + ) + + +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +@pytest.mark.parametrize("typed", [True, False], ids=["typed", "plain"]) +async def test_real_global_executor_collision_routes_through_subworkflow(kwargs_channel: str, typed: bool) -> None: + """A child workflow preserves a real __global__ executor namespace.""" + from agent_framework._workflows._workflow_executor import WorkflowExecutor + + actual_global = _KwargsCapturingAgent(name="__global__") + sibling = _KwargsCapturingAgent(name="sibling") + child = SequentialBuilder(participants=[actual_global, sibling]).build() + parent = SequentialBuilder(participants=[WorkflowExecutor(child, id="subworkflow")]).build() + expected: tuple[dict[str, Any] | None, dict[str, Any] | None] + if typed: + invocation_kwargs: Mapping[str, Any] | WorkflowInvocationKwargs = WorkflowInvocationKwargs( + global_kwargs={"shared": "G", "overridden": "global"}, + executor_kwargs={"__global__": {"special": "A", "overridden": "specific"}}, + ) + expected = ( + {"shared": "G", "special": "A", "overridden": "specific"}, + {"shared": "G", "overridden": "global"}, + ) + else: + invocation_kwargs = {"__global__": {"special": "A"}} + expected = ({"special": "A"}, None) + + if kwargs_channel == "function_invocation_kwargs": + await parent.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await parent.run("test", client_kwargs=invocation_kwargs) + + actual = tuple(agent.captured_kwargs[0].get(kwargs_channel) for agent in (actual_global, sibling)) + assert actual == expected + + # endregion @@ -1131,6 +1327,115 @@ async def test_function_and_client_kwargs_together() -> None: assert agent.captured_kwargs[0].get("client_kwargs") == ci_kwargs +@pytest.mark.parametrize( + ("executor_ids", "invocation_kwargs", "expected"), + [ + ( + ("agent1", "sibling"), + { + "__global__": {"shared": "G", "overridden": "global"}, + "agent1": {"specific": "A", "overridden": "specific"}, + }, + ( + {"shared": "G", "specific": "A", "overridden": "specific"}, + {"shared": "G", "overridden": "global"}, + ), + ), + ( + ("agent1", "sibling"), + {"__global__": "tenant-a", "agent1": {"specific": "A"}}, + (None, None), + ), + ( + ("agent1", "sibling"), + {"__global__": {"tenant": "tenant-a"}, "other_option": 123}, + ( + {"__global__": {"tenant": "tenant-a"}, "other_option": 123}, + {"__global__": {"tenant": "tenant-a"}, "other_option": 123}, + ), + ), + ( + ("__global__", "agent1"), + {"__global__": {"global_executor": True}, "agent1": {"agent1": True}}, + ({"global_executor": True}, {"agent1": True}), + ), + ( + ("__global__", "agent1"), + {"__global__": "invalid", "agent1": {"specific": "A"}}, + (None, {"specific": "A"}), + ), + ( + ("__global__", "sibling"), + WorkflowInvocationKwargs( + global_kwargs={"shared": "G", "overridden": "global"}, + executor_kwargs={"__global__": {"special": "A", "overridden": "specific"}}, + ), + ( + {"shared": "G", "special": "A", "overridden": "specific"}, + {"shared": "G", "overridden": "global"}, + ), + ), + ( + ("agent1", "sibling"), + WorkflowInvocationKwargs( + global_kwargs={"shared": "G"}, + executor_kwargs={"__global__": {"special": "A"}}, + ), + ({"shared": "G"}, {"shared": "G"}), + ), + ( + ("__global__", "sibling"), + {"__global__": {"special": "A"}}, + ({"special": "A"}, None), + ), + ], + ids=[ + "legacy-mixed", + "legacy-mixed-invalid-global", + "mapping-valued-application-global", + "real-global-plus-target", + "malformed-real-global-plus-target", + "typed-global-collision", + "typed-unmatched-global-executor", + "plain-global-collision", + ], +) +@pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) +async def test_invocation_kwargs_compatibility_boundaries( + kwargs_channel: str, + executor_ids: tuple[str, str], + invocation_kwargs: Mapping[str, Any] | WorkflowInvocationKwargs, + expected: tuple[dict[str, Any] | None, dict[str, Any] | None], + caplog: "LogCaptureFixture", +) -> None: + """Preserve legacy boundaries while separating a real __global__ executor.""" + agents = [_KwargsCapturingAgent(name=executor_id) for executor_id in executor_ids] + workflow = SequentialBuilder(participants=agents).build() + + if kwargs_channel == "function_invocation_kwargs": + await workflow.run("test", function_invocation_kwargs=invocation_kwargs) + else: + await workflow.run("test", client_kwargs=invocation_kwargs) + + actual = tuple(agent.captured_kwargs[0].get(kwargs_channel) for agent in agents) + assert actual == expected + if isinstance(invocation_kwargs, Mapping) and invocation_kwargs.get("__global__") == "tenant-a": + assert sum("expected a dict for global kwargs" in record.message for record in caplog.records) == 2 + if ( + isinstance(invocation_kwargs, Mapping) + and executor_ids[0] == "__global__" + and invocation_kwargs.get("__global__") == "invalid" + ): + matching_warnings = [ + record.message + for record in caplog.records + if record.levelname == "WARNING" and record.name == "agent_framework._workflows._agent_utils" + ] + assert matching_warnings == [ + "Executor __global__ expected a dict for its kwargs, but got . Ignoring." + ] + + async def test_global_function_invocation_kwargs_flow_to_all_agents() -> None: """Global function_invocation_kwargs should be received by all agents in a sequential workflow.""" agent1 = _KwargsCapturingAgent(name="agent1") diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py index 190393f6510..ee15de2dc5b 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -788,9 +788,19 @@ async def _invoke_agent_and_store_results( _validate_conversation_history(messages_for_agent, agent_name) # Retrieve kwargs passed to workflow.run() so they propagate to agent tools - from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY - - run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) + from agent_framework._workflows import _agent_utils as workflow_agent_utils + from agent_framework._workflows import _const as workflow_const + + run_kwargs: dict[str, Any] = ctx.get_state(workflow_const.WORKFLOW_RUN_KWARGS_KEY, {}) + prepare_run_kwargs = getattr(workflow_agent_utils, "prepare_executor_run_kwargs", None) + resolved_state_key = getattr(workflow_const, "RESOLVED_WORKFLOW_RUN_KWARGS_KEY", None) + if callable(prepare_run_kwargs) and isinstance(resolved_state_key, str): + missing_resolved_state = object() + resolved_run_kwargs: Any = ctx.get_state(resolved_state_key, missing_resolved_state) + if resolved_run_kwargs is not missing_resolved_state: + if not isinstance(resolved_run_kwargs, dict): + raise TypeError("Resolved workflow run kwargs state must be a dict.") + run_kwargs = cast(Any, prepare_run_kwargs)(self.id, run_kwargs, resolved_run_kwargs) options: dict[str, Any] | None = None if run_kwargs: # Merge caller-provided options to avoid duplicate keyword argument diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index 90a72c0c3da..43662c82c2c 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -68,6 +68,7 @@ def mock_context(mock_state: MagicMock) -> MagicMock: """Create a mock workflow context.""" ctx = MagicMock() ctx.state = mock_state + ctx.get_state = MagicMock(side_effect=mock_state.get) ctx.send_message = AsyncMock() ctx.yield_output = AsyncMock() ctx.request_info = AsyncMock() diff --git a/python/packages/declarative/tests/test_graph_executors.py b/python/packages/declarative/tests/test_graph_executors.py index 81579740be0..4fb9e6e1c85 100644 --- a/python/packages/declarative/tests/test_graph_executors.py +++ b/python/packages/declarative/tests/test_graph_executors.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework import WorkflowInvocationKwargs try: import powerfx # noqa: F401 @@ -1746,6 +1747,178 @@ def test_eval_passes_through_plain_strings_without_engine(self): class TestExecutorKwargsForwarding: """Workflow run kwargs should be forwarded through executor agent invocations.""" + @pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) + @pytest.mark.parametrize( + ("executor_ids", "invocation_kwargs", "expected"), + [ + ( + ("agent1", "sibling"), + { + "__global__": {"shared": "G", "overridden": "global"}, + "agent1": {"specific": "A", "overridden": "specific"}, + }, + ( + {"shared": "G", "specific": "A", "overridden": "specific"}, + {"shared": "G", "overridden": "global"}, + ), + ), + ( + ("__global__", "sibling"), + WorkflowInvocationKwargs( + global_kwargs={"shared": "G", "overridden": "global"}, + executor_kwargs={"__global__": {"specific": "A", "overridden": "specific"}}, + ), + ( + {"shared": "G", "specific": "A", "overridden": "specific"}, + {"shared": "G", "overridden": "global"}, + ), + ), + ], + ids=["legacy-mixed", "global-executor-collision"], + ) + async def test_workflow_run_resolves_executor_kwargs( + self, + kwargs_channel: str, + executor_ids: tuple[str, str], + invocation_kwargs: dict[str, Any] | WorkflowInvocationKwargs, + expected: tuple[dict[str, Any], dict[str, Any]], + ) -> None: + """The public declarative path resolves legacy mixed and collision-free state.""" + agents: dict[str, Any] = {} + actions: list[dict[str, Any]] = [] + for index, executor_id in enumerate(executor_ids): + agent_name = f"agent_{index}" + response = MagicMock(text="response", messages=[], tool_calls=[]) + agent = MagicMock(name=agent_name) + agent.run = AsyncMock(return_value=response) + agents[agent_name] = agent + actions.append({"kind": "InvokeAzureAgent", "id": executor_id, "agent": agent_name, "input": "hello"}) + + workflow = DeclarativeWorkflowBuilder( + {"name": "kwargs_workflow", "actions": actions}, + agents=agents, + ).build() + if kwargs_channel == "function_invocation_kwargs": + await workflow.run(ActionTrigger(), function_invocation_kwargs=invocation_kwargs) + else: + await workflow.run(ActionTrigger(), client_kwargs=invocation_kwargs) + + for agent, expected_kwargs in zip(agents.values(), expected, strict=True): + call_kwargs = agent.run.call_args.kwargs + assert call_kwargs[kwargs_channel] == expected_kwargs + assert call_kwargs["options"]["additional_function_arguments"] == {kwargs_channel: expected_kwargs} + assert "_raw_function_invocation_kwargs" not in call_kwargs + assert "_raw_client_kwargs" not in call_kwargs + + @pytest.mark.parametrize("kwargs_channel", ["function_invocation_kwargs", "client_kwargs"]) + @pytest.mark.parametrize("legacy_checkpoint", [False, True], ids=["new-state", "legacy-state"]) + async def test_workflow_run_kwargs_survive_file_checkpoint_restore( + self, + tmp_path: Any, + kwargs_channel: str, + legacy_checkpoint: bool, + ) -> None: + """New checkpoints resolve kwargs while legacy checkpoints keep historical forwarding.""" + from agent_framework import FileCheckpointStorage + from agent_framework._workflows._const import ( + RESOLVED_WORKFLOW_RUN_KWARGS_KEY, + WORKFLOW_RUN_KWARGS_KEY, + ) + + from agent_framework_declarative._workflows._executors_external_input import ExternalInputResponse + + storage = FileCheckpointStorage( + tmp_path, + allowed_checkpoint_types=[ + "agent_framework_declarative._workflows._declarative_base:ActionComplete", + "agent_framework_declarative._workflows._declarative_base:ActionTrigger", + "agent_framework_declarative._workflows._executors_external_input:ExternalInputRequest", + "agent_framework_declarative._workflows._executors_external_input:ExternalInputResponse", + ], + ) + executor_id = "sibling" if legacy_checkpoint else "__global__" + + def build_workflow() -> tuple[Any, Any]: + response = MagicMock(text="response", messages=[], tool_calls=[]) + agent = MagicMock(name="checkpoint_agent") + agent.run = AsyncMock(return_value=response) + workflow = DeclarativeWorkflowBuilder( + { + "name": "declarative_kwargs_checkpoint", + "actions": [ + { + "kind": "RequestExternalInput", + "id": "pause", + "prompt": "Continue?", + "variable": "Local.answer", + }, + { + "kind": "InvokeAzureAgent", + "id": executor_id, + "agent": "checkpoint_agent", + "input": "hello", + }, + ], + }, + agents={"checkpoint_agent": agent}, + checkpoint_storage=storage, + ).build() + return workflow, agent + + if legacy_checkpoint: + invocation_kwargs: dict[str, Any] | WorkflowInvocationKwargs = { + "__global__": {"shared": "G"}, + "sibling": {"specific": "S"}, + } + else: + invocation_kwargs = WorkflowInvocationKwargs( + global_kwargs={"shared": "G"}, + executor_kwargs={"__global__": {"specific": "S"}}, + ) + + workflow, _ = build_workflow() + if kwargs_channel == "function_invocation_kwargs": + paused = await workflow.run(ActionTrigger(), function_invocation_kwargs=invocation_kwargs) + else: + paused = await workflow.run(ActionTrigger(), client_kwargs=invocation_kwargs) + [request] = paused.get_request_info_events() + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + checkpoint = max( + (item for item in checkpoints if item.pending_request_info_events), + key=lambda item: item.timestamp, + ) + if legacy_checkpoint: + checkpoint.state.pop(RESOLVED_WORKFLOW_RUN_KWARGS_KEY, None) + await storage.save(checkpoint) + else: + assert checkpoint.state[RESOLVED_WORKFLOW_RUN_KWARGS_KEY][kwargs_channel] + assert isinstance(checkpoint.state[WORKFLOW_RUN_KWARGS_KEY][kwargs_channel], dict) + + resumed_workflow, resumed_agent = build_workflow() + resumed = await resumed_workflow.run(checkpoint_id=checkpoint.checkpoint_id) + [resumed_request] = resumed.get_request_info_events() + assert resumed_request.request_id == request.request_id + await resumed_workflow.run( + responses={resumed_request.request_id: ExternalInputResponse(user_input="yes")}, + ) + + call_kwargs = resumed_agent.run.call_args.kwargs + if legacy_checkpoint: + raw_key = ( + "_raw_function_invocation_kwargs" + if kwargs_channel == "function_invocation_kwargs" + else "_raw_client_kwargs" + ) + expected_run_kwargs = {kwargs_channel: invocation_kwargs, raw_key: invocation_kwargs} + assert call_kwargs[kwargs_channel] == invocation_kwargs + assert call_kwargs[raw_key] == invocation_kwargs + else: + expected_run_kwargs = {kwargs_channel: {"shared": "G", "specific": "S"}} + assert call_kwargs[kwargs_channel] == {"shared": "G", "specific": "S"} + assert "_raw_function_invocation_kwargs" not in call_kwargs + assert "_raw_client_kwargs" not in call_kwargs + assert call_kwargs["options"]["additional_function_arguments"] == expected_run_kwargs + @pytest.mark.asyncio async def test_invoke_agent_forwards_kwargs(self): """InvokeAzureAgentExecutor should forward run_kwargs to agent.run().""" @@ -1791,6 +1964,7 @@ def mock_set(key, value): mock_ctx.yield_output = AsyncMock() executor = InvokeAzureAgentExecutor.__new__(InvokeAzureAgentExecutor) + executor.id = "test_agent" executor._agents = {"test_agent": mock_agent} await executor._invoke_agent_and_store_results( @@ -1861,6 +2035,7 @@ def mock_set(key, value): mock_ctx.yield_output = AsyncMock() executor = InvokeAzureAgentExecutor.__new__(InvokeAzureAgentExecutor) + executor.id = "test_agent" executor._agents = {"test_agent": mock_agent} await executor._invoke_agent_and_store_results( diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index 879e09236cf..9e364989e5a 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -34,7 +34,7 @@ from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import prepare_agent_run_args, resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage -from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY +from agent_framework._workflows._const import RESOLVED_WORKFLOW_RUN_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY from agent_framework._workflows._executor import Executor from agent_framework._workflows._workflow import Workflow from agent_framework._workflows._workflow_context import WorkflowContext @@ -496,7 +496,8 @@ async def _invoke_agent(self, ctx: WorkflowContext[Any, Any]) -> AgentOrchestrat those itself or it is the only agent in the group chat that does not see them. """ raw_run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) - function_invocation_kwargs, client_kwargs = prepare_agent_run_args(self.id, raw_run_kwargs) + resolved_run_kwargs: Any = ctx.get_state(RESOLVED_WORKFLOW_RUN_KWARGS_KEY) + function_invocation_kwargs, client_kwargs = prepare_agent_run_args(self.id, raw_run_kwargs, resolved_run_kwargs) async def _invoke_agent_helper(conversation: list[Message]) -> AgentOrchestrationOutput: # Run the agent in non-streaming mode for simplicity diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 92d600ba9ce..725666ac6cb 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -16,6 +16,7 @@ Content, Message, WorkflowEvent, + WorkflowInvocationKwargs, WorkflowRunState, ) from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage @@ -324,6 +325,31 @@ async def test_agent_manager_receives_workflow_run_kwargs() -> None: assert call.get("client_kwargs") == {"trace_id": "trace-abc"} +async def test_agent_manager_receives_resolved_global_and_specific_run_kwargs() -> None: + """The #8312 GroupChat path consumes collision-free state through the shared resolver.""" + manager = KwargsRecordingManagerAgent() + worker = StubAgent("agent", "worker response") + workflow = GroupChatBuilder(participants=[worker], orchestrator_agent=manager).build() + invocation_kwargs = WorkflowInvocationKwargs( + global_kwargs={"shared": "G", "overridden": "global"}, + executor_kwargs={"manager_agent": {"specific": "M", "overridden": "specific"}}, + ) + + async for _ in workflow.run( + "coordinate task", + stream=True, + function_invocation_kwargs=invocation_kwargs, + client_kwargs=invocation_kwargs, + ): + pass + + expected = {"shared": "G", "specific": "M", "overridden": "specific"} + assert manager.seen_kwargs + for call in manager.seen_kwargs: + assert call.get("function_invocation_kwargs") == expected + assert call.get("client_kwargs") == expected + + async def test_agent_manager_receives_no_run_kwargs_when_none_supplied() -> None: """With nothing declared on the run, the orchestrator is invoked with both kwargs as None. From 80f091513879d07a9a96e9f24c645f75cef7ad2a Mon Sep 17 00:00:00 2001 From: CoralGarden52 <97677340+CoralGarden52@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:40:40 +0000 Subject: [PATCH 070/108] Python: validate functional workflow HITL response types (#8309) * Python: validate functional workflow response types * test: cover functional workflow response coercion * refactor: share workflow response validation --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework/_workflows/_functional.py | 5 ++- .../agent_framework/_workflows/_workflow.py | 22 ++++++----- .../workflow/test_functional_workflow.py | 39 +++++++++++++++++++ .../core/tests/workflow/test_workflow.py | 18 +++++++++ 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_functional.py b/python/packages/core/agent_framework/_workflows/_functional.py index e83fa9e0e5c..1e1d4697d10 100644 --- a/python/packages/core/agent_framework/_workflows/_functional.py +++ b/python/packages/core/agent_framework/_workflows/_functional.py @@ -65,7 +65,7 @@ WorkflowRunState, _framework_event, ) -from ._workflow import WorkflowRunResult +from ._workflow import WorkflowRunResult, _coerce_request_info_response logger = logging.getLogger(__name__) @@ -244,6 +244,9 @@ async def request_info( found, value = self._get_response(rid) if found: self._pending_requests.pop(rid, None) + # Functional workflows intentionally allow None responses; _set_responses logs a warning for them. + if value is not None: + value = _coerce_request_info_response(value, response_type, rid) return value # No response — emit event and interrupt diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 84a43101c11..18fb5c9a4db 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -58,6 +58,18 @@ _MISSING: Any = object() +def _coerce_request_info_response(value: Any, response_type: type, request_id: str) -> Any: + """Convert and validate a response supplied for a pending request.""" + if response_type is Content and isinstance(value, str): + value = Content.from_text(text=value) + value = try_coerce_to_type(value, response_type) + if not is_instance_of(value, response_type): + raise ValueError( + f"Response type mismatch for request ID {request_id}: expected {response_type}, got {type(value)}" + ) + return value + + def _coalesce_renamed_kwarg(old_name: str, old_value: Any, new_name: str, new_value: Any) -> Any: """Resolve a renamed keyword argument while keeping the deprecated name working. @@ -1081,15 +1093,7 @@ async def _send_responses_internal(self, responses: Mapping[str, Any]) -> None: if request_id not in pending_requests: raise ValueError(f"Response provided for unknown request ID: {request_id}") pending_request = pending_requests[request_id] - if pending_request.response_type is Content and isinstance(response, str): - response = Content.from_text(text=response) - # Try to coerce raw values (e.g., dicts from JSON) to the expected type - response = try_coerce_to_type(response, pending_request.response_type) - if not is_instance_of(response, pending_request.response_type): - raise ValueError( - f"Response type mismatch for request ID {request_id}: " - f"expected {pending_request.response_type}, got {type(response)}" - ) + response = _coerce_request_info_response(response, pending_request.response_type, request_id) coerced_responses[request_id] = response # Cancelling siblings on error, like every other concurrent write into runner state. Each diff --git a/python/packages/core/tests/workflow/test_functional_workflow.py b/python/packages/core/tests/workflow/test_functional_workflow.py index 5ed7ae0e8a3..395920bf22c 100644 --- a/python/packages/core/tests/workflow/test_functional_workflow.py +++ b/python/packages/core/tests/workflow/test_functional_workflow.py @@ -18,6 +18,7 @@ from agent_framework import ( AgentResponseUpdate, CheckpointStorage, + Content, ExperimentalFeature, FunctionalWorkflow, FunctionalWorkflowAgent, @@ -332,6 +333,44 @@ async def review_wf(doc: str, ctx: RunContext) -> str: assert outputs == ["Final: Looks great!"] assert result2.get_final_state() == WorkflowRunState.IDLE + async def test_request_info_resume_rejects_response_type_mismatch(self): + @built_workflow + async def typed_wf(data: str, ctx: RunContext) -> str: + answer = await ctx.request_info("number", response_type=int, request_id="typed") + return f"{answer}:{type(answer).__name__}" + + await typed_wf.run("input") + + with pytest.raises(ValueError, match="Response type mismatch for request ID typed"): + await typed_wf.run(responses={"typed": "not-an-int"}) + + async def test_request_info_resume_coerces_json_like_response(self): + @dataclass + class Decision: + approved: bool + + @built_workflow + async def typed_wf(data: str, ctx: RunContext) -> str: + decision = await ctx.request_info("decision", response_type=Decision, request_id="decision") + return f"{decision.approved}:{type(decision).__name__}" + + await typed_wf.run("input") + result = await typed_wf.run(responses={"decision": {"approved": True}}) + + assert result.get_outputs() == ["True:Decision"] + + async def test_request_info_resume_converts_text_to_content(self): + @built_workflow + async def content_wf(data: str, ctx: RunContext) -> str: + answer = await ctx.request_info("message", response_type=Content, request_id="content") + assert isinstance(answer, Content) + return f"{answer.type}:{answer.text}" + + await content_wf.run("input") + result = await content_wf.run(responses={"content": "hello"}) + + assert result.get_outputs() == ["text:hello"] + async def test_fresh_message_while_pending_requests_warns(self, caplog: pytest.LogCaptureFixture) -> None: """A fresh message while request_info events are pending is allowed but logs a warning.""" diff --git a/python/packages/core/tests/workflow/test_workflow.py b/python/packages/core/tests/workflow/test_workflow.py index 014694b9b80..762eff1d36e 100644 --- a/python/packages/core/tests/workflow/test_workflow.py +++ b/python/packages/core/tests/workflow/test_workflow.py @@ -111,6 +111,24 @@ async def mock_handler_b( await ctx.send_message(NumberMessage(data=data)) +def test_coerce_request_info_response_converts_content() -> None: + """Request responses should use the shared Content conversion path.""" + from agent_framework._workflows._workflow import _coerce_request_info_response + + response = _coerce_request_info_response("hello", Content, "content") + + assert isinstance(response, Content) + assert response.text == "hello" + + +def test_coerce_request_info_response_rejects_mismatched_type() -> None: + """Request responses that cannot be validated should report their request ID.""" + from agent_framework._workflows._workflow import _coerce_request_info_response + + with pytest.raises(ValueError, match="Response type mismatch for request ID typed"): + _coerce_request_info_response("not-an-int", int, "typed") + + async def test_fresh_message_while_pending_advances_state_without_abandoning_requests( caplog: pytest.LogCaptureFixture, ) -> None: From ed6386f434f28dde36a602a281d8e4feeaad1db5 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 15 Sep 2026 05:55:02 +0000 Subject: [PATCH 071/108] Python: Separate hosted and local shell calls (#8294) * Python: Separate hosted and local shell calls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Preserve local shell approval default Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Distinguish local shell environments Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Format shell call tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Preserve shell transcript replay Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../specs/004-python-function-calling-loop.md | 6 + .../agent_framework_openai/_chat_client.py | 131 ++++-- .../tests/openai/test_openai_chat_client.py | 380 +++++++++++++++--- 3 files changed, 425 insertions(+), 92 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 23526ba47c0..bf7d1ed72a3 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -392,6 +392,11 @@ that manually replay messages own the equivalent rule: do not resend an approval Changing arguments after security middleware has processed them fails closed with `MiddlewareFailure`. - Argument-validation failures after middleware retain the established `Argument parsing failed` result contract. Exceptions raised by middleware or the tool body retain the separate `Function failed` contract. +- Provider-hosted shell calls remain informational transcript content. Only a well-formed explicit `local_shell_call` + or a shell call marked with a local environment, paired with a configured local executor, can enter the local + function loop and its approval boundary. Stateless replay preserves provider shell call/output items in their + original shape and fails explicitly when that provider representation is unavailable or inconsistent. Locally + generated shell outputs are sent in both service-side and stateless continuation modes. ### Reasoning-bound calls @@ -514,6 +519,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Calls across response messages | Every actionable call is executed once. | `test_base_client_executes_function_calls_across_multiple_response_messages` | | Parallel calls | Results retain the corresponding call ids and execution count. | `test_max_function_calls_limits_parallel_invocations`, `test_streaming_multiple_function_calls_parallel_execution` | | Informational-only call | The call is returned but not executed or approved. | `test_informational_only_function_call_is_not_invoked`, `test_informational_only_function_call_does_not_request_approval`, `test_streaming_informational_only_function_call_is_not_invoked` | +| OpenAI hosted/local shell boundary | Hosted shell calls remain informational in streaming and non-streaming responses even when a local executor is configured; only valid explicit local-shell items or shell calls marked with a local environment can execute, local execution preserves its configured approval mode, stateless loops preserve the complete provider shell transcript or fail explicitly, and locally generated shell outputs are sent in every continuation mode. | `packages/openai/tests/openai/test_openai_chat_client.py::test_response_content_creation_with_shell_call_remains_hosted_with_local_tool`, `test_parse_chunk_from_openai_shell_call_done_remains_hosted`, `test_parse_chunk_from_openai_local_environment_shell_call_done_emits_command`, `test_mixed_shell_calls_only_invoke_explicit_local_shell_call`, `test_stateless_shell_transcript_without_provider_item_fails`, `test_prepare_messages_keeps_local_shell_output_under_storage`, `test_malformed_local_environment_shell_call_is_not_executable`, `test_response_content_creation_with_local_shell_call_maps_to_function_call`, `test_malformed_local_shell_call_is_not_executable`, `test_response_function_call_named_local_shell_is_informational`, `test_parse_chunk_function_call_named_local_shell_is_informational`, `test_local_shell_tool_requires_approval_before_function_loop_execution` | | Declaration-only call | The call is surfaced as user input and is not executed; streaming arguments appear once while finalized request metadata remains available. | `test_declaration_only_tool`, `test_streaming_declaration_only_tool_preserves_metadata_without_duplicate_arguments` | | Function invocation disabled | The client bypasses the invocation loop without losing invocation kwargs. | `test_function_invocation_config_enabled_false`, `test_function_invocation_config_enabled_false_preserves_invocation_kwargs`, `test_streaming_function_invocation_config_enabled_false` | | Runtime tool changes | Added tools become available on the next iteration and retain approval behavior. | `test_add_tools_available_next_iteration`, `test_add_tools_with_approval_required_tool` | diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 60c6c2aafd9..aa0937658c7 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -76,6 +76,8 @@ from openai.types.responses import ( FunctionShellToolParam, ResponseCustomToolCall, + ResponseFunctionShellToolCall, + ResponseFunctionShellToolCallOutput, ResponseToolSearchCall, response_create_params, ) @@ -85,6 +87,7 @@ ParsedResponse, ) from openai.types.responses.response import Response as OpenAIResponse +from openai.types.responses.response_input_item_param import LocalShellCall from openai.types.responses.response_stream_event import ( ResponseStreamEvent as OpenAIResponseStreamEvent, ) @@ -96,7 +99,7 @@ Mcp, ) from openai.types.responses.web_search_tool_param import WebSearchToolParam -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter, ValidationError from ._exceptions import OpenAIContentFilterException from ._feature_usage import FeatureIndex @@ -1742,16 +1745,6 @@ def _prepare_message_for_openai( serialized_reasoning_ids.add(content.id) continue case "function_result": - if request_uses_service_side_storage: - props = content.additional_properties or {} - # Local-shell variant serializes as `local_shell_call` carrying a server-issued id; - # plain function_call_output pairs by call_id and is safe under storage. - if props.get( - OPENAI_SHELL_OUTPUT_TYPE_KEY - ) == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL and props.get( - OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY - ): - continue new_args: dict[str, Any] = {} new_args.update( self._prepare_content_for_openai( @@ -1771,7 +1764,25 @@ def _prepare_message_for_openai( replays_local_storage=replays_local_storage, ) if function_call: + if function_call.get("type") in {"shell_call", "local_shell_call"} and ( + "content" in args or "tool_calls" in args + ): + all_messages.append(args) + args = {"type": "message", "role": message.role} all_messages.append(function_call) + case "shell_tool_call" | "shell_tool_result": + if request_uses_service_side_storage: + continue + if "content" in args or "tool_calls" in args: + all_messages.append(args) + args = {"type": "message", "role": message.role} + all_messages.append( + self._prepare_content_for_openai( + message.role, + content, + replays_local_storage=replays_local_storage, + ) + ) case "function_approval_request": # Service-stored hosted requests are already present remotely, and local approvals # are resolved in-process; neither should be serialized as an MCP input item. @@ -1984,6 +1995,11 @@ def _prepare_content_for_openai( return _attach_prompt_cache_breakpoint(file_obj, content) return {} case "function_call": + shell_output_type = content.additional_properties.get(OPENAI_SHELL_OUTPUT_TYPE_KEY) + if shell_output_type == OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL: + return self._prepare_shell_transcript_item_for_openai(content, expected_type="shell_call") + if shell_output_type == OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL: + return self._prepare_shell_transcript_item_for_openai(content, expected_type="local_shell_call") if not content.call_id: logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'") return {} @@ -2006,6 +2022,10 @@ def _prepare_content_for_openai( if status := content.additional_properties.get("status"): function_call_obj["status"] = status return function_call_obj + case "shell_tool_call": + return self._prepare_shell_transcript_item_for_openai(content, expected_type="shell_call") + case "shell_tool_result": + return self._prepare_shell_transcript_item_for_openai(content, expected_type="shell_call_output") case "function_result": shell_output_type = ( content.additional_properties.get(OPENAI_SHELL_OUTPUT_TYPE_KEY) @@ -2175,11 +2195,6 @@ def _to_shell_call_output_payload(content: Content) -> list[dict[str, Any]]: } ] - @staticmethod - def _join_shell_commands(commands: Sequence[str]) -> str: - """Join shell commands into a single executable command string.""" - return "\n".join(command for command in commands if command).strip() - def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) -> list[Content]: """Convert a shell output item into framework ``Content`` objects. @@ -2193,21 +2208,34 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) contents: list[Content] = [] item_type = getattr(item, "type", None) if item_type == "shell_call": - shell_call_id = getattr(item, "call_id", None) or "" - shell_commands: list[str] = [] - shell_timeout_ms: int | None = None - shell_max_output: int | None = None - if action := getattr(item, "action", None): - shell_commands = list(getattr(action, "commands", []) or []) - shell_timeout_ms = getattr(action, "timeout_ms", None) - shell_max_output = getattr(action, "max_output_length", None) - if local_shell_tool_name: - command_text = self._join_shell_commands(shell_commands) + raw_shell_call_id = getattr(item, "call_id", None) + shell_call_id = raw_shell_call_id if isinstance(raw_shell_call_id, str) else "" + raw_shell_call_item_id = getattr(item, "id", None) + shell_call_item_id = raw_shell_call_item_id if isinstance(raw_shell_call_item_id, str) else "" + action = getattr(item, "action", None) + raw_shell_commands = getattr(action, "commands", None) + shell_commands: list[str] = ( + cast("list[str]", raw_shell_commands) + if isinstance(raw_shell_commands, list) + and raw_shell_commands + and all(isinstance(command, str) for command in cast("list[object]", raw_shell_commands)) + else [] + ) + shell_timeout_ms = getattr(action, "timeout_ms", None) + shell_max_output = getattr(action, "max_output_length", None) + is_local_environment = getattr(getattr(item, "environment", None), "type", None) == "local" + if ( + local_shell_tool_name + and is_local_environment + and shell_call_id + and shell_call_item_id + and shell_commands + ): contents.append( Content.from_function_call( call_id=shell_call_id, name=local_shell_tool_name, - arguments=json.dumps({"command": command_text}), + arguments=json.dumps({"command": "\n".join(shell_commands).strip()}), additional_properties={ OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_SHELL_CALL, OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: shell_commands, @@ -2227,10 +2255,20 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) ) ) elif item_type == "local_shell_call": - local_call_id = getattr(item, "call_id", None) or "" - local_command_parts = list(getattr(getattr(item, "action", None), "command", []) or []) + raw_local_call_id = getattr(item, "call_id", None) + local_call_id = raw_local_call_id if isinstance(raw_local_call_id, str) else "" + raw_local_call_item_id = getattr(item, "id", None) + local_call_item_id = raw_local_call_item_id if isinstance(raw_local_call_item_id, str) else "" + raw_local_command_parts = getattr(getattr(item, "action", None), "command", None) + local_command_parts: list[str] = ( + cast("list[str]", raw_local_command_parts) + if isinstance(raw_local_command_parts, list) + and raw_local_command_parts + and all(isinstance(part, str) for part in cast("list[object]", raw_local_command_parts)) + else [] + ) local_command = shlex.join(local_command_parts) if local_command_parts else "" - if local_shell_tool_name: + if local_shell_tool_name and local_call_id and local_call_item_id and local_command_parts: contents.append( Content.from_function_call( call_id=local_call_id, @@ -2238,7 +2276,7 @@ def _shell_item_to_contents(self, item: Any, local_shell_tool_name: str | None) arguments=json.dumps({"command": local_command}), additional_properties={ OPENAI_SHELL_OUTPUT_TYPE_KEY: OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL, - OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: getattr(item, "id", None), + OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY: local_call_item_id, OPENAI_LOCAL_SHELL_COMMAND_PARTS_KEY: local_command_parts, }, raw_representation=item, @@ -2370,6 +2408,35 @@ def _coalesce_pending_mcp_results(items: list[dict[str, Any]]) -> list[dict[str, out.append(item) return out + @classmethod + def _prepare_shell_transcript_item_for_openai( + cls, + content: Content, + *, + expected_type: Literal["shell_call", "shell_call_output", "local_shell_call"], + ) -> dict[str, Any]: + """Restore a provider-issued shell transcript item for stateless replay.""" + payload = cls._serialize_provider_payload(content.raw_representation) + if isinstance(payload, Mapping): + typed_payload = cast("Mapping[str, Any]", payload) + raw_call_id = typed_payload.get("call_id") + try: + if expected_type == "shell_call": + ResponseFunctionShellToolCall.model_validate(typed_payload) + elif expected_type == "shell_call_output": + ResponseFunctionShellToolCallOutput.model_validate(typed_payload) + else: + TypeAdapter(LocalShellCall).validate_python(typed_payload) + except ValidationError: + pass + else: + if isinstance(raw_call_id, str) and raw_call_id and raw_call_id == content.call_id: + return dict(typed_payload) + raise ChatClientInvalidRequestException( + f"Stateless replay cannot reconstruct {expected_type} for shell call {content.call_id!r}. " + "Use service-side continuation or preserve the original provider response item." + ) + @staticmethod def _serialize_provider_payload(value: Any) -> Any: """Convert OpenAI SDK objects into JSON-serializable Python values.""" @@ -2849,6 +2916,7 @@ def _parse_response_from_openai( call_id=item.call_id, name=item.name, arguments=item.arguments, + informational_only=item.name == local_shell_tool_name, additional_properties={"fc_id": item.id, "status": item.status}, raw_representation=item, ) @@ -3348,6 +3416,7 @@ def output_text_properties(output: Any) -> dict[str, Any] | None: call_id=call_id, name=name, arguments=event.delta, + informational_only=name == local_shell_tool_name, additional_properties={ "output_index": event.output_index, "fc_id": event.item_id, diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 58d625fb0ec..778163c5468 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -42,6 +42,7 @@ SettingNotFoundError, ) from openai import AsyncOpenAI, BadRequestError +from openai.types.responses import ResponseFunctionShellToolCall, ResponseFunctionShellToolCallOutput from openai.types.responses.response_reasoning_item import Summary from openai.types.responses.response_reasoning_summary_text_delta_event import ( ResponseReasoningSummaryTextDeltaEvent, @@ -2273,9 +2274,65 @@ def local_exec(command: str) -> str: assert call_content.additional_properties[OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY] == "local-shell-item-1" +@pytest.mark.parametrize( + ("item_type", "item_id", "command_parts"), + [ + ("local_shell_call", None, ["echo", "ok"]), + ("local_shell_call", "item-1", "echo ok"), + ("forged_local_shell_call", "item-1", ["echo", "ok"]), + ], +) +def test_malformed_local_shell_call_is_not_executable( + item_type: str, + item_id: str | None, + command_parts: Any, +) -> None: + """Malformed or unknown local shell items must not become function calls.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + item = MagicMock() + item.type = item_type + item.id = item_id + item.call_id = "local-shell-call-1" + item.action.command = command_parts + item.status = "completed" + + contents = client._shell_item_to_contents(item, "run_shell") + + assert all(content.type != "function_call" for content in contents) + + +@pytest.mark.parametrize( + ("item_id", "call_id", "commands"), + [ + (None, "local-shell-call-1", ["echo ok"]), + ("local-shell-item-1", None, ["echo ok"]), + ("local-shell-item-1", "local-shell-call-1", "echo ok"), + ("local-shell-item-1", "local-shell-call-1", []), + ], +) +def test_malformed_local_environment_shell_call_is_not_executable( + item_id: str | None, + call_id: str | None, + commands: Any, +) -> None: + """Malformed local-environment shell items must not become function calls.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + item = MagicMock() + item.type = "shell_call" + item.id = item_id + item.call_id = call_id + item.action.commands = commands + item.environment.type = "local" + item.status = "completed" + + contents = client._shell_item_to_contents(item, "run_shell") + + assert all(content.type != "function_call" for content in contents) + + @pytest.mark.asyncio -async def test_local_shell_tool_is_invoked_in_function_loop() -> None: - """Test local shell call executes executor and sends local_shell_call_output.""" +async def test_local_shell_tool_requires_approval_before_function_loop_execution() -> None: + """An explicit local shell call executes only after approval.""" client = OpenAIChatClient(model="test-model", api_key="test-key") executed_commands: list[str] = [] @@ -2285,7 +2342,7 @@ def local_exec(command: str) -> str: local_shell_tool = OpenAIChatClient.get_shell_tool( func=local_exec, - approval_mode="never_require", + approval_mode="always_require", ) mock_response1 = MagicMock() @@ -2300,14 +2357,18 @@ def local_exec(command: str) -> str: mock_response1.incomplete = None mock_action = MagicMock() - mock_action.command = ["python", "--version"] + mock_action.commands = ["python --version"] mock_action.timeout_ms = 30000 + mock_environment = MagicMock() + mock_environment.type = "local" + mock_local_shell_call = MagicMock() - mock_local_shell_call.type = "local_shell_call" + mock_local_shell_call.type = "shell_call" mock_local_shell_call.id = "local-shell-item-1" mock_local_shell_call.call_id = "local-shell-call-1" mock_local_shell_call.action = mock_action + mock_local_shell_call.environment = mock_environment mock_local_shell_call.status = "completed" mock_response1.output = [mock_local_shell_call] @@ -2333,23 +2394,34 @@ def local_exec(command: str) -> str: with patch.object( client.client.responses, "create", side_effect=[_as_raw(mock_response1), _as_raw(mock_response2)] ) as mock_create: - await client.get_response( + response = await client.get_response( messages=[Message(role="user", contents=["What Python version is available?"])], options={"tools": [local_shell_tool]}, ) + assert executed_commands == [] + assert mock_create.call_count == 1 + approval_request = next( + content for content in response.messages[0].contents if content.type == "function_approval_request" + ) + approval_response = approval_request.to_function_approval_response(approved=True) + await client.get_response( + messages=[Message(role="user", contents=[approval_response])], + options={"tools": [local_shell_tool]}, + ) + assert executed_commands == ["python --version"] assert mock_create.call_count == 2 second_call_input = mock_create.call_args_list[1].kwargs["input"] - local_shell_outputs = [item for item in second_call_input if item.get("type") == "local_shell_call_output"] + local_shell_outputs = [item for item in second_call_input if item.get("type") == "shell_call_output"] assert len(local_shell_outputs) == 1 - output_payload = json.loads(local_shell_outputs[0]["output"]) - assert output_payload["stdout"] == "Python 3.13.0" + assert local_shell_outputs[0]["call_id"] == "local-shell-call-1" + assert local_shell_outputs[0]["output"][0]["stdout"] == "Python 3.13.0" @pytest.mark.asyncio -async def test_shell_call_is_invoked_as_local_shell_function_loop() -> None: - """Test shell_call maps to local function invocation and returns shell_call_output.""" +async def test_mixed_shell_calls_only_invoke_explicit_local_shell_call() -> None: + """Stateless replay preserves hosted shell transcript while executing only the local call.""" client = OpenAIChatClient(model="test-model", api_key="test-key") executed_commands: list[str] = [] @@ -2361,6 +2433,29 @@ def local_exec(command: str) -> str: func=local_exec, approval_mode="never_require", ) + hosted_shell_call = ResponseFunctionShellToolCall.model_validate({ + "id": "hosted-shell-item-1", + "type": "shell_call", + "call_id": "hosted-shell-call-1", + "action": {"commands": ["pwd"], "timeout_ms": 30000, "max_output_length": 4096}, + "environment": {"type": "container_reference", "container_id": "container-1"}, + "status": "completed", + }) + hosted_shell_output = ResponseFunctionShellToolCallOutput.model_validate({ + "id": "hosted-shell-output-1", + "type": "shell_call_output", + "call_id": "hosted-shell-call-1", + "output": [{"stdout": "/workspace", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}], + "status": "completed", + }) + local_shell_call = ResponseFunctionShellToolCall.model_validate({ + "id": "local-shell-item-1", + "type": "shell_call", + "call_id": "local-shell-call-1", + "action": {"commands": ["python --version"], "timeout_ms": 30000}, + "environment": {"type": "local"}, + "status": "completed", + }) mock_response1 = MagicMock() mock_response1.output_parsed = None @@ -2372,19 +2467,15 @@ def local_exec(command: str) -> str: mock_response1.status = "completed" mock_response1.finish_reason = "tool_calls" mock_response1.incomplete = None - - mock_action = MagicMock() - mock_action.commands = ["python --version"] - mock_action.timeout_ms = 30000 - mock_action.max_output_length = 4096 - - mock_shell_call = MagicMock() - mock_shell_call.type = "shell_call" - mock_shell_call.id = "sh_test_shell_call_1" - mock_shell_call.call_id = "shell-call-1" - mock_shell_call.action = mock_action - mock_shell_call.status = "completed" - mock_response1.output = [mock_shell_call] + prefix_message = MagicMock() + prefix_message.type = "message" + prefix_content = MagicMock() + prefix_content.type = "output_text" + prefix_content.text = "Checking shell environments" + prefix_content.annotations = [] + prefix_content.logprobs = None + prefix_message.content = [prefix_content] + mock_response1.output = [prefix_message, hosted_shell_call, hosted_shell_output, local_shell_call] mock_response2 = MagicMock() mock_response2.output_parsed = None @@ -2410,19 +2501,100 @@ def local_exec(command: str) -> str: ) as mock_create: await client.get_response( messages=[Message(role="user", contents=["What Python version is available?"])], - options={"tools": [local_shell_tool]}, + options={"tools": [local_shell_tool], "store": False}, ) assert executed_commands == ["python --version"] assert mock_create.call_count == 2 second_call_input = mock_create.call_args_list[1].kwargs["input"] - shell_outputs = [item for item in second_call_input if item.get("type") == "shell_call_output"] - assert len(shell_outputs) == 1 - assert shell_outputs[0]["call_id"] == "shell-call-1" - assert isinstance(shell_outputs[0]["output"], list) - assert shell_outputs[0]["output"][0]["stdout"] == "Python 3.13.0" - local_shell_outputs = [item for item in second_call_input if item.get("type") == "local_shell_call_output"] - assert len(local_shell_outputs) == 0 + assert [item.get("type") for item in second_call_input] == [ + "message", + "message", + "shell_call", + "shell_call_output", + "shell_call", + "shell_call_output", + ] + assert second_call_input[1]["content"][0]["text"] == "Checking shell environments" + shell_items = [item for item in second_call_input if item.get("type") in {"shell_call", "shell_call_output"}] + assert shell_items == [ + hosted_shell_call.model_dump(mode="json", exclude_none=True), + hosted_shell_output.model_dump(mode="json", exclude_none=True), + local_shell_call.model_dump(mode="json", exclude_none=True), + { + "type": "shell_call_output", + "call_id": "local-shell-call-1", + "output": [ + { + "stdout": "Python 3.13.0", + "stderr": "", + "outcome": {"type": "exit", "exit_code": 0}, + } + ], + }, + ] + assert all(item.get("type") != "function_call" for item in second_call_input) + + +@pytest.mark.parametrize( + ("content", "expected_type"), + [ + (Content.from_shell_tool_call(call_id="shell-call-1", commands=["pwd"]), "shell_call"), + ( + Content.from_shell_tool_result( + call_id="shell-call-1", + outputs=[Content.from_shell_command_output(stdout="/workspace", stderr="", exit_code=0)], + ), + "shell_call_output", + ), + ( + Content.from_shell_tool_call( + call_id="shell-call-1", + commands=["pwd"], + raw_representation={"type": "shell_call", "call_id": "shell-call-1"}, + ), + "shell_call", + ), + ( + Content.from_shell_tool_result( + call_id="shell-call-1", + outputs=[Content.from_shell_command_output(stdout="/workspace", stderr="", exit_code=0)], + raw_representation={"type": "shell_call_output", "call_id": "shell-call-1"}, + ), + "shell_call_output", + ), + ( + Content.from_shell_tool_call( + call_id="shell-call-1", + commands=["pwd"], + raw_representation=ResponseFunctionShellToolCall.model_validate({ + "id": "hosted-shell-item-1", + "type": "shell_call", + "call_id": "different-shell-call", + "action": {"commands": ["pwd"]}, + "environment": {"type": "container_reference", "container_id": "container-1"}, + "status": "completed", + }), + ), + "shell_call", + ), + ], +) +def test_stateless_shell_transcript_without_provider_item_fails( + content: Content, + expected_type: str, +) -> None: + """Stateless replay fails explicitly when provider shell shape is unavailable.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + with pytest.raises( + ChatClientInvalidRequestException, + match=rf"cannot reconstruct {expected_type}.*shell-call-1", + ): + client._prepare_messages_for_openai( + [Message(role="assistant", contents=[content])], + request_uses_service_side_storage=False, + ) async def test_tool_loop_store_false_replays_encrypted_reasoning_group() -> None: @@ -2552,10 +2724,15 @@ async def test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_outp create.assert_not_awaited() -def test_response_content_creation_with_shell_call() -> None: - """Test _parse_response_from_openai with shell_call output.""" +def test_response_content_creation_with_shell_call_remains_hosted_with_local_tool() -> None: + """A hosted shell call remains informational when a local shell tool is configured.""" client = OpenAIChatClient(model="test-model", api_key="test-key") + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIChatClient.get_shell_tool(func=local_exec) + mock_response = MagicMock() mock_response.output_parsed = None mock_response.metadata = {} @@ -2575,11 +2752,12 @@ def test_response_content_creation_with_shell_call() -> None: mock_shell_call.type = "shell_call" mock_shell_call.call_id = "shell-call-1" mock_shell_call.action = mock_action + mock_shell_call.environment = None mock_shell_call.status = "completed" mock_response.output = [mock_shell_call] - response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + response = client._parse_response_from_openai(mock_response, options={"tools": [local_shell_tool]}) # type: ignore[arg-type] assert len(response.messages[0].contents) == 1 call_content = response.messages[0].contents[0] @@ -2710,6 +2888,39 @@ def test_response_content_creation_with_function_call() -> None: assert function_call.informational_only is False +def test_response_function_call_named_local_shell_is_informational() -> None: + """A generic function call cannot impersonate the configured local shell item.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIChatClient.get_shell_tool(func=local_exec, approval_mode="never_require") + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "test-id" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + + mock_function_call_item = MagicMock() + mock_function_call_item.type = "function_call" + mock_function_call_item.call_id = "call_123" + mock_function_call_item.name = local_shell_tool.name + mock_function_call_item.arguments = '{"command": "echo blocked"}' + mock_function_call_item.id = "fc_456" + mock_function_call_item.status = "completed" + mock_response.output = [mock_function_call_item] + + response = client._parse_response_from_openai(mock_response, options={"tools": [local_shell_tool]}) # type: ignore[arg-type] + + function_call = response.messages[0].contents[0] + assert function_call.type == "function_call" + assert function_call.name == local_shell_tool.name + assert function_call.informational_only is True + + def test_parse_response_from_openai_with_custom_tool_call_is_informational_only() -> None: """Custom tool calls are hosted Responses items, not local Agent Framework function calls.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -3175,6 +3386,48 @@ def test_parse_chunk_from_openai_function_call_is_actionable() -> None: assert update.contents[0].informational_only is False +def test_parse_chunk_function_call_named_local_shell_is_informational() -> None: + """A streamed generic function call cannot impersonate a local shell item.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + def local_exec(command: str) -> str: + return command + + local_shell_tool = OpenAIChatClient.get_shell_tool(func=local_exec, approval_mode="never_require") + chat_options: dict[str, Any] = {"tools": [local_shell_tool]} + function_call_ids: dict[int, tuple[str, str]] = {} + + added_event = MagicMock() + added_event.type = "response.output_item.added" + added_event.output_index = 0 + added_item = MagicMock() + added_item.type = "function_call" + added_item.call_id = "call_123" + added_item.name = local_shell_tool.name + added_event.item = added_item + + delta_event = MagicMock() + delta_event.type = "response.function_call_arguments.delta" + delta_event.output_index = 0 + delta_event.delta = '{"command": "echo blocked"}' + delta_event.item_id = "fc_456" + + client._parse_chunk_from_openai( + added_event, + options=chat_options, + function_call_ids=function_call_ids, + ) + update = client._parse_chunk_from_openai( + delta_event, + options=chat_options, + function_call_ids=function_call_ids, + ) + + assert len(update.contents) == 1 + assert update.contents[0].type == "function_call" + assert update.contents[0].informational_only is True + + def test_parse_chunk_from_openai_custom_tool_call_done_is_informational_only() -> None: client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options: dict[str, Any] = {} @@ -3266,8 +3519,8 @@ def local_exec(command: str) -> str: assert update.contents == [] -def test_parse_chunk_from_openai_shell_call_done_emits_command() -> None: - """A completed shell_call on output_item.done must emit a function call with the real command.""" +def test_parse_chunk_from_openai_shell_call_done_remains_hosted() -> None: + """A completed hosted shell call remains informational in streaming output.""" client = OpenAIChatClient(model="test-model", api_key="test-key") def local_exec(command: str) -> str: @@ -3281,11 +3534,15 @@ def local_exec(command: str) -> str: mock_action.timeout_ms = 30000 mock_action.max_output_length = 4096 + mock_environment = MagicMock() + mock_environment.type = "container_reference" + mock_item = MagicMock() mock_item.type = "shell_call" mock_item.id = "sh_1" mock_item.call_id = "shell-call-1" mock_item.action = mock_action + mock_item.environment = mock_environment mock_item.status = "completed" mock_event = MagicMock() @@ -3298,18 +3555,13 @@ def local_exec(command: str) -> str: assert len(update.contents) == 1 call_content = update.contents[0] - assert call_content.type == "function_call" + assert call_content.type == "shell_tool_call" assert call_content.call_id == "shell-call-1" - assert call_content.name == local_shell_tool.name - assert call_content.parse_arguments() == {"command": "ls -la"} + assert call_content.commands == ["ls -la"] -def test_parse_chunk_from_openai_local_shell_call_done_emits_command() -> None: - """A completed local_shell_call on output_item.done emits a function call with the command. - - Mirrors the non-streaming local_shell_call mapping: the joined command and the - local-shell metadata (item id) must be present on the completed item. - """ +def test_parse_chunk_from_openai_local_environment_shell_call_done_emits_command() -> None: + """A completed shell call with a local environment emits an executable function call.""" client = OpenAIChatClient(model="test-model", api_key="test-key") def local_exec(command: str) -> str: @@ -3319,14 +3571,18 @@ def local_exec(command: str) -> str: function_call_ids: dict[int, tuple[str, str]] = {} mock_action = MagicMock() - mock_action.command = ["python", "--version"] + mock_action.commands = ["python --version"] mock_action.timeout_ms = 30000 + mock_environment = MagicMock() + mock_environment.type = "local" + mock_item = MagicMock() - mock_item.type = "local_shell_call" + mock_item.type = "shell_call" mock_item.id = "local-shell-item-1" mock_item.call_id = "local-shell-call-1" mock_item.action = mock_action + mock_item.environment = mock_environment mock_item.status = "completed" mock_event = MagicMock() @@ -3343,7 +3599,7 @@ def local_exec(command: str) -> str: assert call_content.call_id == "local-shell-call-1" assert call_content.name == local_shell_tool.name assert call_content.parse_arguments() == {"command": "python --version"} - assert call_content.additional_properties[OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY] == "local-shell-item-1" + assert call_content.additional_properties["openai.responses.shell.output_type"] == "shell_call_output" def test_parse_chunk_from_openai_shell_call_output_added_defers_result() -> None: @@ -8337,9 +8593,8 @@ def test_stateless_history_preserves_pending_hosted_approval_request_until_respo assert resolved_items == [] -def test_prepare_messages_strips_local_shell_call_under_storage() -> None: - """Local-shell-call function_results carry a server-issued local_shell_call_item_id and must - be stripped under storage. Plain function_results (no shell ID) are kept either way (#3295).""" +def test_prepare_messages_keeps_local_shell_output_under_storage() -> None: + """Locally generated shell output must reach the provider in every continuation mode.""" from agent_framework_openai._chat_client import ( OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY, OPENAI_SHELL_OUTPUT_TYPE_KEY, @@ -8358,15 +8613,18 @@ def test_prepare_messages_strips_local_shell_call_under_storage() -> None: plain_result = Content.from_function_result(call_id="plain_1", result="plain") message = Message(role="tool", contents=[shell_result, plain_result]) - storage_on = client._prepare_message_for_openai(message, request_uses_service_side_storage=True) - types_on = [item.get("type") for item in storage_on] - assert OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL not in types_on - assert "function_call_output" in types_on - - storage_off = client._prepare_message_for_openai(message, request_uses_service_side_storage=False) - types_off = [item.get("type") for item in storage_off] - assert OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL in types_off - assert "function_call_output" in types_off + expected_shell_output = { + "id": "lsh_server_issued", + "type": OPENAI_SHELL_OUTPUT_TYPE_LOCAL_SHELL_CALL, + "output": '{"stdout": "ok", "exit_code": 0}', + } + for request_uses_service_side_storage in (True, False): + prepared = client._prepare_message_for_openai( + message, + request_uses_service_side_storage=request_uses_service_side_storage, + ) + assert expected_shell_output in prepared + assert any(item.get("type") == "function_call_output" for item in prepared) def test_prepare_messages_strips_mcp_items_under_storage() -> None: From a0c668f2a6352bb8088235ad7c32160710b9b4fe Mon Sep 17 00:00:00 2001 From: CoralGarden52 <97677340+CoralGarden52@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:55:29 +0000 Subject: [PATCH 072/108] Python: preserve functional workflow state on response-only HITL resume (#8300) * Python: preserve functional workflow state on response-only resume * refactor: centralize functional workflow replay state --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework/_workflows/_functional.py | 43 +++++++++---- .../workflow/test_functional_workflow.py | 62 +++++++++++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_functional.py b/python/packages/core/agent_framework/_workflows/_functional.py index 1e1d4697d10..14f7a132735 100644 --- a/python/packages/core/agent_framework/_workflows/_functional.py +++ b/python/packages/core/agent_framework/_workflows/_functional.py @@ -742,6 +742,7 @@ def __init__( self._last_message: Any = None self._last_step_cache: dict[tuple[str, int], Any] = {} self._last_step_cache_auto_request_info_counts: dict[tuple[str, int], int] = {} + self._last_state: dict[str, Any] = {} self._last_pending_request_ids: set[str] = set() # Signature arity is validated once at decoration time. @@ -755,6 +756,30 @@ def __init__( functools.update_wrapper(self, func) # type: ignore[arg-type] + def _capture_replay_state(self, ctx: RunContext, message: Any | None = None) -> None: + """Capture the state needed to continue a response-only HITL replay.""" + if message is not None: + self._last_message = message + self._last_step_cache = dict(ctx._step_cache) + self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) + self._last_state = dict(ctx._state) + self._last_pending_request_ids = set(ctx._pending_requests) + + def _restore_replay_state(self, ctx: RunContext) -> Any: + """Restore cached execution state and return the message used by the replay.""" + ctx._step_cache = dict(self._last_step_cache) + ctx._step_cache_auto_request_info_counts = dict(self._last_step_cache_auto_request_info_counts) + ctx._state = dict(self._last_state) + return self._last_message + + def _clear_replay_state(self) -> None: + """Clear all state retained for a response-only replay.""" + self._last_message = None + self._last_step_cache = {} + self._last_step_cache_auto_request_info_counts = {} + self._last_state = {} + self._last_pending_request_ids = set() + @staticmethod def _classify_signature(func: Callable[..., Any]) -> list[str]: """Return the names of non-ctx parameters, validating arity. @@ -1014,10 +1039,9 @@ async def _run_core( # For response-only replay (no checkpoint), restore cached state if checkpoint_id is None and responses: + replay_message = self._restore_replay_state(ctx) if message is None: - message = self._last_message - ctx._step_cache = dict(self._last_step_cache) - ctx._step_cache_auto_request_info_counts = dict(self._last_step_cache_auto_request_info_counts) + message = replay_message # Store message for future replays if message is not None: @@ -1065,8 +1089,7 @@ async def _on_step_completed() -> None: ) # Persist step cache for response-only replay - self._last_step_cache = dict(ctx._step_cache) - self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) + self._capture_replay_state(ctx, message) # Yield collected events. # NOTE: Events are buffered during _execute() and yielded after @@ -1087,23 +1110,17 @@ async def _on_step_completed() -> None: # Final status if saw_request: - self._last_pending_request_ids = set(ctx._pending_requests) yield _framework_event(WorkflowEvent.status, WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) else: # Clean completion — drop cross-run replay state. - self._last_message = None - self._last_step_cache = {} - self._last_step_cache_auto_request_info_counts = {} - self._last_pending_request_ids = set() + self._clear_replay_state() yield _framework_event(WorkflowEvent.status, WorkflowRunState.IDLE) span.add_event(OtelAttr.WORKFLOW_COMPLETED) except WorkflowInterrupted: # Persist step cache for response-only replay - self._last_step_cache = dict(ctx._step_cache) - self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) - self._last_pending_request_ids = set(ctx._pending_requests) + self._capture_replay_state(ctx, message) # HITL interruption — yield events collected so far for event in ctx._get_events(): diff --git a/python/packages/core/tests/workflow/test_functional_workflow.py b/python/packages/core/tests/workflow/test_functional_workflow.py index 395920bf22c..6f60aba0395 100644 --- a/python/packages/core/tests/workflow/test_functional_workflow.py +++ b/python/packages/core/tests/workflow/test_functional_workflow.py @@ -1509,6 +1509,68 @@ def emit(self, record: logging.LogRecord) -> None: class TestHITLInStepWithCaching: """Regression tests: request_info inside @step combined with caching and bypass.""" + def test_replay_state_helpers_restore_and_clear_the_full_bundle(self): + """Replay helpers keep message, caches, state, and pending IDs together.""" + + @built_workflow + async def wf(data: str) -> str: + return data + + source_ctx = _RunContext("wf") + source_ctx._step_cache = {("seed_state", 0): "seeded"} + source_ctx._step_cache_auto_request_info_counts = {("seed_state", 0): 1} + source_ctx._state = {"marker": "ok"} + source_ctx._pending_requests = { + "r1": WorkflowEvent.request_info( + request_id="r1", + source_executor_id="wf", + request_data="question", + response_type=str, + ) + } + + wf._capture_replay_state(source_ctx, "input") + + restored_ctx = _RunContext("wf") + assert wf._restore_replay_state(restored_ctx) == "input" + assert restored_ctx._step_cache == source_ctx._step_cache + assert restored_ctx._step_cache_auto_request_info_counts == source_ctx._step_cache_auto_request_info_counts + assert restored_ctx._state == source_ctx._state + assert wf._last_pending_request_ids == {"r1"} + + wf._clear_replay_state() + + assert wf._last_message is None + assert wf._last_step_cache == {} + assert wf._last_step_cache_auto_request_info_counts == {} + assert wf._last_state == {} + assert wf._last_pending_request_ids == set() + + async def test_response_only_resume_restores_state_from_cached_step(self): + """Response-only HITL resumes must preserve state written before a cached step.""" + seed_calls = 0 + + @step + async def seed_state(ctx: RunContext) -> str: + nonlocal seed_calls + seed_calls += 1 + ctx.set_state("marker", "ok") + return "seeded" + + @built_workflow + async def wf(data: str, ctx: RunContext) -> str: + value = await seed_state() + answer = await ctx.request_info("question", response_type=str, request_id="r1") + return f"{ctx.get_state('marker', 'MISSING')}:{value}:{answer}" + + result1 = await wf.run("input") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + result2 = await wf.run(responses={"r1": "ok"}) + + assert seed_calls == 1 + assert result2.get_outputs() == ["ok:seeded:ok"] + async def test_preceding_step_bypassed_on_hitl_resume(self): """When a step after a completed step calls request_info and interrupts, resuming should bypass the first step (cached) and re-execute the HITL step.""" From d95075f61e384e73d15c3db1e0786f84796daf2f Mon Sep 17 00:00:00 2001 From: fzfzzfzzzfzzzz <115906210+fzfzzfzzzfzzzz@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:09:14 +0000 Subject: [PATCH 073/108] Python: handle concurrent FileCheckpointStorage deletion (#8261) * Python: handle concurrent checkpoint deletion * Python: serialize concurrent checkpoint deletes --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework/_workflows/_checkpoint.py | 26 ++++++-- .../core/tests/workflow/test_checkpoint.py | 59 +++++++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index c6a6a9dc891..fe1045b2a6f 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -7,12 +7,13 @@ import json import logging import os +import threading import uuid from collections.abc import Mapping from dataclasses import dataclass, field, fields from datetime import datetime, timezone from pathlib import Path -from typing import TYPE_CHECKING, Any, Protocol, TypeAlias +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, TypeAlias from ..exceptions import WorkflowCheckpointException @@ -272,6 +273,11 @@ class FileCheckpointStorage: ) """ + _DELETE_LOCK_STRIPE_COUNT: ClassVar[int] = 64 + _DELETE_LOCKS: ClassVar[tuple[threading.Lock, ...]] = tuple( + threading.Lock() for _ in range(_DELETE_LOCK_STRIPE_COUNT) + ) + def __init__( self, storage_path: str | Path, @@ -312,6 +318,11 @@ def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path: raise WorkflowCheckpointException(f"Invalid checkpoint ID: {checkpoint_id}") return file_path + @classmethod + def _delete_lock(cls, file_path: Path) -> threading.Lock: + """Return the process-local deletion lock for a checkpoint file.""" + return cls._DELETE_LOCKS[hash(file_path) % cls._DELETE_LOCK_STRIPE_COUNT] + async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: """Save a checkpoint and return its ID. @@ -435,13 +446,16 @@ async def delete(self, checkpoint_id: CheckpointID) -> bool: True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists. """ file_path = self._validate_file_path(checkpoint_id) + file_lock = self._delete_lock(file_path) def _delete() -> bool: - if file_path.exists(): - file_path.unlink() - logger.info(f"Deleted checkpoint {checkpoint_id} from {file_path}") - return True - return False + with file_lock: + try: + file_path.unlink() + except FileNotFoundError: + return False + logger.info(f"Deleted checkpoint {checkpoint_id} from {file_path}") + return True return await asyncio.to_thread(_delete) diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py index 5c06b617c89..31ad62ee4bf 100644 --- a/python/packages/core/tests/workflow/test_checkpoint.py +++ b/python/packages/core/tests/workflow/test_checkpoint.py @@ -1,11 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. +import asyncio import json import tempfile +import threading from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any +from unittest.mock import patch import pytest @@ -1258,6 +1261,62 @@ async def test_file_checkpoint_storage_delete(): assert result is False +async def test_file_checkpoint_storage_concurrent_delete(): + """Serialize deletes when overlapping unlink calls could both report success.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + other_storage = FileCheckpointStorage(temp_dir) + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + checkpoint_id="same", + ) + await storage.save(checkpoint) + + file_path = (Path(temp_dir) / "same.json").resolve() + original_to_thread = asyncio.to_thread + original_unlink = Path.unlink + worker_barrier = threading.Barrier(2) + unlink_barrier = threading.Barrier(2) + unlink_guard = threading.Lock() + overlapping_delete_completed = False + + async def synchronized_to_thread(function: Any, /, *args: Any, **kwargs: Any) -> Any: + def synchronized_call() -> Any: + worker_barrier.wait(timeout=5) + return function(*args, **kwargs) + + return await original_to_thread(synchronized_call) + + def macos_style_unlink(path: Path, missing_ok: bool = False) -> None: + nonlocal overlapping_delete_completed + if path.resolve() != file_path: + original_unlink(path, missing_ok=missing_ok) + return + + try: + unlink_barrier.wait(timeout=0.5) + except threading.BrokenBarrierError: + original_unlink(path, missing_ok=missing_ok) + return + + with unlink_guard: + if not overlapping_delete_completed: + original_unlink(path, missing_ok=missing_ok) + overlapping_delete_completed = True + + with ( + patch.object(asyncio, "to_thread", synchronized_to_thread), + patch.object(Path, "unlink", macos_style_unlink), + ): + results = await asyncio.gather( + storage.delete(checkpoint.checkpoint_id), + other_storage.delete(checkpoint.checkpoint_id), + ) + + assert sorted(results) == [False, True] + + async def test_file_checkpoint_storage_directory_creation(): with tempfile.TemporaryDirectory() as temp_dir: nested_path = Path(temp_dir) / "nested" / "checkpoint" / "storage" From b45bcc3c5adb74189f7de2504739bbd172e938b1 Mon Sep 17 00:00:00 2001 From: LI <2484593937@qq.com> Date: Tue, 15 Sep 2026 06:09:43 +0000 Subject: [PATCH 074/108] Python: fix(checkpoint): preserve dicts whose keys collide after str() (#8272) * fix(python): pickle checkpoint dicts with colliding str keys When dict keys collide after str() (e.g. 1 and "1"), JSON encoding silently overwrote values. Pickle those mappings instead (#8256). * fix(checkpoint): stringify dict keys once during encode Reuse one str(key) for reserved-key checks, collision detection, and the encoded mapping so stateful __str__ cannot disagree across passes. * fix(checkpoint): stringify dict keys once during encode Reuse one str(key) for reserved-key checks, collision detection, and the encoded mapping so stateful __str__ cannot disagree across passes. --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../_workflows/_checkpoint_encoding.py | 11 +++++- .../tests/workflow/test_checkpoint_encode.py | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index f6fd3766924..1d46253b396 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -313,9 +313,16 @@ def _encode(value: Any) -> Any: # Recursively encode dict values (keys become strings) if isinstance(value, dict): typed_dict = cast(dict[Any, Any], value) - if any(str(k) in _RESERVED_DICT_KEYS for k in typed_dict): + # Stringify each key once so reserved-key checks, collision detection, and + # the encoded mapping all observe the same strings (stateful ``__str__``). + stringified_items = [(str(k), v) for k, v in typed_dict.items()] + if any(key in _RESERVED_DICT_KEYS for key, _ in stringified_items): return _encode_pickle(value) - encoded_dict: dict[str, Any] = {str(k): _encode(v) for k, v in typed_dict.items()} + # Distinct Python keys can collapse after str(); pickle those mappings so + # values are not silently overwritten (for example {1: "a", "1": "b"}). + if len({key for key, _ in stringified_items}) != len(stringified_items): + return _encode_pickle(value) + encoded_dict: dict[str, Any] = {key: _encode(v) for key, v in stringified_items} return encoded_dict # Recursively encode list items (lists are JSON-native collections) diff --git a/python/packages/core/tests/workflow/test_checkpoint_encode.py b/python/packages/core/tests/workflow/test_checkpoint_encode.py index 08c12cbf45e..35eedaa66d0 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_encode.py +++ b/python/packages/core/tests/workflow/test_checkpoint_encode.py @@ -108,6 +108,43 @@ def test_encode_dict_with_non_string_keys() -> None: assert result == {"1": "one", "2": "two"} +def test_encode_dict_with_stringified_key_collision_uses_pickle() -> None: + """Distinct keys that collide after str() must not silently overwrite values.""" + data = {1: "integer-key", "1": "string-key"} + result = encode_checkpoint_value(data) + assert isinstance(result, dict) + assert _PICKLE_MARKER in result + assert _TYPE_MARKER in result + restored = decode_checkpoint_value(result, allowed_types=frozenset()) + assert restored == data + assert restored[1] == "integer-key" + assert restored["1"] == "string-key" + + +def test_encode_dict_stringifies_each_key_once_for_stateful_str() -> None: + """Reserved/collision checks and encoding must reuse one str(key) per entry.""" + + class FlipStr: + def __init__(self) -> None: + self.n = 0 + + def __str__(self) -> str: + self.n += 1 + return "stable" if self.n == 1 else "other" + + def __hash__(self) -> int: + return id(self) + + def __eq__(self, other: object) -> bool: + return self is other + + key = FlipStr() + data = {key: "value"} + result = encode_checkpoint_value(data) + # First str() was "stable" for all checks; must not disagree across passes. + assert result == {"stable": "value"} + + def test_encode_empty_list() -> None: """Test encoding an empty list.""" assert encode_checkpoint_value([]) == [] From cd9f4cc546dfc1871f813d8e13a18ca641b6a35b Mon Sep 17 00:00:00 2001 From: alanhuangyoo Date: Tue, 15 Sep 2026 07:04:04 +0000 Subject: [PATCH 075/108] Python: drop continuation_token after a resumed background stream completes (#8358) * Python: drop continuation_token after a resumed background stream completes The non-streaming path pops continuation_token from the caller's options once the background response completes (#5394), because FunctionInvocationLayer reuses that dict across tool-loop iterations. The streaming resume branch did not, so after the resumed stream ended in a function call, every iteration retrieved the same response again and ran its tools again until max_iterations. Fixes #8357 Co-Authored-By: Claude Opus 5 * Python: drop the continuation_token before yielding the terminal update A consumer that stops iterating at the terminal update never resumed the generator past that yield, so the cleanup after the loop didn't run and the options kept the token. Pop it when the terminal event arrives. Co-Authored-By: Claude Opus 5 * Python: drop a finished background response's token from the function loop's options The pops in _inner_get_response act on the options that reached the service call. Chat middleware may replace those, and then the function loop keeps the token in its own options, retrieves the finished response again on the next iteration and runs its tools again. Clear the token in OpenAIChatClient._update_function_invocation_continuation_state once the response has no continuation token, for both streaming and non-streaming calls. The pre-yield cleanup stays for raw-stream consumers. Co-Authored-By: Claude Opus 5 * Python: type the resume tests' options as OpenAIChatOptions Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework_openai/_chat_client.py | 30 +++ .../tests/openai/test_openai_chat_client.py | 197 ++++++++++++++++++ 2 files changed, 227 insertions(+) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index aa0937658c7..f6e18a761ec 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -132,6 +132,7 @@ class _PromptCacheOptions(TypedDict, total=False): if TYPE_CHECKING: + from agent_framework._sessions import AgentSession from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -756,6 +757,19 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: ) if served_model is not None: update.model = served_model + if chunk.type in ( + "response.completed", + "response.incomplete", + "response.failed", + ) and isinstance(options, dict): + # Same as the non-streaming path (issue #5394): once the resumed + # background response has finished, drop the continuation_token + # from the caller's options dict. FunctionInvocationLayer reuses + # that dict, so a leftover token makes the next tool-loop iteration + # retrieve this response again instead of POSTing the tool results, + # and the tools run again each time. Do it before yielding, so a + # consumer that stops at the terminal update doesn't keep it. + options.pop("continuation_token", None) yield update except Exception as ex: self._handle_request_error(ex) @@ -3888,6 +3902,22 @@ class MyOptions(OpenAIChatOptions, total=False): additional_properties=additional_properties, ) + @override + def _update_function_invocation_continuation_state( + self, + kwargs: dict[str, Any], + response: ChatResponse[Any], + *, + session: AgentSession | None, + options: dict[str, Any] | None = None, + ) -> None: + super()._update_function_invocation_continuation_state(kwargs, response, session=session, options=options) + # _inner_get_response drops the token from the options that reached the service call, which + # chat middleware may have replaced. Drop it from the function loop's own options as well once + # the background response has finished, or the next iteration retrieves it again. + if options is not None and response.continuation_token is None: + options.pop("continuation_token", None) + def _apply_openai_chat_client_docstrings() -> None: """Align OpenAI Responses client docstrings with the raw implementation.""" diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 778163c5468..80ae0aa9ef9 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -7748,6 +7748,203 @@ def test_map_chat_to_agent_update_preserves_continuation_token() -> None: assert _response_id_from_token(agent_update.continuation_token) == "resp_map_123" +def _completed_response_event(response_id: str, output_type: str) -> MagicMock: + event = MagicMock() + event.type = "response.completed" + event.response.id = response_id + event.response.conversation = None + event.response.model = "test-model" + event.response.created_at = 1_700_000_000 + event.response.usage = None + event.response.status = "completed" + event.response.incomplete_details = None + event.response.output = [MagicMock(type=output_type)] + return event + + +def _completed_response(response_id: str, output_type: str) -> MagicMock: + response = MagicMock() + response.id = response_id + response.conversation = None + response.model = "test-model" + response.created_at = 1_700_000_000 + response.usage = None + response.status = "completed" + response.incomplete_details = None + response.metadata = {} + if output_type == "function_call": + item = MagicMock( + type="function_call", call_id="call_1", arguments='{"to": "bob"}', id="fc_1", status="completed" + ) + item.name = "send_email" + else: + item = MagicMock( + type="message", content=[MagicMock(type="output_text", text="Email sent.", annotations=[], logprobs=None)] + ) + response.output = [item] + response.parse = MagicMock(return_value=response) + response.headers = {} + return response + + +async def test_streaming_resume_with_tools_runs_the_tool_once() -> None: + """Resuming a background stream must not re-retrieve it on every tool-loop iteration. + + Streaming twin of #5394: the non-streaming path drops ``continuation_token`` from the + options once the background response completes; without that, the next iteration + retrieves the same response again and runs its tool calls again. + """ + executions: list[str] = [] + + @tool(approval_mode="never_require") + def send_email(to: str) -> str: + """Send an email.""" + executions.append(to) + return "sent" + + function_call_added = MagicMock() + function_call_added.type = "response.output_item.added" + function_call_added.output_index = 0 + function_call_added.item.type = "function_call" + function_call_added.item.call_id = "call_1" + function_call_added.item.name = "send_email" + arguments_delta = MagicMock() + arguments_delta.type = "response.function_call_arguments.delta" + arguments_delta.output_index = 0 + arguments_delta.item_id = "fc_1" + arguments_delta.delta = '{"to": "bob"}' + text_delta = MagicMock() + text_delta.type = "response.output_text.delta" + text_delta.delta = "Email sent." + text_delta.logprobs = None + + retrieve = AsyncMock( + side_effect=lambda *args, **kwargs: _FakeAsyncEventStream([ + function_call_added, + arguments_delta, + _completed_response_event("resp_bg", "function_call"), + ]) + ) + create = AsyncMock( + side_effect=lambda *args, **kwargs: _FakeAsyncEventStream([ + text_delta, + _completed_response_event("resp_next", "message"), + ]) + ) + + client = OpenAIChatClient(model="test-model", api_key="test-key") + client.function_invocation_configuration["max_iterations"] = 4 + options: OpenAIChatOptions[None] = {"continuation_token": {"response_id": "resp_bg"}, "tools": [send_email]} + with ( + patch.object(client.client.responses.with_raw_response, "retrieve", new=retrieve), + patch.object(client.client.responses.with_raw_response, "create", new=create), + ): + stream = client.get_response([Message(role="user", contents=["email bob"])], stream=True, options=options) + async for _ in stream: + pass + final = await stream.get_final_response() + + assert executions == ["bob"] + assert retrieve.await_count == 1 + assert create.await_count == 1 + assert create.await_args is not None + assert create.await_args.kwargs.get("previous_response_id") == "resp_bg" + assert final.text == "Email sent." + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_resume_with_tools_runs_the_tool_once_when_middleware_replaces_options(stream: bool) -> None: + """Chat middleware may replace the options for the service call; the tool loop must still drop the token.""" + from agent_framework import ChatContext, ChatMiddleware + + class ReplaceOptions(ChatMiddleware): + async def process(self, context: ChatContext, call_next: Any) -> None: + context.options = dict(context.options or {}) + await call_next() + + executions: list[str] = [] + + @tool(approval_mode="never_require") + def send_email(to: str) -> str: + """Send an email.""" + executions.append(to) + return "sent" + + function_call_added = MagicMock() + function_call_added.type = "response.output_item.added" + function_call_added.output_index = 0 + function_call_added.item.type = "function_call" + function_call_added.item.call_id = "call_1" + function_call_added.item.name = "send_email" + arguments_delta = MagicMock() + arguments_delta.type = "response.function_call_arguments.delta" + arguments_delta.output_index = 0 + arguments_delta.item_id = "fc_1" + arguments_delta.delta = '{"to": "bob"}' + text_delta = MagicMock() + text_delta.type = "response.output_text.delta" + text_delta.delta = "Email sent." + text_delta.logprobs = None + + if stream: + retrieve = AsyncMock( + side_effect=lambda *args, **kwargs: _FakeAsyncEventStream([ + function_call_added, + arguments_delta, + _completed_response_event("resp_bg", "function_call"), + ]) + ) + create = AsyncMock( + side_effect=lambda *args, **kwargs: _FakeAsyncEventStream([ + text_delta, + _completed_response_event("resp_next", "message"), + ]) + ) + else: + retrieve = AsyncMock(side_effect=lambda *args, **kwargs: _completed_response("resp_bg", "function_call")) + create = AsyncMock(side_effect=lambda *args, **kwargs: _completed_response("resp_next", "message")) + + client = OpenAIChatClient(model="test-model", api_key="test-key", middleware=[ReplaceOptions()]) + client.function_invocation_configuration["max_iterations"] = 4 + options: OpenAIChatOptions[None] = {"continuation_token": {"response_id": "resp_bg"}, "tools": [send_email]} + with ( + patch.object(client.client.responses.with_raw_response, "retrieve", new=retrieve), + patch.object(client.client.responses.with_raw_response, "create", new=create), + ): + messages = [Message(role="user", contents=["email bob"])] + if stream: + response_stream = client.get_response(messages, stream=True, options=options) + async for _ in response_stream: + pass + final = await response_stream.get_final_response() + else: + final = await client.get_response(messages, options=options) + + assert executions == ["bob"] + assert retrieve.await_count == 1 + assert create.await_count == 1 + assert final.text == "Email sent." + + +async def test_streaming_resume_drops_continuation_token_before_the_terminal_update() -> None: + """A consumer that stops at the terminal update must not leave the token in its options.""" + retrieve = AsyncMock( + side_effect=lambda *args, **kwargs: _FakeAsyncEventStream([_completed_response_event("resp_bg", "message")]) + ) + + client = OpenAIChatClient(model="test-model", api_key="test-key") + # The tool loop hands its own options dict to _inner_get_response and reuses it. + options: dict[str, Any] = {"continuation_token": {"response_id": "resp_bg"}} + with patch.object(client.client.responses.with_raw_response, "retrieve", new=retrieve): + stream = _as_chat_response_stream( + client._inner_get_response(messages=[Message(role="user", contents=["hi"])], options=options, stream=True) + ) + async for _ in stream: + break + + assert "continuation_token" not in options + + async def test_prepare_options_excludes_continuation_token() -> None: """Test that _prepare_options does not pass continuation_token to OpenAI API.""" client = OpenAIChatClient(model="test-model", api_key="test-key") From a757aeb7ba5005c4d9e5058ec433fb450c4a8fd0 Mon Sep 17 00:00:00 2001 From: RKS Date: Tue, 15 Sep 2026 07:04:14 +0000 Subject: [PATCH 076/108] Python: record gen_ai.client.operation.duration for failed chat and embedding calls (#8347) * Python: record gen_ai.client.operation.duration for failed chat and embedding calls * Python: record duration metric when stream finalizer raises * fix(python): re-raise finalizer and result-hook exceptions in _finalize_stream Narrow error handling to get_final_response and re-raise exceptions while independently guarding telemetry capture, ensuring caller outcomes and duration error metrics remain aligned. --- .../core/agent_framework/observability.py | 144 +++++++--- .../core/tests/core/test_observability.py | 248 ++++++++++++++++++ 2 files changed, 351 insertions(+), 41 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 5dc9fa8828c..9ecc90d06c8 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -2064,59 +2064,85 @@ def _record_duration() -> None: ) except Exception as exception: capture_exception(span=span, exception=exception, timestamp=time_ns()) + _capture_operation_error( + attributes=attributes, + exception=exception, + operation_duration_histogram=getattr(self, "duration_histogram", None), + duration=perf_counter() - start_time, + ) _close_span() raise async def _finalize_stream() -> None: from ._types import ChatResponse - try: - if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage] - # Stream errored; skip get_final_response() to avoid firing - # result hooks such as after_run context providers on error - # paths. Capture the error on the span before returning. - capture_exception( - span=span, - exception=result_stream._stream_error, # type: ignore - timestamp=time_ns(), - ) - return - response: ChatResponse[Any] = await result_stream.get_final_response() - duration = duration_state.get("duration") - response_attributes = _get_response_attributes(attributes, response) - self._backfill_request_model(span, response_attributes) - _capture_response( + if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage] + # Stream errored; skip get_final_response() to avoid firing + # result hooks such as after_run context providers on error + # paths. Capture the error on the span before returning. + capture_exception( span=span, - attributes=response_attributes, - token_usage_histogram=getattr(self, "token_usage_histogram", None), + exception=result_stream._stream_error, # type: ignore + timestamp=time_ns(), + ) + _capture_operation_error( + attributes=attributes, + exception=result_stream._stream_error, # type: ignore operation_duration_histogram=getattr(self, "duration_histogram", None), - duration=duration, + duration=duration_state.get("duration", perf_counter() - start_time), ) - _mark_inner_response_telemetry_captured(response) - if ( - OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED - and isinstance(response, ChatResponse) - and response.messages - and span.is_recording() - ): - finish_reason = _get_response_finish_reason(response) - # Activate the span: this cleanup hook runs after the final pull has - # exited its _activate_span context, so it wouldn't otherwise be current. - with _activate_span(span): - _capture_message_events_v1_36( - provider_name=provider_name, + _close_span() + return + + try: + try: + response: ChatResponse[Any] = await result_stream.get_final_response() + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + _capture_operation_error( + attributes=attributes, + exception=exception, + operation_duration_histogram=getattr(self, "duration_histogram", None), + duration=duration_state.get("duration", perf_counter() - start_time), + ) + raise + + try: + duration = duration_state.get("duration") + response_attributes = _get_response_attributes(attributes, response) + self._backfill_request_model(span, response_attributes) + _capture_response( + span=span, + attributes=response_attributes, + token_usage_histogram=getattr(self, "token_usage_histogram", None), + operation_duration_histogram=getattr(self, "duration_histogram", None), + duration=duration, + ) + _mark_inner_response_telemetry_captured(response) + if ( + OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED + and isinstance(response, ChatResponse) + and response.messages + and span.is_recording() + ): + finish_reason = _get_response_finish_reason(response) + # Activate the span: this cleanup hook runs after the final pull has + # exited its _activate_span context, so it wouldn't otherwise be current. + with _activate_span(span): + _capture_message_events_v1_36( + provider_name=provider_name, + messages=response.messages, + finish_reason=finish_reason, + output=True, + ) + _capture_message_span_attributes_latest_experimental( + span=span, messages=response.messages, finish_reason=finish_reason, output=True, ) - _capture_message_span_attributes_latest_experimental( - span=span, - messages=response.messages, - finish_reason=finish_reason, - output=True, - ) - except Exception as exception: - capture_exception(span=span, exception=exception, timestamp=time_ns()) + except Exception as telemetry_exception: + logger.debug("Failed to capture telemetry for stream: %s", telemetry_exception) finally: _close_span() @@ -2171,6 +2197,12 @@ async def _get_response() -> ChatResponse: ) except Exception as exception: capture_exception(span=span, exception=exception, timestamp=time_ns()) + _capture_operation_error( + attributes=attributes, + exception=exception, + operation_duration_histogram=getattr(self, "duration_histogram", None), + duration=perf_counter() - start_time_stamp, + ) raise duration = perf_counter() - start_time_stamp response_attributes = _get_response_attributes(attributes, response) @@ -2256,6 +2288,12 @@ async def get_embeddings( ) except Exception as exception: capture_exception(span=span, exception=exception, timestamp=time_ns()) + _capture_operation_error( + attributes=attributes, + exception=exception, + operation_duration_histogram=getattr(self, "duration_histogram", None), + duration=perf_counter() - start_time_stamp, + ) raise duration = perf_counter() - start_time_stamp response_attributes: dict[str, Any] = {**attributes} @@ -3517,6 +3555,30 @@ def _get_response_attributes( ) +def _filter_metric_attributes(attributes: dict[str, Any]) -> dict[str, Any]: + """Filter attributes to only the GenAI metric attribute set.""" + return {key: attributes[key] for key in GEN_AI_METRIC_ATTRIBUTES if key in attributes} + + +def _capture_operation_error( + attributes: dict[str, Any], + exception: BaseException, + operation_duration_histogram: metrics.Histogram | None = None, + duration: float | None = None, +) -> None: + """Record the operation duration metric for a call that failed. + + The GenAI semantic conventions define ``gen_ai.client.operation.duration`` for failed + operations as well as successful ones, with ``error.type`` set to the class of the error. + Recording only successes leaves error latency out of the metric and gives no error rate. + """ + if operation_duration_histogram is None or duration is None: + return + attrs = _filter_metric_attributes(attributes) + attrs[OtelAttr.ERROR_TYPE] = type(exception).__name__ + operation_duration_histogram.record(duration, attributes=attrs) + + def _capture_response( span: trace.Span, attributes: dict[str, Any], @@ -3526,7 +3588,7 @@ def _capture_response( ) -> None: """Set the response for a given span.""" span.set_attributes(attributes) - attrs: dict[str, Any] = {k: v for k, v in attributes.items() if k in GEN_AI_METRIC_ATTRIBUTES} + attrs = _filter_metric_attributes(attributes) if token_usage_histogram and (input_tokens := attributes.get(OtelAttr.INPUT_TOKENS)) is not None: token_usage_histogram.record(input_tokens, attributes={**attrs, OtelAttr.T_TYPE: OtelAttr.T_TYPE_INPUT}) if token_usage_histogram and (output_tokens := attributes.get(OtelAttr.OUTPUT_TOKENS)) is not None: diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index f496e44d07f..0dcafce37f8 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -5723,6 +5723,254 @@ def test_capture_response_with_error_type(span_exporter: InMemorySpanExporter): assert spans[0].attributes.get(OtelAttr.ERROR_TYPE) == "ValueError" # type: ignore[union-attr] # ty: ignore[unresolved-attribute] +def test_filter_metric_attributes() -> None: + """_filter_metric_attributes preserves only the allowed GenAI metric attributes.""" + from agent_framework.observability import OtelAttr, _filter_metric_attributes + + attributes = { + OtelAttr.OPERATION: "chat", + OtelAttr.PROVIDER_NAME: "test_provider", + OtelAttr.SYSTEM: "test_system", + OtelAttr.REQUEST_MODEL: "gpt-4o", + OtelAttr.RESPONSE_MODEL: "gpt-4o-mini", + OtelAttr.ADDRESS: "127.0.0.1", + OtelAttr.PORT: 8000, + "custom_attribute": "ignored", + OtelAttr.CONVERSATION_ID: "conv-123", + } + filtered = _filter_metric_attributes(attributes) + assert filtered == { + OtelAttr.OPERATION: "chat", + OtelAttr.PROVIDER_NAME: "test_provider", + OtelAttr.SYSTEM: "test_system", + OtelAttr.REQUEST_MODEL: "gpt-4o", + OtelAttr.RESPONSE_MODEL: "gpt-4o-mini", + OtelAttr.ADDRESS: "127.0.0.1", + OtelAttr.PORT: 8000, + } + + +def test_capture_operation_error_keeps_only_metric_attributes() -> None: + """The error record carries the metric attribute set plus error.type, nothing else.""" + from agent_framework.observability import _capture_operation_error + + histogram = Mock() + _capture_operation_error( + attributes={ + OtelAttr.OPERATION: OtelAttr.CHAT_COMPLETION_OPERATION, + OtelAttr.REQUEST_MODEL: "test-model", + OtelAttr.CONVERSATION_ID: "conv-1", + }, + exception=TimeoutError("slow"), + operation_duration_histogram=histogram, + duration=0.25, + ) + + histogram.record.assert_called_once_with( + 0.25, + attributes={ + OtelAttr.OPERATION: OtelAttr.CHAT_COMPLETION_OPERATION, + OtelAttr.REQUEST_MODEL: "test-model", + OtelAttr.ERROR_TYPE: "TimeoutError", + }, + ) + + +def test_capture_operation_error_without_histogram_or_duration() -> None: + """No histogram or no duration means nothing is recorded.""" + from agent_framework.observability import _capture_operation_error + + histogram = Mock() + _capture_operation_error(attributes={}, exception=ValueError("x"), operation_duration_histogram=histogram) + _capture_operation_error(attributes={}, exception=ValueError("x"), duration=1.0) + histogram.record.assert_not_called() + + +async def test_chat_client_records_duration_on_error( + mock_chat_client: Any, span_exporter: InMemorySpanExporter +) -> None: + """A failed non-streaming call records gen_ai.client.operation.duration with error.type.""" + + class FailingChatClient(mock_chat_client): # type: ignore[misc, valid-type] + async def _get_non_streaming_response(self, **kwargs: Any) -> ChatResponse: + raise ValueError("boom") + + client = FailingChatClient() + histogram = Mock() + client.duration_histogram = histogram + span_exporter.clear() + + with pytest.raises(ValueError, match="boom"): + await client.get_response(messages=[Message(role="user", contents=["hi"])], options={"model": "Test"}) + + histogram.record.assert_called_once() + duration, kwargs = histogram.record.call_args[0][0], histogram.record.call_args[1] + assert duration >= 0 + attributes = kwargs["attributes"] + assert attributes[OtelAttr.ERROR_TYPE] == "ValueError" + assert attributes[OtelAttr.REQUEST_MODEL] == "Test" + assert attributes[OtelAttr.OPERATION] == OtelAttr.CHAT_COMPLETION_OPERATION + + +async def test_chat_client_records_duration_on_streaming_error( + mock_chat_client: Any, span_exporter: InMemorySpanExporter +) -> None: + """A stream that fails mid-iteration also records the duration metric with error.type.""" + + class FailingStreamChatClient(mock_chat_client): # type: ignore[misc, valid-type] + def _get_streaming_response(self, **kwargs: Any) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[Content.from_text("Hello")], role="assistant") + raise RuntimeError("stream broke") + + return ResponseStream(_stream(), finalizer=lambda updates: ChatResponse.from_updates(updates)) + + client = FailingStreamChatClient() + histogram = Mock() + client.duration_histogram = histogram + span_exporter.clear() + + with pytest.raises(RuntimeError, match="stream broke"): + async for _ in client.get_response( + messages=[Message(role="user", contents=["hi"])], stream=True, options={"model": "Test"} + ): + pass + + histogram.record.assert_called_once() + attributes = histogram.record.call_args[1]["attributes"] + assert attributes[OtelAttr.ERROR_TYPE] == "RuntimeError" + assert attributes[OtelAttr.REQUEST_MODEL] == "Test" + + +async def test_chat_client_records_duration_when_stream_setup_fails( + mock_chat_client: Any, span_exporter: InMemorySpanExporter +) -> None: + """A stream that fails before it is built records the duration metric with error.type.""" + + class FailingSetupChatClient(mock_chat_client): # type: ignore[misc, valid-type] + def _get_streaming_response(self, **kwargs: Any) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + raise RuntimeError("setup broke") + + client = FailingSetupChatClient() + histogram = Mock() + client.duration_histogram = histogram + span_exporter.clear() + + with pytest.raises(RuntimeError, match="setup broke"): + async for _ in client.get_response( + messages=[Message(role="user", contents=["hi"])], stream=True, options={"model": "Test"} + ): + pass + + histogram.record.assert_called_once() + duration, kwargs = histogram.record.call_args[0][0], histogram.record.call_args[1] + assert duration >= 0 + attributes = kwargs["attributes"] + assert attributes[OtelAttr.ERROR_TYPE] == "RuntimeError" + assert attributes[OtelAttr.REQUEST_MODEL] == "Test" + assert attributes[OtelAttr.OPERATION] == OtelAttr.CHAT_COMPLETION_OPERATION + + +async def test_chat_client_records_duration_when_stream_finalizer_fails( + mock_chat_client: Any, span_exporter: InMemorySpanExporter +) -> None: + """A stream whose finalizer raises records the duration metric with error.type.""" + + class FailingFinalizerChatClient(mock_chat_client): # type: ignore[misc, valid-type] + def _get_streaming_response(self, **kwargs: Any) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[Content.from_text("Hello")], role="assistant") + + def _broken_finalizer(updates: Any) -> ChatResponse: + raise ValueError("finalizer failed") + + return ResponseStream(_stream(), finalizer=_broken_finalizer) + + client = FailingFinalizerChatClient() + histogram = Mock() + client.duration_histogram = histogram + span_exporter.clear() + + with pytest.raises(ValueError, match="finalizer failed"): + stream = client.get_response( + messages=[Message(role="user", contents=["hi"])], stream=True, options={"model": "Test"} + ) + async for _ in stream: + pass + + histogram.record.assert_called_once() + duration, kwargs = histogram.record.call_args[0][0], histogram.record.call_args[1] + assert duration >= 0 + attributes = kwargs["attributes"] + assert attributes[OtelAttr.ERROR_TYPE] == "ValueError" + assert attributes[OtelAttr.REQUEST_MODEL] == "Test" + assert attributes[OtelAttr.OPERATION] == OtelAttr.CHAT_COMPLETION_OPERATION + + +async def test_chat_client_records_duration_when_stream_result_hook_fails( + mock_chat_client: Any, span_exporter: InMemorySpanExporter +) -> None: + """A stream whose result hook raises records the duration metric with error.type.""" + + def _broken_result_hook(response: ChatResponse) -> ChatResponse: + raise RuntimeError("result hook failed") + + client = mock_chat_client() + histogram = Mock() + client.duration_histogram = histogram + span_exporter.clear() + + with pytest.raises(RuntimeError, match="result hook failed"): + stream = client.get_response( + messages=[Message(role="user", contents=["hi"])], stream=True, options={"model": "Test"} + ) + stream.with_result_hook(_broken_result_hook) + async for _ in stream: + pass + + histogram.record.assert_called_once() + duration, kwargs = histogram.record.call_args[0][0], histogram.record.call_args[1] + assert duration >= 0 + attributes = kwargs["attributes"] + assert attributes[OtelAttr.ERROR_TYPE] == "RuntimeError" + assert attributes[OtelAttr.REQUEST_MODEL] == "Test" + assert attributes[OtelAttr.OPERATION] == OtelAttr.CHAT_COMPLETION_OPERATION + + +async def test_embedding_client_records_duration_on_error(span_exporter: InMemorySpanExporter) -> None: + """A failed embedding call records gen_ai.client.operation.duration with error.type.""" + from agent_framework import BaseEmbeddingClient, GeneratedEmbeddings + from agent_framework.observability import EmbeddingTelemetryLayer + + class RawFailingEmbeddingClient(BaseEmbeddingClient[str, list[float], Any]): # type: ignore[type-arg] + async def get_embeddings( + self, values: Sequence[str], *, options: Any = None + ) -> GeneratedEmbeddings[list[float], Any]: + raise ValueError("embed boom") + + class FailingEmbeddingClient(EmbeddingTelemetryLayer, RawFailingEmbeddingClient): # type: ignore[misc] + OTEL_PROVIDER_NAME = "test" + + def service_url(self) -> str: + return "https://test.example.com" + + client = FailingEmbeddingClient() + histogram = Mock() + client.duration_histogram = histogram + span_exporter.clear() + + with pytest.raises(ValueError, match="embed boom"): + await client.get_embeddings(["hi"], options={"model": "test-embed"}) + + histogram.record.assert_called_once() + duration, kwargs = histogram.record.call_args[0][0], histogram.record.call_args[1] + assert duration >= 0 + attributes = kwargs["attributes"] + assert attributes[OtelAttr.ERROR_TYPE] == "ValueError" + assert attributes[OtelAttr.REQUEST_MODEL] == "test-embed" + assert attributes[OtelAttr.OPERATION] == OtelAttr.EMBEDDING_OPERATION + + def test_backfill_request_model_when_unknown(span_exporter: InMemorySpanExporter): """_backfill_request_model updates the span name and REQUEST_MODEL attribute when unknown.""" from agent_framework.observability import OtelAttr, get_tracer From 540919324a3a711ce41bb9071f4aac6d5eec597c Mon Sep 17 00:00:00 2001 From: CoralGarden52 <97677340+CoralGarden52@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:12:29 +0000 Subject: [PATCH 077/108] Python: resolve postponed @response_handler annotations (#8328) * Python: resolve postponed response handler annotations * Modify handle_response to use WorkflowContext with bool Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * test: cover response handler annotation fallbacks * fix: harden response handler annotation validation * fix: resolve response handler CI checks * fix: satisfy core pyright checks --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework/_workflows/_executor.py | 17 ++-- .../_workflows/_function_executor.py | 16 ++-- .../_workflows/_request_info_mixin.py | 53 ++++++++++-- .../_workflows/_typing_utils.py | 17 +++- .../test_checkpoint_unrestricted_pickle.py | 5 +- .../tests/workflow/test_executor_future.py | 84 ++++++++++++++++++- 6 files changed, 162 insertions(+), 30 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_executor.py b/python/packages/core/agent_framework/_workflows/_executor.py index 39027955a7f..ee9734561ec 100644 --- a/python/packages/core/agent_framework/_workflows/_executor.py +++ b/python/packages/core/agent_framework/_workflows/_executor.py @@ -7,7 +7,6 @@ import inspect import logging import types -import typing from collections.abc import Awaitable, Callable from typing import Any, TypeVar, overload @@ -21,7 +20,13 @@ from ._request_info_mixin import RequestInfoMixin from ._runner_context import MessageType, RunnerContext, WorkflowMessage from ._state import State -from ._typing_utils import contains_typevar, is_instance_of, normalize_type_to_list, resolve_type_annotation +from ._typing_utils import ( + _resolve_function_annotations, # pyright: ignore[reportPrivateUsage] + contains_typevar, + is_instance_of, + normalize_type_to_list, + resolve_type_annotation, +) from ._workflow_context import WorkflowContext, validate_workflow_context_annotation logger = logging.getLogger(__name__) @@ -778,13 +783,7 @@ def _validate_handler_signature( if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty: raise ValueError(f"Handler {func.__name__} must have a type annotation for the message parameter") - # Resolve string annotations from `from __future__ import annotations`. - # Fall back to raw annotations if resolution fails (e.g. unresolvable forward refs, - # AttributeError, or RecursionError), so registration failures are easier to diagnose. - try: - type_hints = typing.get_type_hints(func) - except (NameError, AttributeError, RecursionError): - type_hints = {p.name: p.annotation for p in params} + type_hints = _resolve_function_annotations(func, params) message_type = type_hints.get(message_param.name, message_param.annotation) if message_type == inspect.Parameter.empty: diff --git a/python/packages/core/agent_framework/_workflows/_function_executor.py b/python/packages/core/agent_framework/_workflows/_function_executor.py index dc641697c14..99f60635400 100644 --- a/python/packages/core/agent_framework/_workflows/_function_executor.py +++ b/python/packages/core/agent_framework/_workflows/_function_executor.py @@ -19,12 +19,16 @@ import inspect import sys import types -import typing from collections.abc import Awaitable, Callable from typing import Any from ._executor import Executor -from ._typing_utils import contains_typevar, normalize_type_to_list, resolve_type_annotation +from ._typing_utils import ( + _resolve_function_annotations, # pyright: ignore[reportPrivateUsage] + contains_typevar, + normalize_type_to_list, + resolve_type_annotation, +) from ._workflow_context import WorkflowContext, validate_workflow_context_annotation if sys.version_info >= (3, 11): @@ -359,13 +363,7 @@ def _validate_function_signature( if not skip_message_annotation and message_param.annotation == inspect.Parameter.empty: raise ValueError(f"Function instance {func.__name__} must have a type annotation for the message parameter") - # Resolve string annotations from `from __future__ import annotations`. - # Fall back to raw annotations if resolution fails (e.g. unresolvable forward refs, - # AttributeError, or RecursionError), so registration failures are easier to diagnose. - try: - type_hints = typing.get_type_hints(func) - except (NameError, AttributeError, RecursionError): - type_hints = {p.name: p.annotation for p in params} + type_hints = _resolve_function_annotations(func, params) message_type = type_hints.get(message_param.name, message_param.annotation) if message_type == inspect.Parameter.empty: message_type = None diff --git a/python/packages/core/agent_framework/_workflows/_request_info_mixin.py b/python/packages/core/agent_framework/_workflows/_request_info_mixin.py index ac7132e2fe0..938a6e03066 100644 --- a/python/packages/core/agent_framework/_workflows/_request_info_mixin.py +++ b/python/packages/core/agent_framework/_workflows/_request_info_mixin.py @@ -11,7 +11,14 @@ from types import UnionType from typing import TYPE_CHECKING, Any, TypeVar, cast -from ._typing_utils import is_instance_of, is_type_compatible, normalize_type_to_list, resolve_type_annotation +from ._typing_utils import ( + _resolve_function_annotations, # pyright: ignore[reportPrivateUsage] + contains_typevar, + is_instance_of, + is_type_compatible, + normalize_type_to_list, + resolve_type_annotation, +) from ._workflow_context import WorkflowContext, validate_workflow_context_annotation if sys.version_info >= (3, 11): @@ -241,6 +248,19 @@ def decorator( f"Response handler {func.__name__} with explicit type parameters must specify 'response' type" ) + for param_name, param_type in [ + ("request", resolved_request_type), + ("response", resolved_response_type), + ]: + if contains_typevar(param_type): + raise ValueError( + f"Response handler {func.__name__} has an unresolved TypeVar '{param_type}' " + f"as its {param_name} type. " + "Generic TypeVar annotations are not supported for workflow type validation. " + "Use @response_handler(request=, response=) " + "to specify explicit types." + ) + final_request_type = resolved_request_type final_response_type = resolved_response_type final_output_types = normalize_type_to_list(resolved_output_type) if resolved_output_type else [] @@ -348,20 +368,39 @@ def _validate_response_handler_signature( if not skip_annotations and response_param.annotation == inspect.Parameter.empty: raise ValueError(f"Response handler {func.__name__} must have a type annotation for the response parameter") + type_hints = _resolve_function_annotations(func, params) + # Validate ctx parameter is WorkflowContext and extract type args (if annotated) ctx_param = params[3] + ctx_annotation = type_hints.get(ctx_param.name, ctx_param.annotation) if ctx_param.annotation != inspect.Parameter.empty: output_types, workflow_output_types = validate_workflow_context_annotation( - ctx_param.annotation, f"parameter '{ctx_param.name}'", "Response handler" + ctx_annotation, f"parameter '{ctx_param.name}'", "Response handler" ) else: output_types, workflow_output_types = [], [] - request_type = ( - original_request_param.annotation if original_request_param.annotation != inspect.Parameter.empty else None - ) - response_type = response_param.annotation if response_param.annotation != inspect.Parameter.empty else None - ctx_annotation = ctx_param.annotation if ctx_param.annotation != inspect.Parameter.empty else None + request_type = type_hints.get(original_request_param.name, original_request_param.annotation) + if request_type == inspect.Parameter.empty: + request_type = None + response_type = type_hints.get(response_param.name, response_param.annotation) + if response_type == inspect.Parameter.empty: + response_type = None + if ctx_annotation == inspect.Parameter.empty: + ctx_annotation = None + + for param_name, param_type in [ + ("original_request", request_type), + ("response", response_type), + ]: + if param_type is not None and contains_typevar(param_type): + raise ValueError( + f"Response handler {func.__name__} has an unresolved TypeVar '{param_type}' " + f"as its {param_name} type annotation. " + "Generic TypeVar annotations are not supported for workflow type validation. " + "Use @response_handler(request=, response=) " + "to specify explicit types." + ) return request_type, response_type, ctx_annotation, output_types, workflow_output_types diff --git a/python/packages/core/agent_framework/_workflows/_typing_utils.py b/python/packages/core/agent_framework/_workflows/_typing_utils.py index 134641f8703..251441144fb 100644 --- a/python/packages/core/agent_framework/_workflows/_typing_utils.py +++ b/python/packages/core/agent_framework/_workflows/_typing_utils.py @@ -2,7 +2,7 @@ import sys import typing -from collections.abc import Mapping +from collections.abc import Callable, Mapping, Sequence from dataclasses import InitVar, is_dataclass from types import ModuleType, UnionType from typing import Any, Literal, TypeGuard, Union, cast, get_args, get_origin, get_type_hints @@ -55,6 +55,21 @@ def contains_typevar(annotation: Any) -> bool: return any(contains_typevar(arg) for arg in get_args(annotation)) +def _resolve_function_annotations( # pyright: ignore[reportUnusedFunction] + func: Callable[..., Any], params: Sequence[Any] +) -> dict[str, Any]: + """Resolve function annotations and fall back to raw parameter annotations on failure. + + ``typing.get_type_hints`` resolves postponed annotations, but a single unresolved + forward reference prevents it from returning any hints. Keeping the raw annotations + in that case lets the caller produce its normal, parameter-specific validation error. + """ + try: + return get_type_hints(func) + except (NameError, AttributeError, RecursionError): + return {param.name: param.annotation for param in params} + + def is_chat_agent(agent: Any) -> TypeGuard[Agent]: """Check if the given agent is a Agent. diff --git a/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py index 1f78f4e6ad4..77f0ab84235 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py +++ b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py @@ -300,7 +300,10 @@ async def test_file_storage_rejects_unlisted_user_type_at_save(): graph_signature_hash="hash", state={"data": _AllowedTestState(name="test", value=1)}, ) - with pytest.raises(WorkflowCheckpointException, match="deserialization blocked|Unable to save|cannot be restored|cannot be encoded"): + with pytest.raises( + WorkflowCheckpointException, + match="deserialization blocked|Unable to save|cannot be restored|cannot be encoded", + ): await storage.save(checkpoint) assert not await asyncio.to_thread(lambda: list(Path(tmpdir).glob("*.json"))) diff --git a/python/packages/core/tests/workflow/test_executor_future.py b/python/packages/core/tests/workflow/test_executor_future.py index 81818caed31..1b59b91d596 100644 --- a/python/packages/core/tests/workflow/test_executor_future.py +++ b/python/packages/core/tests/workflow/test_executor_future.py @@ -2,12 +2,13 @@ from __future__ import annotations -from typing import Any +import inspect +from typing import TYPE_CHECKING, Any, TypeVar import pytest from pydantic import BaseModel -from agent_framework import Executor, WorkflowContext, handler +from agent_framework import Executor, WorkflowContext, handler, response_handler class MyTypeA(BaseModel): @@ -22,6 +23,18 @@ class MyTypeC(BaseModel): pass +if TYPE_CHECKING: + + class NonExistentType: + pass + + class MissingType: + pass + + +_T = TypeVar("_T") + + class TestExecutorFutureAnnotations: """Test suite for Executor with from __future__ import annotations.""" @@ -109,6 +122,71 @@ async def example(self, input: str, ctx: WorkflowContext[MyTypeA | MyTypeB, MyTy assert spec["output_types"] == [MyTypeA, MyTypeB] assert spec["workflow_output_types"] == [MyTypeC] + def test_response_handler_decorator_future_annotations(self): + """Test @response_handler with stringified annotations and future annotations.""" + + class MyExecutor(Executor): + @handler + async def example(self, input: str, ctx: WorkflowContext) -> None: + pass + + @response_handler + async def handle_response( + self, original_request: str, response: int, ctx: WorkflowContext[str, bool] + ) -> None: + pass + + exec_instance = MyExecutor(id="test") + assert (str, int) in exec_instance._response_handlers # pyright: ignore[reportPrivateUsage] + spec = exec_instance._response_handler_specs[0] # pyright: ignore[reportPrivateUsage] + assert spec["request_type"] is str + assert spec["response_type"] is int + assert spec["output_types"] == [str] + assert spec["workflow_output_types"] == [bool] + + def test_response_handler_unresolvable_annotation_raises(self): + """Test that an unresolvable response-handler annotation raises ValueError.""" + with pytest.raises(ValueError, match="Response handler parameter 'ctx' must be annotated as"): + + class BadResponseHandler(Executor): # pyright: ignore[reportUnusedClass] + @response_handler # pyright: ignore[reportUnknownArgumentType] + async def handle_response( + self, + original_request: NonExistentType, + response: int, + ctx: WorkflowContext[MyTypeA, MyTypeB], + ) -> None: + pass + + def test_response_handler_rejects_unresolved_typevar_in_request_annotation(self): + """Test that response handlers reject an unresolved request TypeVar during registration.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class GenericRequestResponseExecutor(Executor): # pyright: ignore[reportUnusedClass] + @response_handler # pyright: ignore[reportUnknownArgumentType] + async def handle_response(self, original_request: _T, response: int, ctx: WorkflowContext) -> None: + pass + + def test_response_handler_rejects_unresolved_typevar_in_response_annotation(self): + """Test that response handlers reject an unresolved response TypeVar during registration.""" + with pytest.raises(ValueError, match="unresolved TypeVar"): + + class GenericResponseExecutor(Executor): # pyright: ignore[reportUnusedClass] + @response_handler # pyright: ignore[reportUnknownArgumentType] + async def handle_response(self, original_request: str, response: _T, ctx: WorkflowContext) -> None: + pass + + def test_annotation_resolver_falls_back_to_raw_annotations(self): + """Test that annotation resolution preserves raw annotations when a hint is unresolved.""" + from agent_framework._workflows._typing_utils import _resolve_function_annotations + + def sample(value: MissingType) -> None: + pass + + params = list(inspect.signature(sample).parameters.values()) + + assert _resolve_function_annotations(sample, params)["value"] == "MissingType" + def test_handler_unresolvable_annotation_raises(self): """Test that an unresolvable forward-reference annotation raises ValueError. @@ -120,5 +198,5 @@ def test_handler_unresolvable_annotation_raises(self): class Bad(Executor): # pyright: ignore[reportUnusedClass] @handler # pyright: ignore[reportUnknownArgumentType] - async def example(self, input: NonExistentType, ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: # type: ignore[name-defined] # ty: ignore[unresolved-reference] # noqa: F821 + async def example(self, input: NonExistentType, ctx: WorkflowContext[MyTypeA, MyTypeB]) -> None: pass From 6ea65fb301b2cd240e1fc87ecb923d647e580bc8 Mon Sep 17 00:00:00 2001 From: leilei3167 Date: Tue, 15 Sep 2026 07:25:30 +0000 Subject: [PATCH 078/108] Python: close open Workflow AG-UI tool calls before approval interrupt (#8373) * Python: close open Workflow AG-UI tool calls before approval interrupt Workflow AG-UI left streamed function tool calls open when pausing for request_info approval, so clients rejected RUN_FINISHED. Drain pending tool call ends before the interrupt path, matching Agent approval emit. Fixes #8244 * Avoid unmatched TOOL_CALL_END after Workflow approval resume Only emit TOOL_CALL_END from _emit_tool_result_common when the call was opened in this run and is still open. Synthetic closure before interrupt and fresh FlowState on resume then surface TOOL_CALL_RESULT only, matching the Agent approval path. * Fix ag-ui mypy errors in approval interrupt tests Use EventType for list.count and type WorkflowContext yield_output parameters so the approval tool-call interrupt tests pass mypy. --- .../agent_framework_ag_ui/_run_common.py | 30 ++-- .../agent_framework_ag_ui/_workflow_run.py | 38 ++++- python/packages/ag-ui/tests/ag_ui/test_run.py | 16 ++ .../ag-ui/tests/ag_ui/test_run_common.py | 38 +++++ .../ag-ui/tests/ag_ui/test_workflow_run.py | 160 ++++++++++++++++++ 5 files changed, 263 insertions(+), 19 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 4f7c33826b1..cdbf1d71c47 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -763,28 +763,28 @@ def _emit_tool_result_common( snapshot_result: Any = _UNSET, # noqa: ANN401 model_items: list[dict[str, Any]] | None = None, ) -> list[BaseEvent]: - """Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup. + """Shared helper for emitting ToolCallResult (and ToolCallEnd when still open). Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result`` (MCP server tool results) delegate to this function. - Args: - call_id: Tool call identifier. - raw_result: The stringified tool result content sent back to the LLM. - flow: Current ``FlowState``. - predictive_handler: Optional predictive state handler driven by - ``predict_state_config``. - state_update: Optional deterministic state snapshot produced by a tool - returning :func:`agent_framework_ag_ui.state_update`. When present, - it is merged into ``flow.current_state`` and a ``StateSnapshotEvent`` - is emitted after the ``ToolCallResult`` event. When both - ``predictive_handler`` and ``state_update`` are active, predictive - updates are applied first, then the deterministic merge, and a - single coalesced ``StateSnapshotEvent`` is emitted. + ``TOOL_CALL_END`` is emitted only when ``call_id`` was opened in this run and + has not already been ended (including synthetic protocol closure before an + approval interrupt). On resume, a fresh ``FlowState`` never STARTs that id, + so only ``TOOL_CALL_RESULT`` is emitted — matching Agent approval resume. """ events: list[BaseEvent] = [] - events.append(ToolCallEndEvent(tool_call_id=call_id)) + # Only end tool calls that were opened in this run and are still open. + # Synthetic protocol closure before an approval interrupt (Workflow + # ``_drain_open_tool_calls``, Agent ``_emit_approval_request``) already + # emitted TOOL_CALL_END; a later real function_result — including on a + # resumed run with a fresh FlowState that never STARTed this id — must not + # emit an unmatched or duplicate END. Match Agent approval resume, which + # surfaces TOOL_CALL_RESULT only via ``_make_approval_tool_result_events``. + tool_is_open = call_id in flow.tool_calls_by_id and call_id not in flow.tool_calls_ended + if tool_is_open: + events.append(ToolCallEndEvent(tool_call_id=call_id)) flow.tool_calls_ended.add(call_id) result_content = _stringify_tool_result(raw_result) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index 4a10053777a..9ee70075f2b 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -1157,20 +1157,47 @@ def _drain_open_message() -> list[TextMessageEndEvent]: flow.accumulated_text = "" return [TextMessageEndEvent(message_id=current_message_id)] + def _drain_open_tool_calls() -> list[ToolCallEndEvent]: + """Close any still-open real tool calls tracked in flow state. + + AG-UI clients reject ``RUN_FINISHED`` (and a subsequent ``request_info`` + tool call) while a prior ``TOOL_CALL_START`` remains open. Participant + agents that stream a ``function_call`` before pausing for approval leave + that real tool id open; end it here so the interrupt path matches the + native Agent ``_emit_approval_request`` behavior. + + Marking the id in ``flow.tool_calls_ended`` also lets a later real + ``function_result`` in this run skip a duplicate ``TOOL_CALL_END``. On + resume, a fresh ``FlowState`` never STARTs that id, so + ``_emit_tool_result_common`` likewise suppresses an unmatched END and + emits ``TOOL_CALL_RESULT`` only — same as Agent approval resume. + """ + events: list[ToolCallEndEvent] = [] + for tool_call in flow.get_pending_without_end(): + tool_call_id = tool_call.get("id") + if not tool_call_id: + continue + events.append(ToolCallEndEvent(tool_call_id=tool_call_id)) + flow.tool_calls_ended.add(tool_call_id) + return events + def _drain_open_blocks() -> list[BaseEvent]: - """Close any open reasoning block and assistant text message. + """Close any open reasoning block, assistant text message, and tool calls. Emitted before content that must not sit inside an open block: a terminal event (RUN_FINISHED / RUN_ERROR, which must be the final events in the stream) or a request_info tool call (non-reasoning message content). Otherwise the block's REASONING_* / TEXT_MESSAGE_* end events would be flushed only by the post-loop - cleanup -- after the terminal event, or after the tool call. Both inner helpers - are no-ops when their block is not open, so this is always safe to call (a later - cleanup pass then simply does nothing). + cleanup -- after the terminal event, or after the tool call. Open tool calls + must also end before those boundaries so clients do not reject the stream + with active tool-call errors. The inner helpers are no-ops when nothing is + open, so this is always safe to call (a later cleanup pass then simply does + nothing). """ events: list[BaseEvent] = [] events.extend(_close_reasoning_block(flow)) events.extend(_drain_open_message()) + events.extend(_drain_open_tool_calls()) return events fwd_kwargs: dict[str, Any] = {} @@ -1411,6 +1438,9 @@ def _drain_open_blocks() -> list[BaseEvent]: for end_event in _drain_open_message(): yield end_event + for end_event in _drain_open_tool_calls(): + yield end_event + if not run_started_emitted: yield RunStartedEvent(run_id=run_id, thread_id=thread_id) diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index a6fab9957a6..ca72a071d59 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -61,6 +61,17 @@ ) +def _open_tool_call(flow: FlowState, call_id: str, name: str = "tool") -> None: + """Register a tool call as started in this run so TOOL_CALL_END is eligible.""" + entry = { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": "{}"}, + } + flow.pending_tool_calls.append(entry) + flow.tool_calls_by_id[call_id] = entry + + def _message_role(message: object) -> object: if isinstance(message, dict): return cast(dict[str, object], message).get("role") @@ -903,6 +914,7 @@ def test_emit_tool_result_closes_open_message(): # Simulate an open text message (e.g., from Feature #4 tool-only detection) flow.message_id = "open-msg-123" flow.tool_call_id = "call_456" + _open_tool_call(flow, "call_456") content = Content.from_function_result(call_id="call_456", result="tool result") @@ -2056,6 +2068,7 @@ class TestEmitMcpToolResult: def test_produces_end_and_result_events(self): """MCP tool result emits ToolCallEnd + ToolCallResult events.""" flow = FlowState() + _open_tool_call(flow, "mcp_call_1", name="mcp_tool") content = Content.from_mcp_server_tool_result( call_id="mcp_call_1", output={"results": [{"title": "Weather", "url": "https://example.com"}]}, @@ -2097,6 +2110,7 @@ def test_no_call_id_returns_empty(self): def test_serializes_non_string_output(self): """Non-string output is serialized to JSON.""" flow = FlowState() + _open_tool_call(flow, "mcp_call_6", name="mcp_tool") content = Content.from_mcp_server_tool_result( call_id="mcp_call_6", output={"key": "value", "count": 42}, @@ -2111,6 +2125,7 @@ def test_serializes_non_string_output(self): def test_output_none_falls_back_to_empty_string(self): """When output is None (default), the result content is an empty string.""" flow = FlowState() + _open_tool_call(flow, "mcp_call_none", name="mcp_tool") content = Content(type="mcp_server_tool_result", call_id="mcp_call_none") events = _emit_mcp_tool_result(content, flow) @@ -2281,6 +2296,7 @@ def test_routes_mcp_server_tool_call(self): def test_routes_mcp_server_tool_result(self): """_emit_content dispatches mcp_server_tool_result to _emit_mcp_tool_result.""" flow = FlowState() + _open_tool_call(flow, "route_test_2", name="mcp_tool") content = Content.from_mcp_server_tool_result( call_id="route_test_2", output="result data", diff --git a/python/packages/ag-ui/tests/ag_ui/test_run_common.py b/python/packages/ag-ui/tests/ag_ui/test_run_common.py index ba921638299..1841a0d37cc 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run_common.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run_common.py @@ -50,6 +50,17 @@ ) +def _open_tool_call(flow: FlowState, call_id: str, name: str = "tool") -> None: + """Register a tool call as started in this run so TOOL_CALL_END is eligible.""" + entry = { + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": "{}"}, + } + flow.pending_tool_calls.append(entry) + flow.tool_calls_by_id[call_id] = entry + + class TestNormalizeResumeInterrupts: """Tests for _normalize_resume_interrupts edge cases.""" @@ -244,6 +255,7 @@ def test_tool_result_closes_open_text_message(self): """Tool result closes any open text message (issue #3568 fix).""" content = Content.from_function_result(call_id="call_1", result="done") flow = FlowState(message_id="msg_1", accumulated_text="Hello") + _open_tool_call(flow, "call_1") events = _emit_tool_result(content, flow) event_types = [e.type for e in events] @@ -253,6 +265,30 @@ def test_tool_result_closes_open_text_message(self): assert flow.message_id is None assert flow.accumulated_text == "" + def test_tool_result_skips_end_when_never_opened_in_this_run(self): + """Resume/orphan results must not emit unmatched TOOL_CALL_END.""" + content = Content.from_function_result(call_id="call_1", result="done") + flow = FlowState() + events = _emit_tool_result(content, flow) + + event_types = [e.type for e in events] + assert "TOOL_CALL_END" not in event_types + assert "TOOL_CALL_RESULT" in event_types + assert "call_1" in flow.tool_calls_ended + + def test_tool_result_skips_duplicate_end_after_synthetic_close(self): + """A real result after synthetic protocol closure emits RESULT only once-ended.""" + content = Content.from_function_result(call_id="call_1", result="Sunny") + flow = FlowState() + _open_tool_call(flow, "call_1", name="get_weather") + flow.tool_calls_ended.add("call_1") # synthetic close before interrupt + + events = _emit_tool_result(content, flow) + + event_types = [e.type for e in events] + assert event_types.count(EventType.TOOL_CALL_END) == 0 + assert EventType.TOOL_CALL_RESULT in event_types + def test_tool_result_does_not_emit_internal_exception(self): """AG-UI events and snapshots contain only the channel-visible result.""" diagnostic = "test-token-value at /srv/private/tool.py" @@ -382,6 +418,7 @@ def test_emits_state_snapshot_after_tool_call_result(self): ) content = Content.from_function_result(call_id="call_1", result=[tool_return]) flow = FlowState() + _open_tool_call(flow, "call_1") events = _emit_tool_result(content, flow) event_types = [e.type for e in events] @@ -906,6 +943,7 @@ def test_mcp_tool_result_emits_state_snapshot_from_additional_properties(self): additional_properties={TOOL_RESULT_STATE_KEY: {"mcp_ok": True}}, ) flow = FlowState() + _open_tool_call(flow, "mcp_1", name="mcp_tool") events = _emit_mcp_tool_result(content, flow) event_types = [e.type for e in events] diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py index 2992439fa46..be6213d2198 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py @@ -473,6 +473,166 @@ async def requester(message: Any, ctx: WorkflowContext) -> None: assert content_index < end_index < request_start_index +async def test_workflow_run_approval_pause_closes_open_real_tool_call() -> None: + """Streamed function calls must receive TOOL_CALL_END before approval interrupt. + + Regression for microsoft/agent-framework#8244: Workflow AG-UI clients reject + RUN_FINISHED while a real tool call id is still open after request_info. + """ + + @executor(id="approval_with_streamed_tool") + async def approval_with_streamed_tool( + message: Any, ctx: WorkflowContext[Any, AgentResponseUpdate] + ) -> None: + del message + function_call = Content.from_function_call( + call_id="weather-call", + name="api_getWeather", + arguments={"city": "Seattle"}, + ) + await ctx.yield_output(AgentResponseUpdate(contents=[function_call], role=None)) + approval_request = Content.from_function_approval_request(id="approval-1", function_call=function_call) + await ctx.request_info(approval_request, Content, request_id="approval-1") + + workflow = WorkflowBuilder(start_executor=approval_with_streamed_tool).build() + events = [event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow)] + + real_tool_start_index = next( + i + for i, event in enumerate(events) + if event.type == "TOOL_CALL_START" and getattr(event, "tool_call_id", None) == "weather-call" + ) + real_tool_end_index = next( + i + for i, event in enumerate(events) + if event.type == "TOOL_CALL_END" and getattr(event, "tool_call_id", None) == "weather-call" + ) + request_info_start_index = next( + i + for i, event in enumerate(events) + if event.type == "TOOL_CALL_START" + and getattr(event, "tool_call_name", None) == "request_info" + and getattr(event, "tool_call_id", None) == "approval-1" + ) + request_info_end_index = next( + i + for i, event in enumerate(events) + if event.type == "TOOL_CALL_END" and getattr(event, "tool_call_id", None) == "approval-1" + ) + run_finished_index = next(i for i, event in enumerate(events) if event.type == "RUN_FINISHED") + + assert real_tool_start_index < real_tool_end_index + assert real_tool_end_index < request_info_start_index + assert request_info_start_index < request_info_end_index < run_finished_index + + open_tool_ids: set[str] = set() + for event in events[: run_finished_index + 1]: + tool_call_id = getattr(event, "tool_call_id", None) + if not isinstance(tool_call_id, str): + continue + if event.type == "TOOL_CALL_START": + open_tool_ids.add(tool_call_id) + elif event.type == "TOOL_CALL_END": + open_tool_ids.discard(tool_call_id) + assert open_tool_ids == set(), f"Tool calls still open at RUN_FINISHED: {sorted(open_tool_ids)}" + + finished = events[run_finished_index] + interrupts = _interrupts_from_run_finished(finished) + assert interrupts[0]["id"] == "approval-1" + + +async def test_workflow_run_approval_resume_skips_unmatched_tool_call_end() -> None: + """After synthetic close, resume must not re-END the original tool call id. + + Regression for PR review on #8373 / #8244: a fresh FlowState on resume would + otherwise emit TOOL_CALL_END via _emit_tool_result_common without a matching + TOOL_CALL_START in that run. Match Agent approval resume (RESULT only). + """ + + class ApprovalWithStreamedTool(Executor): + def __init__(self) -> None: + super().__init__(id="approval_with_streamed_tool") + + @handler + async def start(self, message: Any, ctx: WorkflowContext[Any, AgentResponseUpdate]) -> None: + del message + function_call = Content.from_function_call( + call_id="weather-call", + name="api_getWeather", + arguments={"city": "Seattle"}, + ) + await ctx.yield_output(AgentResponseUpdate(contents=[function_call], role=None)) + approval_request = Content.from_function_approval_request(id="approval-1", function_call=function_call) + await ctx.request_info(approval_request, Content, request_id="approval-1") + + @response_handler + async def handle_approval( + self, original_request: Content, response: Content, ctx: WorkflowContext[Any, AgentResponseUpdate | str] + ) -> None: + del original_request + call_id = "weather-call" + if bool(response.approved): + await ctx.yield_output( + AgentResponseUpdate( + contents=[Content.from_function_result(call_id=call_id, result="Sunny in Seattle")], + role="tool", + ) + ) + await ctx.yield_output("Weather tool approved.") + else: + await ctx.yield_output("Weather tool rejected.") + + workflow = WorkflowBuilder(start_executor=ApprovalWithStreamedTool()).build() + first_events = [ + event async for event in run_workflow_stream({"messages": [{"role": "user", "content": "go"}]}, workflow) + ] + first_finished = [event for event in first_events if event.type == "RUN_FINISHED"][0] + interrupt_payload = _interrupts_from_run_finished(first_finished) + interrupt_value = _interrupt_metadata_value(interrupt_payload[0]) + + # Interrupt turn must close the real tool id before RUN_FINISHED. + assert any( + event.type == "TOOL_CALL_END" and getattr(event, "tool_call_id", None) == "weather-call" + for event in first_events + ) + + resumed_events: list[Any] = [ + event + async for event in run_workflow_stream( + { + "messages": [], + "resume": [ + { + "interruptId": "approval-1", + "status": "resolved", + "payload": { + "type": "function_approval_response", + "approved": True, + "id": "approval-1", + "function_call": interrupt_value.get("function_call"), + }, + } + ], + }, + workflow, + ) + ] + + assert "RUN_ERROR" not in [event.type for event in resumed_events] + assert not any( + event.type in {"TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END"} + and getattr(event, "tool_call_id", None) == "weather-call" + for event in resumed_events + ), "Resume must not re-open or re-end the synthetically closed tool call id" + result_events = [ + event + for event in resumed_events + if event.type == "TOOL_CALL_RESULT" and getattr(event, "tool_call_id", None) == "weather-call" + ] + assert len(result_events) == 1 + assert "Sunny" in result_events[0].content # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + async def test_workflow_run_request_info_interrupt_uses_raw_dict_value(): """Dict request payloads should be preserved in canonical interrupt metadata.""" From 8236fe0eb1c0748ec27aeea493c31d9f3f792a1e Mon Sep 17 00:00:00 2001 From: Harsheet Shah <50236780+harsheet-shah@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:27:06 +0000 Subject: [PATCH 079/108] Python: perf(foundry-hosting): parallelize pre-model storage reads (#8363) * perf(foundry-hosting): parallelize pre-model storage reads The non-workflow response handler loaded the caller's input items, the prior conversation history, and the agent session with three strictly-sequential awaits before the model call. These are independent storage round-trips with no data dependency, so on warm/multi-turn requests they added avoidable serial latency to the request critical path. Load them concurrently: the input/history reads are gathered in a new _load_request_messages helper and kicked off (via ensure_future) so they overlap the session load. Result ordering (history precedes input) and the existing error semantics are preserved; the read task is cancelled if session preparation fails. Stateless single-turn requests are unaffected (history and session loads short-circuit without a round-trip). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d900395-13d0-4698-bf9d-f6670f9e545c * address review: own/cancel read tasks, assemble in helper, add tests - _load_request_messages now owns both read tasks and, on any BaseException (including CancelledError/GeneratorExit from gather's first-failure or an outer cancellation), cancels and drains the sibling so no storage read is orphaned. - The helper returns the assembled [*history, *input] list, so the message ordering rule lives only in the helper (per review); the caller no longer reverses the tuple. - Session-prep cleanup now catches BaseException, cancels AND awaits (drains) the in-flight read task, and logs only ordinary Exceptions. - Add TestParallelRequestReads: overlap, message ordering, history-skip when not agent-server history, and sibling-cancellation on a failed read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d900395-13d0-4698-bf9d-f6670f9e545c * test(foundry-hosting): cover session-prep failure cancels in-flight reads Adds a handler-level regression test proving that when session preparation fails, the concurrently-launched input/history read task started by _handle_inner_agent is cancelled and drained rather than orphaned. Completes the no-orphan coverage requested in review (the helper-level read-failure path was already covered). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3d900395-13d0-4698-bf9d-f6670f9e545c --------- Co-authored-by: Harsheet Shah Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Copilot-Session: 3d900395-13d0-4698-bf9d-f6670f9e545c --- .../_responses.py | 74 ++++++-- .../foundry_hosting/tests/test_responses.py | 158 ++++++++++++++++++ 2 files changed, 222 insertions(+), 10 deletions(-) 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 682103a24d6..77d56c5e631 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -707,6 +707,50 @@ async def _handle_response( for event in self._emit_failure(response_event_stream, tracker, ex): yield event + async def _load_request_messages( + self, + context: ResponseContext, + *, + approval_storage: FunctionApprovalStore | None, + ) -> list[Message]: + """Load the request's input and prior history concurrently, assembled for the run. + + The caller's input items and the conversation history are independent + storage round-trips with no data dependency, so they are fetched in + parallel to remove serial latency from the request critical path. The + history read is only issued when AgentServer is the history source; in + stateless single-turn requests it short-circuits without a round-trip. + + Returns the messages already ordered as model input (history precedes + input), so the message-ordering rule lives only here and callers do not + need to know the storage-result ordering. If either read fails, the + sibling task is cancelled and drained so no storage read is orphaned. + """ + + async def _load_input() -> list[Message]: + input_items = await context.get_input_items() + return await _items_to_messages(input_items, approval_storage=approval_storage) + + async def _load_history() -> list[Message]: + if not self._uses_agent_server_history: + return [] + history = await context.get_history() + return await _output_items_to_messages(history, approval_storage=approval_storage) + + input_task = asyncio.ensure_future(_load_input()) + history_task = asyncio.ensure_future(_load_history()) + try: + input_messages, history_messages = await asyncio.gather(input_task, history_task) + except BaseException: + # gather surfaces the first failure without cancelling the sibling, and a + # cancellation of this coroutine must not leave either read running. Cancel + # both and await them so no storage operation is orphaned after we unwind. + input_task.cancel() + history_task.cancel() + await asyncio.gather(input_task, history_task, return_exceptions=True) + raise + return [*history_messages, *input_messages] + async def _handle_inner_agent( self, request: CreateResponse, @@ -728,6 +772,7 @@ async def _handle_inner_agent( "The agent will restart from the original input." ) + request_messages_task: asyncio.Task[list[Message]] | None = None try: request_context = get_request_context() approval_storage = self._function_approval_storage_provider.get_store( @@ -737,6 +782,13 @@ async def _handle_inner_agent( config=self.config, platform_context=request_context ) + # Load the caller's input items and prior conversation history concurrently with the + # session load below. These are independent storage round-trips with no data dependency + # between them, so overlapping them removes serial latency from the request critical path. + request_messages_task = asyncio.ensure_future( + self._load_request_messages(context, approval_storage=approval_storage) + ) + previous_response_id = request.get("previous_response_id") session_load_id = context.conversation_id or previous_response_id session = await session_storage.get(session_load_id) if session_load_id is not None else None @@ -747,8 +799,16 @@ async def _handle_inner_agent( ) session = self._agent.create_session() session_save_id = context.conversation_id or context.response_id - except Exception as ex: - logger.error("Failed to prepare state storage: %s", ex, exc_info=(type(ex), ex, ex.__traceback__)) + except BaseException as ex: + # Session preparation failed (or the request was cancelled / the stream closed — + # neither of which is an Exception). Cancel and drain the in-flight message-loading + # task so it is not orphaned, and log only ordinary failures. + if request_messages_task is not None: + request_messages_task.cancel() + with suppress(BaseException): + await request_messages_task + if isinstance(ex, Exception): + logger.error("Failed to prepare state storage: %s", ex, exc_info=(type(ex), ex, ex.__traceback__)) raise request_failure: Exception | None = None @@ -763,15 +823,9 @@ async def _handle_inner_agent( # prior turn, so AgentServer-history mode always starts the model call statelessly. session.service_session_id = None - input_items = await context.get_input_items() - input_messages = await _items_to_messages(input_items, approval_storage=approval_storage) - - history_messages: list[Message] = [] - if self._uses_agent_server_history: - history = await context.get_history() - history_messages = await _output_items_to_messages(history, approval_storage=approval_storage) + messages = await request_messages_task run_kwargs: dict[str, Any] = { - "messages": [*history_messages, *input_messages], + "messages": messages, "session": session, } chat_options, are_options_set = _to_chat_options(request) diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index b4522d29107..97b076450bd 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -6171,3 +6171,161 @@ async def test_workflow_yields_checkpoint_event_when_resilient_background(self, # endregion + + +# region Parallel pre-model reads (_load_request_messages) + + +class TestParallelRequestReads: + """Covers the concurrent input/history read helper introduced to remove serial + latency from the request critical path (overlap, ordering, and no orphaned + storage reads when one side fails).""" + + @staticmethod + def _identity_converters(monkeypatch: pytest.MonkeyPatch) -> None: + async def _passthrough(items: Any, *, approval_storage: Any = None) -> list[Any]: + del approval_storage + return list(items) + + monkeypatch.setattr("agent_framework_foundry_hosting._responses._items_to_messages", _passthrough) + monkeypatch.setattr("agent_framework_foundry_hosting._responses._output_items_to_messages", _passthrough) + + async def test_reads_overlap_and_preserve_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._identity_converters(monkeypatch) + server = _make_server(_make_agent()) + server._uses_agent_server_history = True # pyright: ignore[reportPrivateUsage] + + history_msg = Message(role="assistant", contents=[Content.from_text("H")]) + input_msg = Message(role="user", contents=[Content.from_text("I")]) + + input_started = asyncio.Event() + history_started = asyncio.Event() + release = asyncio.Event() + + async def get_input_items() -> list[Message]: + input_started.set() + await release.wait() + return [input_msg] + + async def get_history() -> list[Message]: + history_started.set() + await release.wait() + return [history_msg] + + context = MagicMock(spec=ResponseContext) + context.get_input_items = get_input_items + context.get_history = get_history + + task = asyncio.ensure_future(server._load_request_messages(context, approval_storage=None)) # pyright: ignore[reportPrivateUsage] + try: + # Both reads must be in-flight before either is allowed to finish — proves they overlap. + await asyncio.wait_for(input_started.wait(), timeout=1) + await asyncio.wait_for(history_started.wait(), timeout=1) + release.set() + messages = await asyncio.wait_for(task, timeout=1) + finally: + release.set() + + # History precedes input in the assembled model input, and the helper owns the ordering. + assert messages == [history_msg, input_msg] + + async def test_history_read_skipped_without_agent_server_history(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._identity_converters(monkeypatch) + server = _make_server(_make_agent()) + server._uses_agent_server_history = False # pyright: ignore[reportPrivateUsage] + + input_msg = Message(role="user", contents=[Content.from_text("I")]) + + async def get_input_items() -> list[Message]: + return [input_msg] + + context = MagicMock(spec=ResponseContext) + context.get_input_items = get_input_items + context.get_history = AsyncMock() + + messages = await server._load_request_messages(context, approval_storage=None) # pyright: ignore[reportPrivateUsage] + + assert messages == [input_msg] + context.get_history.assert_not_awaited() + + async def test_failed_read_cancels_and_drains_sibling(self, monkeypatch: pytest.MonkeyPatch) -> None: + self._identity_converters(monkeypatch) + server = _make_server(_make_agent()) + server._uses_agent_server_history = True # pyright: ignore[reportPrivateUsage] + + sibling_cancelled = asyncio.Event() + + async def get_input_items() -> list[Message]: + raise RuntimeError("input read boom") + + async def get_history() -> list[Message]: + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + sibling_cancelled.set() + raise + return [] + + context = MagicMock(spec=ResponseContext) + context.get_input_items = get_input_items + context.get_history = get_history + + with pytest.raises(RuntimeError, match="input read boom"): + await asyncio.wait_for( + server._load_request_messages(context, approval_storage=None), # pyright: ignore[reportPrivateUsage] + timeout=2, + ) + + # The still-blocked history read must have been cancelled, not left orphaned. + await asyncio.wait_for(sibling_cancelled.wait(), timeout=1) + + async def test_session_preparation_failure_cancels_pending_reads(self) -> None: + """If session preparation fails, the concurrently-launched read must be cancelled and + drained by `_handle_inner_agent`, not left running as an orphan after the request fails.""" + input_started = asyncio.Event() + sibling_cancelled = asyncio.Event() + + class _GetFailsOnceReadStarted(SessionStore): + async def get(self, session_id: str) -> AgentSession | None: + del session_id + # Fail session preparation only once the concurrent read is genuinely in-flight, + # so this proves the handler cancels a running read (not a not-yet-started task). + await input_started.wait() + raise RuntimeError("session prep boom") + + server = _make_server(_make_agent(), session_store=_GetFailsOnceReadStarted()) + request = CreateResponse(model="m", input="hi", stream=True) + # A previous_response_id makes session_load_id non-None so the failing get() is reached. + request["previous_response_id"] = "resp-x" + context = ResponseContext(response_id="response-1", mode_flags=MagicMock()) + + async def get_input_items(_self: Any) -> list[Any]: + input_started.set() + try: + await asyncio.sleep(3600) + except asyncio.CancelledError: + sibling_cancelled.set() + raise + return [] + + with ( + patch.object(ResponseContext, "get_input_items", new=get_input_items), + patch.object(ResponseContext, "get_history", new=AsyncMock(return_value=[])), + ): + handler = cast( + AsyncGenerator[Any, None], + server._handle_response(request, context, asyncio.Event()), # pyright: ignore[reportPrivateUsage] + ) + + async def _drain() -> list[Any]: + return [event async for event in handler] + + events = await asyncio.wait_for(_drain(), timeout=2) + + types = [event.get("type") for event in events if isinstance(event, Mapping)] + assert types[-1] == "response.failed" + # The in-flight input read must have been cancelled by the handler's cleanup, not orphaned. + await asyncio.wait_for(sibling_cancelled.wait(), timeout=1) + + +# endregion From 007593ff0170cf66fbcc80251a8c00a4809e6a94 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:36:03 +0000 Subject: [PATCH 080/108] Python: Build(deps-dev): bump ruff from 0.16.5 to 0.16.6 in /python in the basics group (#8266) * Build(deps-dev): bump ruff in /python in the basics group Bumps the basics group in /python with 1 update: [ruff](https://github.com/astral-sh/ruff). Updates `ruff` from 0.16.5 to 0.16.6 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.5...0.16.6) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: basics ... Signed-off-by: dependabot[bot] * Fix Ruff lockfiles and config aliases Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Clarify Ruff selector aliases Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Document Ruff selector convention Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Annotate Ruff selector aliases Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Polish Ruff selector comments Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Clarify Ruff selector comments Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Preserve Ruff rule code selectors Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Use Ruff code for selector preference ignore Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Group Ruff T selectors Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Fix Hyperlight rejected output cleanup test Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> --- python/packages/lab/pyproject.toml | 2 +- python/packages/lab/uv.lock | 44 +++++++++++++++--------------- python/pyproject.toml | 9 +++--- python/uv.lock | 44 +++++++++++++++--------------- 4 files changed, 50 insertions(+), 49 deletions(-) diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 2db0359a750..e865740007c 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -59,7 +59,7 @@ math = [ dev = [ "agent-framework-openai>=1.14.2,<2", "uv==0.12.9", - "ruff==0.16.5", + "ruff==0.16.6", "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-cov==7.1.0", diff --git a/python/packages/lab/uv.lock b/python/packages/lab/uv.lock index 00f19ccb06d..a642845cb82 100644 --- a/python/packages/lab/uv.lock +++ b/python/packages/lab/uv.lock @@ -146,7 +146,7 @@ dev = [ { name = "pytest-asyncio", specifier = "==1.4.0" }, { name = "pytest-cov", specifier = "==7.1.0" }, { name = "rich", specifier = ">=13.7.1,<15.0.0" }, - { name = "ruff", specifier = "==0.16.5" }, + { name = "ruff", specifier = "==0.16.6" }, { name = "tomli", specifier = "==2.4.1" }, { name = "tomli-w", specifier = "==1.2.0" }, { name = "uv", specifier = "==0.12.9" }, @@ -5551,27 +5551,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, - { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, - { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, - { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, - { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, - { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, - { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] diff --git a/python/pyproject.toml b/python/pyproject.toml index b7d34ef6105..ff5b6f046ae 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ dev = [ "uv==0.12.9", "flit==4.0.2", - "ruff==0.16.5", + "ruff==0.16.6", "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-cov==7.1.0", @@ -141,12 +141,12 @@ select = [ "RET", # flake8-return check "RSE", # raise exception parantheses check "RUF", # RUF specific rules + "S", # Bandit checks "SIM", # flake8-simplify check - "T20", # typing checks + "T100", # Debugger + "T20", # flake8-print checks "TD", # todos "W", # pycodestyle warning checks - "T100", # Debugger, - "S", # Bandit checks ] ignore = [ "D100", # allow missing docstring in public module @@ -159,6 +159,7 @@ ignore = [ "B027", # allow empty non-abstract method in ABC "B905", # `zip()` without an explicit `strict=` parameter "RUF067", # allow version detection in __init__.py + "RUF201", # allow stable rule codes in Ruff selectors ] [tool.ruff.lint.per-file-ignores] diff --git a/python/uv.lock b/python/uv.lock index d51ac06d7a7..f74bf0f7179 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -171,7 +171,7 @@ dev = [ { name = "pytest-timeout", specifier = "==2.4.0" }, { name = "pytest-xdist", extras = ["psutil"], specifier = "==3.8.0" }, { name = "rich", specifier = ">=13.7.1,<16.0.0" }, - { name = "ruff", specifier = "==0.16.5" }, + { name = "ruff", specifier = "==0.16.6" }, { name = "tomli", specifier = "==2.4.1" }, { name = "ty", specifier = "==0.0.77" }, { name = "uv", specifier = "==0.12.9" }, @@ -5530,27 +5530,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, - { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, - { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, - { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, - { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, - { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, - { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] From bf4d8dc02742c7fb0effa831d3c2f809125fa062 Mon Sep 17 00:00:00 2001 From: Saibernard <112599512+Saibernard@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:41:21 +0000 Subject: [PATCH 081/108] Python: .NET: Show Aspire traces in DevUI (#7874) * .NET: Retrieve Aspire traces in DevUI aggregator * .NET: Display Aspire traces in DevUI frontend * .NET: Fix Aspire DevUI sample startup configuration * .NET: Wait for complete Aspire trace snapshots * .NET: Respect Aspire trace retrieval capability * .NET: Preserve trace mapping with response compression * .NET: Capture response IDs from oversized SSE events * .NET: Preserve SSE response ID precedence * Python: Preserve trace snapshots and bound polling time Keep collected spans across transient failures and enforce a six-second deadline across requests, response bodies, and delays. Move frontend tests out of Python test discovery and format the regenerated bundle for the required hooks. --------- Co-authored-by: Eduard van Valkenburg --- dotnet/Directory.Packages.props | 1 + .../DevUIIntegration.AppHost.csproj | 4 +- .../Properties/launchSettings.json | 4 +- .../AgentFrameworkBuilderExtensions.cs | 6 +- .../AspireDashboardTraceClient.cs | 318 +++++ .../DevUIAggregatorHostedService.cs | 342 +++++- .../JsonResponseIdCapture.cs | 72 ++ .../README.md | 13 + .../SseResponseIdCapture.cs | 150 +++ .../AspireDashboardTracingTests.cs | 1089 +++++++++++++++++ .../agent_framework_devui/ui/assets/index.css | 3 +- .../agent_framework_devui/ui/assets/index.js | 46 +- python/packages/devui/frontend/package.json | 1 + python/packages/devui/frontend/src/App.tsx | 10 +- .../components/features/agent/agent-view.tsx | 91 +- .../features/agent/context-inspector.tsx | 22 +- .../src/components/layout/debug-panel.tsx | 22 +- .../devui/frontend/src/services/api.ts | 113 +- .../devui/frontend/src/stores/devuiStore.ts | 15 +- .../devui/frontend/src/types/index.ts | 4 +- .../frontend/test/trace-polling.test.mjs | 260 ++++ 21 files changed, 2511 insertions(+), 75 deletions(-) create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AspireDashboardTraceClient.cs create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/JsonResponseIdCapture.cs create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/SseResponseIdCapture.cs create mode 100644 dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AspireDashboardTracingTests.cs create mode 100644 python/packages/devui/frontend/test/trace-polling.test.mjs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 806eef9ebde..4a37e210fb6 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -17,6 +17,7 @@ + diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj index 35c8eb709db..a7d06802a77 100644 --- a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj @@ -4,7 +4,8 @@ Exe - net10.0 + net10.0 + enable enable @@ -18,6 +19,7 @@ + diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json index 1012f97aa1e..6d5f6ddd5f5 100644 --- a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json @@ -11,7 +11,7 @@ "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17250", "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18100", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17250", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17252", "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" } }, @@ -25,7 +25,7 @@ "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17251", "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18101", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17251", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17253", "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" } diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs index 7e7d5b16c08..618e71596f3 100644 --- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs @@ -6,6 +6,7 @@ using System.Threading; using Aspire.Hosting.AgentFramework; using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -63,7 +64,10 @@ public static IResourceBuilder AddDevUI( builder.Eventing.Subscribe(resource, async (e, ct) => { var logger = e.Logger; - var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService().CreateLogger()); + var aggregator = new DevUIAggregatorHostedService( + resource, + e.Services.GetRequiredService().CreateLogger(), + e.Services.GetService()); try { diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AspireDashboardTraceClient.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AspireDashboardTraceClient.cs new file mode 100644 index 00000000000..b3771cfc59a --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AspireDashboardTraceClient.cs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace Aspire.Hosting.AgentFramework; + +/// +/// Connection details for the Aspire Dashboard telemetry API. +/// +internal sealed record AspireDashboardConnection(Uri BaseUri, string? ApiKey); + +/// +/// Converts OTLP JSON returned by the Aspire Dashboard telemetry API into DevUI trace events. +/// +internal static class AspireDashboardTraceClient +{ + private const string ApiKeyHeaderName = "x-api-key"; + + /// + /// Checks whether the configured Aspire Dashboard exposes its authenticated telemetry API. + /// + internal static async Task IsAvailableAsync( + HttpClient client, + Uri dashboardBaseUri, + string? dashboardApiKey, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(dashboardBaseUri, "/api/telemetry/resources")); + AddApiKey(request, dashboardApiKey); + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode; + } + + /// + /// Reads the spans for one response trace from the Aspire Dashboard telemetry API. + /// + internal static async Task?> GetTraceEventsAsync( + HttpClient client, + Uri dashboardBaseUri, + string? dashboardApiKey, + string traceId, + string responseId, + string entityId, + CancellationToken cancellationToken) + { + var path = $"/api/telemetry/traces/{Uri.EscapeDataString(traceId)}"; + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(dashboardBaseUri, path)); + AddApiKey(request, dashboardApiKey); + + using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return []; + } + + if (!response.IsSuccessStatusCode) + { + return null; + } + + using var document = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false), + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (document.RootElement.TryGetProperty("totalCount", out var totalCount) && + totalCount.TryGetInt64(out var total) && + document.RootElement.TryGetProperty("returnedCount", out var returnedCount) && + returnedCount.TryGetInt64(out var returned) && + total != returned) + { + return null; + } + + if (!document.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) + { + return []; + } + + return ConvertToTraceEvents(data, responseId, entityId); + } + + private static void AddApiKey(HttpRequestMessage request, string? dashboardApiKey) + { + if (!string.IsNullOrEmpty(dashboardApiKey)) + { + request.Headers.TryAddWithoutValidation(ApiKeyHeaderName, dashboardApiKey); + } + } + + /// + /// Converts an OTLP trace response to the trace-event contract consumed by DevUI. + /// + internal static IReadOnlyList ConvertToTraceEvents( + JsonElement response, + string responseId, + string entityId) + { + var results = new List(); + + if (!response.TryGetProperty("resourceSpans", out var resourceSpans) || + resourceSpans.ValueKind != JsonValueKind.Array) + { + return results; + } + + foreach (var resourceSpan in resourceSpans.EnumerateArray()) + { + if (!resourceSpan.TryGetProperty("scopeSpans", out var scopeSpans) || + scopeSpans.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var scopeSpan in scopeSpans.EnumerateArray()) + { + if (!scopeSpan.TryGetProperty("spans", out var spans) || spans.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var span in spans.EnumerateArray()) + { + var data = ConvertSpan(span, responseId, entityId); + if (data is not null) + { + results.Add(new JsonObject + { + ["type"] = "response.trace.completed", + ["data"] = data, + ["sequence_number"] = 0 + }); + } + } + } + } + + return results; + } + + private static JsonObject? ConvertSpan(JsonElement span, string responseId, string entityId) + { + if (!TryGetString(span, "traceId", out var traceId) || + !TryGetString(span, "spanId", out var spanId) || + !TryGetString(span, "name", out var name)) + { + return null; + } + + var startTime = ReadUnixNanoseconds(span, "startTimeUnixNano"); + var endTime = ReadUnixNanoseconds(span, "endTimeUnixNano"); + var status = ReadStatus(span, out var error); + + var data = new JsonObject + { + ["type"] = "trace_span", + ["span_id"] = spanId, + ["trace_id"] = traceId, + ["parent_span_id"] = ReadOptionalString(span, "parentSpanId"), + ["operation_name"] = name, + ["start_time"] = startTime, + ["end_time"] = endTime, + ["duration_ms"] = startTime is not null && endTime is not null + ? (endTime.Value - startTime.Value) * 1000 + : null, + ["attributes"] = ReadAttributes(span), + ["status"] = status, + ["response_id"] = responseId, + ["entity_id"] = entityId + }; + + if (error is not null) + { + data["error"] = error; + } + + if (span.TryGetProperty("events", out var events) && events.ValueKind == JsonValueKind.Array) + { + var convertedEvents = new JsonArray(); + foreach (var spanEvent in events.EnumerateArray()) + { + convertedEvents.Add(new JsonObject + { + ["name"] = ReadOptionalString(spanEvent, "name"), + ["timestamp"] = ReadUnixNanoseconds(spanEvent, "timeUnixNano"), + ["attributes"] = ReadAttributes(spanEvent) + }); + } + + data["events"] = convertedEvents; + } + + return data; + } + + private static string ReadStatus(JsonElement span, out string? error) + { + error = null; + if (!span.TryGetProperty("status", out var status) || status.ValueKind != JsonValueKind.Object) + { + return "StatusCode.UNSET"; + } + + var code = status.TryGetProperty("code", out var codeElement) && codeElement.TryGetInt32(out var value) + ? value + : 0; + + if (code == 2) + { + error = ReadOptionalString(status, "message") ?? "Unknown error"; + return "ERROR"; + } + + return code == 1 ? "OK" : "StatusCode.UNSET"; + } + + private static JsonObject ReadAttributes(JsonElement container) + { + var result = new JsonObject(); + if (!container.TryGetProperty("attributes", out var attributes) || attributes.ValueKind != JsonValueKind.Array) + { + return result; + } + + foreach (var attribute in attributes.EnumerateArray()) + { + if (!TryGetString(attribute, "key", out var key) || + !attribute.TryGetProperty("value", out var value) || + value.ValueKind != JsonValueKind.Object) + { + continue; + } + + result[key] = ReadAnyValue(value); + } + + return result; + } + + private static JsonNode? ReadAnyValue(JsonElement value) + { + if (value.TryGetProperty("stringValue", out var stringValue)) + { + return JsonValue.Create(stringValue.GetString()); + } + + if (value.TryGetProperty("boolValue", out var boolValue) && + boolValue.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return JsonValue.Create(boolValue.GetBoolean()); + } + + if (value.TryGetProperty("intValue", out var intValue)) + { + if (intValue.ValueKind == JsonValueKind.Number && intValue.TryGetInt64(out var number)) + { + return JsonValue.Create(number); + } + + if (intValue.ValueKind == JsonValueKind.String && + long.TryParse(intValue.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out number)) + { + return JsonValue.Create(number); + } + } + + if (value.TryGetProperty("doubleValue", out var doubleValue) && doubleValue.TryGetDouble(out var floatingPoint)) + { + return JsonValue.Create(floatingPoint); + } + + return JsonNode.Parse(value.GetRawText()); + } + + private static double? ReadUnixNanoseconds(JsonElement container, string propertyName) + { + if (!container.TryGetProperty(propertyName, out var value)) + { + return null; + } + + if (value.ValueKind == JsonValueKind.String && + decimal.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var nanoseconds)) + { + return (double)(nanoseconds / 1_000_000_000m); + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetDecimal(out nanoseconds)) + { + return (double)(nanoseconds / 1_000_000_000m); + } + + return null; + } + + private static bool TryGetString(JsonElement container, string propertyName, out string value) + { + value = string.Empty; + if (!container.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString()!; + return true; + } + + private static string? ReadOptionalString(JsonElement container, string propertyName) + => TryGetString(container, propertyName, out var value) && !string.IsNullOrEmpty(value) ? value : null; +} diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs index d87e6ea28d1..ae182609614 100644 --- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; @@ -17,6 +18,7 @@ using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -35,6 +37,13 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable private WebApplication? _app; private readonly DevUIResource _resource; private readonly ILogger _logger; + private readonly IConfiguration? _configuration; + private readonly AspireDashboardConnection? _dashboardConnectionOverride; + private readonly SemaphoreSlim _tracingProbeLock = new(1, 1); + private bool _tracingAvailable; + private int _tracingUnavailableLogged; + private int _tracingEnabledLogged; + private int _disposed; // Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable) private readonly Dictionary? _frontendResources; @@ -43,12 +52,19 @@ internal sealed class DevUIAggregatorHostedService : IAsyncDisposable // Populated when the aggregator routes conversation requests to a positively-resolved backend. private readonly ConcurrentDictionary _conversationBackendMap = new(StringComparer.OrdinalIgnoreCase); + // Maps OpenAI response IDs to the Aspire resource and W3C trace ID propagated to that backend request. + private readonly ConcurrentDictionary _responseTraceMap = new(StringComparer.Ordinal); + public DevUIAggregatorHostedService( DevUIResource resource, - ILogger logger) + ILogger logger, + IConfiguration? configuration = null, + AspireDashboardConnection? dashboardConnectionOverride = null) { this._resource = resource; this._logger = logger; + this._configuration = configuration; + this._dashboardConnectionOverride = dashboardConnectionOverride; this._frontendResources = LoadFrontendResources(logger); } @@ -99,11 +115,18 @@ public async Task StopAsync(CancellationToken cancellationToken) public async ValueTask DisposeAsync() { + if (Interlocked.Exchange(ref this._disposed, 1) != 0) + { + return; + } + if (this._app is not null) { await this._app.DisposeAsync().ConfigureAwait(false); this._app = null; } + + this._tracingProbeLock.Dispose(); } /// @@ -248,8 +271,10 @@ private async Task TryServeResourceAsync(HttpContext context, string resou return true; } - private static IResult GetMeta() + private async Task GetMetaAsync(HttpContext context) { + var tracingAvailable = await this.IsTracingAvailableAsync(context).ConfigureAwait(false); + return Results.Json(new { ui_mode = "developer", @@ -258,7 +283,9 @@ private static IResult GetMeta() runtime = "dotnet", capabilities = new Dictionary { - ["tracing"] = false, + ["instrumentation"] = tracingAvailable, + ["tracing"] = tracingAvailable, + ["trace_retrieval"] = tracingAvailable, ["openai_proxy"] = false, ["deployment"] = false }, @@ -266,6 +293,86 @@ private static IResult GetMeta() }); } + private async Task IsTracingAvailableAsync(HttpContext context) + { + if (this._tracingAvailable) + { + return true; + } + + await this._tracingProbeLock.WaitAsync(context.RequestAborted).ConfigureAwait(false); + try + { + // Once the dashboard API has responded successfully, keep tracing armed. A later + // browser cancellation or transient dashboard failure must not disable other clients. + if (this._tracingAvailable) + { + return true; + } + + if (!this.TryResolveDashboardConnection(out var dashboardBaseUri, out var dashboardApiKey)) + { + this.LogTracingUnavailable("no loopback Aspire Dashboard endpoint was found in ASPNETCORE_URLS"); + return false; + } + + var httpClientFactory = context.RequestServices.GetRequiredService(); + using var client = httpClientFactory.CreateClient("devui-proxy"); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + + try + { + this._tracingAvailable = await AspireDashboardTraceClient.IsAvailableAsync( + client, + dashboardBaseUri, + dashboardApiKey, + timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException ex) + { + this.LogTracingUnavailable("the Aspire Dashboard telemetry API probe timed out", ex); + return false; + } + catch (HttpRequestException ex) + { + this.LogTracingUnavailable("the Aspire Dashboard telemetry API could not be reached", ex); + return false; + } + + if (!this._tracingAvailable) + { + this.LogTracingUnavailable("the Aspire Dashboard telemetry API rejected the availability probe"); + return false; + } + + if (Interlocked.Exchange(ref this._tracingEnabledLogged, 1) == 0) + { + this._logger.LogInformation( + "Aspire Dashboard trace retrieval is enabled at {DashboardBaseUri}", + dashboardBaseUri); + } + + return true; + } + finally + { + this._tracingProbeLock.Release(); + } + } + + private void LogTracingUnavailable(string reason, Exception? exception = null) + { + if (Interlocked.Exchange(ref this._tracingUnavailableLogged, 1) == 0) + { + this._logger.LogInformation(exception, "Aspire Dashboard trace retrieval is disabled because {Reason}.", reason); + } + } + private void MapRoutes(WebApplication app) { app.MapGet("/health", () => Results.Ok(new { status = "healthy" })); @@ -274,8 +381,9 @@ private void MapRoutes(WebApplication app) app.MapGet("/v1/entities", (Delegate)this.AggregateEntitiesAsync); app.MapGet("/v1/entities/{**entityPath}", this.RouteEntityInfoAsync); app.MapPost("/v1/responses", this.RouteResponsesAsync); + app.MapGet("/v1/responses/{responseId}/traces", this.GetResponseTracesAsync); app.Map("/v1/conversations/{**path}", this.ProxyConversationsAsync); - app.MapGet("/meta", GetMeta); + app.MapGet("/meta", (Delegate)this.GetMetaAsync); // Serve the DevUI frontend from embedded assembly resources app.Map("/devui/{**path}", this.ServeDevUIFrontendAsync); @@ -474,7 +582,76 @@ await context.Response.WriteAsJsonAsync( json!["metadata"]!["entity_id"] = actualEntityId; var rewrittenBody = JsonSerializer.SerializeToUtf8Bytes(json); - await ProxyRequestAsync(context, backendUrl, "/v1/responses", rewrittenBody, streaming: true).ConfigureAwait(false); + var resourceName = this.ResolveResourceName(entityId); + var traceId = resourceName is not null && this.TryResolveDashboardConnection(out _, out _) + ? ActivityTraceId.CreateRandom().ToHexString() + : null; + var traceParent = traceId is not null + ? $"00-{traceId}-{ActivitySpanId.CreateRandom().ToHexString()}-01" + : null; + + var responseId = await ProxyRequestAsync( + context, + backendUrl, + "/v1/responses", + rewrittenBody, + streaming: true, + traceParent, + captureResponseId: traceId is not null).ConfigureAwait(false); + + if (traceId is not null && responseId is not null && resourceName is not null) + { + this._responseTraceMap[responseId] = new TraceRequestInfo(traceId, entityId, DateTimeOffset.UtcNow); + this.RemoveExpiredTraceMappings(); + } + } + + private async Task GetResponseTracesAsync(string responseId, HttpContext context) + { + if (!this._responseTraceMap.TryGetValue(responseId, out var traceRequest)) + { + return Results.NotFound(); + } + + if (!this.TryResolveDashboardConnection(out var dashboardBaseUri, out var dashboardApiKey)) + { + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + try + { + var httpClientFactory = context.RequestServices.GetRequiredService(); + using var client = httpClientFactory.CreateClient("devui-proxy"); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + var events = await AspireDashboardTraceClient.GetTraceEventsAsync( + client, + dashboardBaseUri, + dashboardApiKey, + traceRequest.TraceId, + responseId, + traceRequest.EntityId, + timeout.Token).ConfigureAwait(false); + + if (events is null) + { + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + return events.Count == 0 + ? Results.Accepted(value: new { data = events }) + : Results.Ok(new { data = events }); + } + catch (OperationCanceledException ex) when (!context.RequestAborted.IsCancellationRequested) + { + this._logger.LogDebug(ex, "Timed out reading trace {TraceId} from the Aspire Dashboard", traceRequest.TraceId); + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + catch (Exception ex) when (ex is HttpRequestException or JsonException) + { + this._logger.LogDebug(ex, "Unable to read trace {TraceId} from the Aspire Dashboard", traceRequest.TraceId); + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } } private async Task ProxyConversationsAsync(HttpContext context, string? path) @@ -682,12 +859,14 @@ private async Task ProxyAndRecordConversationAsync( } } - private static async Task ProxyRequestAsync( + private static async Task ProxyRequestAsync( HttpContext context, string backendUrl, string path, byte[]? bodyBytes, - bool streaming = false) + bool streaming = false, + string? traceParent = null, + bool captureResponseId = false) { var httpClientFactory = context.RequestServices.GetRequiredService(); using var client = httpClientFactory.CreateClient("devui-proxy"); @@ -697,7 +876,7 @@ private static async Task ProxyRequestAsync( { context.Response.StatusCode = StatusCodes.Status400BadRequest; await context.Response.WriteAsync("Invalid proxy target.", context.RequestAborted).ConfigureAwait(false); - return; + return null; } using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUri); @@ -712,6 +891,20 @@ private static async Task ProxyRequestAsync( request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); } + if (captureResponseId) + { + // Response ID capture parses SSE or JSON, so keep the upstream body uncompressed. + request.Headers.Remove("Accept-Encoding"); + request.Headers.TryAddWithoutValidation("Accept-Encoding", "identity"); + } + + if (traceParent is not null) + { + request.Headers.Remove("traceparent"); + request.Headers.Remove("tracestate"); + request.Headers.TryAddWithoutValidation("traceparent", traceParent); + } + if (bodyBytes is not null) { request.Content = new ByteArrayContent(bodyBytes); @@ -722,7 +915,7 @@ private static async Task ProxyRequestAsync( } } - var completionOption = streaming + var completionOption = streaming || captureResponseId ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead; @@ -736,12 +929,25 @@ private static async Task ProxyRequestAsync( context.Response.Headers.CacheControl = "no-cache"; using var stream = await response.Content.ReadAsStreamAsync(context.RequestAborted).ConfigureAwait(false); - await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); - } - else - { - await CopyResponseAsync(response, context).ConfigureAwait(false); + var responseIdCapture = captureResponseId && response.IsSuccessStatusCode ? new SseResponseIdCapture() : null; + var buffer = new byte[16 * 1024]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, context.RequestAborted).ConfigureAwait(false)) > 0) + { + var bytes = buffer.AsMemory(0, bytesRead); + responseIdCapture?.Append(bytes.Span); + await context.Response.Body.WriteAsync(bytes, context.RequestAborted).ConfigureAwait(false); + await context.Response.Body.FlushAsync(context.RequestAborted).ConfigureAwait(false); + } + + context.RequestAborted.ThrowIfCancellationRequested(); + return responseIdCapture?.ResponseId; } + + return await CopyResponseAsync( + response, + context, + captureResponseId && response.IsSuccessStatusCode).ConfigureAwait(false); } private (string? BackendUrl, string ActualPath) ResolveBackend(string prefixedId) @@ -772,6 +978,87 @@ private static async Task ProxyRequestAsync( return (null, prefixedId); } + private string? ResolveResourceName(string prefixedId) + { + var slashIndex = prefixedId.IndexOf('/'); + var prefix = slashIndex >= 0 ? prefixedId[..slashIndex] : prefixedId; + + return this._resource.Annotations + .OfType() + .FirstOrDefault(annotation => string.Equals( + annotation.EntityIdPrefix ?? annotation.AgentService.Name, + prefix, + StringComparison.Ordinal))? + .AgentService.Name; + } + + internal bool TryResolveDashboardConnection(out Uri dashboardBaseUri, out string? dashboardApiKey) + { + dashboardBaseUri = null!; + dashboardApiKey = null; + + if (this._dashboardConnectionOverride is not null) + { + dashboardBaseUri = this._dashboardConnectionOverride.BaseUri; + dashboardApiKey = this._dashboardConnectionOverride.ApiKey; + return true; + } + + var dashboardUrls = this._configuration?["ASPNETCORE_URLS"]; + if (string.IsNullOrWhiteSpace(dashboardUrls)) + { + return false; + } + + // In an AppHost, ASPNETCORE_URLS contains the built-in Dashboard frontend endpoints. + // Only loopback endpoints are accepted so the Dashboard API key can never be sent to + // an unrelated host through configuration. + foreach (var value in dashboardUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (Uri.TryCreate(value, UriKind.Absolute, out var candidate) && candidate.IsLoopback) + { + dashboardBaseUri = candidate; + break; + } + } + + if (dashboardBaseUri is null) + { + return false; + } + + dashboardApiKey = this._configuration?["AppHost:DashboardApiKey"]; + return true; + } + + private void RemoveExpiredTraceMappings() + { + if (this._responseTraceMap.Count <= 512) + { + return; + } + + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-30); + foreach (var item in this._responseTraceMap) + { + if (item.Value.CreatedAt < cutoff) + { + this._responseTraceMap.TryRemove(item.Key, out _); + } + } + + var overflow = this._responseTraceMap.Count - 512; + if (overflow > 0) + { + foreach (var item in this._responseTraceMap.OrderBy(item => item.Value.CreatedAt).Take(overflow)) + { + this._responseTraceMap.TryRemove(item.Key, out _); + } + } + } + + private sealed record TraceRequestInfo(string TraceId, string EntityId, DateTimeOffset CreatedAt); + private static async Task ReadRequestBodyAsync(HttpRequest request) { using var ms = new MemoryStream(); @@ -779,7 +1066,10 @@ private static async Task ReadRequestBodyAsync(HttpRequest request) return ms.ToArray(); } - private static async Task CopyResponseAsync(HttpResponseMessage response, HttpContext context) + internal static async Task CopyResponseAsync( + HttpResponseMessage response, + HttpContext context, + bool captureResponseId = false) { context.Response.StatusCode = (int)response.StatusCode; @@ -793,7 +1083,27 @@ private static async Task CopyResponseAsync(HttpResponseMessage response, HttpCo context.Response.Headers[header.Key] = header.Value.ToArray(); } - await response.Content.CopyToAsync(context.Response.Body).ConfigureAwait(false); + if (!captureResponseId) + { + await response.Content.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + return null; + } + + using var stream = await response.Content.ReadAsStreamAsync(context.RequestAborted).ConfigureAwait(false); + var responseIdCapture = new JsonResponseIdCapture(); + var buffer = new byte[16 * 1024]; + int bytesRead; + while ((bytesRead = await stream.ReadAsync(buffer, context.RequestAborted).ConfigureAwait(false)) > 0) + { + var bytes = buffer.AsMemory(0, bytesRead); + responseIdCapture.Append(bytes.Span); + await context.Response.Body.WriteAsync(bytes, context.RequestAborted).ConfigureAwait(false); + await context.Response.Body.FlushAsync(context.RequestAborted).ConfigureAwait(false); + } + + // Only expose the captured ID after the entire response has been forwarded successfully. + context.RequestAborted.ThrowIfCancellationRequested(); + return responseIdCapture.Complete(); } private static bool IsHopByHopHeader(string headerName) diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/JsonResponseIdCapture.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/JsonResponseIdCapture.cs new file mode 100644 index 00000000000..341b80887d2 --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/JsonResponseIdCapture.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Buffers; +using System.Text.Json; + +namespace Aspire.Hosting.AgentFramework; + +/// +/// Captures a top-level response ID from a bounded JSON prefix without buffering the response body. +/// +internal sealed class JsonResponseIdCapture +{ + private const int MaxPrefixLength = 64 * 1024; + private readonly ArrayBufferWriter _prefix = new(MaxPrefixLength); + private bool _truncated; + + /// + /// Retains up to 64 KiB of the response, ignoring subsequent bytes. + /// + /// The next fragment read from the proxied response. + internal void Append(ReadOnlySpan bytes) + { + var length = Math.Min(bytes.Length, MaxPrefixLength - this._prefix.WrittenCount); + if (length > 0) + { + this._prefix.Write(bytes[..length]); + } + + this._truncated |= length < bytes.Length; + } + + /// + /// Gets the captured ID after the caller has finished forwarding the response. + /// + /// The top-level ID, or if it is missing from the prefix or the prefix is malformed. + internal string? Complete() + { + try + { + var reader = new Utf8JsonReader(this._prefix.WrittenSpan, isFinalBlock: !this._truncated, state: default); + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + return null; + } + + string? responseId = null; + var pendingId = false; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + pendingId = reader.CurrentDepth == 1 && reader.ValueTextEquals("id"u8); + continue; + } + + if (pendingId) + { + responseId = reader.TokenType == JsonTokenType.String ? reader.GetString() : null; + pendingId = false; + } + } + + return responseId is { Length: > 0 } ? responseId : null; + } + catch (JsonException) + { + // Capture is best effort; malformed JSON must not affect the proxied response. + return null; + } + } +} diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md index 8dbace2514e..b49d1853d56 100644 --- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md @@ -56,9 +56,22 @@ app.MapOpenAIConversations(); 2. **Aggregates entities** from all configured agent service backends into a single `/v1/entities` listing. Each entity ID is prefixed with the backend name to ensure uniqueness across services (e.g., `writer-agent/writer`, `editor-agent/editor`). 3. **Routes requests** to the correct backend based on the entity ID prefix. When DevUI sends a `POST /v1/responses` or `/v1/conversations` request, the aggregator strips the prefix and forwards it to the appropriate service. 4. **Streams SSE responses** for the `/v1/responses` endpoint, so agent responses stream back to the DevUI frontend in real time. +5. **Correlates OpenTelemetry traces** with each response when the Aspire Dashboard telemetry API is available. The aggregator propagates a unique W3C trace context to the selected agent service, retrieves matching spans from the dashboard after the response completes, and displays them in DevUI's Traces tab. The aggregator publishes its URL to the Aspire dashboard, where it appears as a clickable link. +## Tracing + +Tracing is enabled automatically when the running Aspire Dashboard exposes its telemetry API, available in Aspire 13.2 and later. DevUI checks this capability at startup, so older or unavailable dashboards do not delay agent responses or produce trace errors in the UI. + +Agent services must still be configured to emit and export OpenTelemetry spans to Aspire. This normally means using Aspire service defaults and registering the Agent Framework activity sources used by the service. The aggregator keeps the dashboard API key server-side; it is never returned to the browser. + +Aspire commonly exports spans in batches, so trace events may appear shortly after an answer completes. Trace retrieval works for streaming and non-streaming responses, happens in the background, and does not keep the chat response in a streaming state. + +DevUI merges trace snapshots throughout a bounded polling window of about six seconds. Spans exported after that window may be missing. For non-streaming responses, the aggregator forwards the body as it arrives and captures the top-level response ID from only the first 64 KiB. An ID outside that prefix leaves the response unchanged but cannot be correlated with traces. + +OpenTelemetry attributes can contain sensitive prompts, responses, tool arguments, or results when sensitive-data capture is enabled. Only enable sensitive telemetry in an appropriately secured development environment. + ## Agent discovery By default, `WithAgentService` declares a single agent named after the Aspire resource. You can provide explicit agent metadata when the agent name differs from the resource name, or when a service hosts multiple agents: diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/SseResponseIdCapture.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/SseResponseIdCapture.cs new file mode 100644 index 00000000000..c33daf20fdc --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/SseResponseIdCapture.cs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Buffers; +using System.Text.Json; + +namespace Aspire.Hosting.AgentFramework; + +/// +/// Incrementally extracts an OpenAI response ID from an SSE response without delaying proxy writes. +/// +internal sealed class SseResponseIdCapture +{ + private const int MaxLineLength = 64 * 1024; + private readonly ArrayBufferWriter _lineBuffer = new(); + private bool _discardingOversizedLine; + + /// + /// Gets the first response ID observed in the stream. + /// + internal string? ResponseId { get; private set; } + + /// + /// Adds another raw SSE response fragment to the parser. + /// + /// The next bytes read from the proxied response. + internal void Append(ReadOnlySpan bytes) + { + if (this.ResponseId is not null) + { + return; + } + + foreach (var value in bytes) + { + if (value == (byte)'\n') + { + if (!this._discardingOversizedLine) + { + this.ProcessLine(this._lineBuffer.WrittenSpan); + } + + this._lineBuffer.Clear(); + this._discardingOversizedLine = false; + + if (this.ResponseId is not null) + { + return; + } + + continue; + } + + if (this._discardingOversizedLine) + { + continue; + } + + if (this._lineBuffer.WrittenCount >= MaxLineLength) + { + this.ProcessLine(this._lineBuffer.WrittenSpan, isFinalBlock: false); + if (this.ResponseId is not null) + { + return; + } + + this._lineBuffer.Clear(); + this._discardingOversizedLine = true; + continue; + } + + this._lineBuffer.GetSpan(1)[0] = value; + this._lineBuffer.Advance(1); + } + } + + private void ProcessLine(ReadOnlySpan line, bool isFinalBlock = true) + { + if (!line.IsEmpty && line[^1] == (byte)'\r') + { + line = line[..^1]; + } + + ReadOnlySpan dataPrefix = "data:"u8; + if (!line.StartsWith(dataPrefix)) + { + return; + } + + line = line[dataPrefix.Length..].TrimStart((byte)' '); + if (line.SequenceEqual("[DONE]"u8)) + { + return; + } + + try + { + var reader = new Utf8JsonReader(line, isFinalBlock, state: default); + var responseObjectDepth = -1; + var pendingResponseObject = false; + var pendingResponseId = false; + var pendingDirectId = false; + string? nestedResponseId = null; + string? directResponseId = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + pendingResponseObject = reader.CurrentDepth == 1 && reader.ValueTextEquals("response"u8); + pendingResponseId = reader.CurrentDepth == responseObjectDepth && reader.ValueTextEquals("id"u8); + pendingDirectId = reader.CurrentDepth == 1 && reader.ValueTextEquals("id"u8); + continue; + } + + if (reader.TokenType == JsonTokenType.StartObject && pendingResponseObject) + { + responseObjectDepth = reader.CurrentDepth + 1; + } + else if (reader.TokenType == JsonTokenType.EndObject && + responseObjectDepth == reader.CurrentDepth + 1) + { + responseObjectDepth = -1; + } + else if (reader.TokenType == JsonTokenType.String && (pendingResponseId || pendingDirectId)) + { + var responseId = reader.GetString(); + if (pendingResponseId) + { + nestedResponseId = responseId; + } + else if (responseId?.StartsWith("resp_", StringComparison.Ordinal) == true) + { + directResponseId = responseId; + } + } + + pendingResponseObject = false; + pendingResponseId = false; + pendingDirectId = false; + } + + this.ResponseId = nestedResponseId ?? directResponseId; + } + catch (JsonException) + { + // A malformed SSE event must not prevent a later valid response event from being captured. + } + } +} diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AspireDashboardTracingTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AspireDashboardTracingTests.cs new file mode 100644 index 00000000000..6227b2a974b --- /dev/null +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AspireDashboardTracingTests.cs @@ -0,0 +1,1089 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.IO.Pipelines; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Aspire.Hosting.ApplicationModel; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests; + +/// +/// Regression tests for forwarding Aspire Dashboard traces to DevUI. +/// +public class AspireDashboardTracingTests +{ + [Theory] + [InlineData("{\"id\":\"resp_valid\",\"output\":[]}", "resp_valid")] + [InlineData("{\"output\":[{\"id\":\"nested\"}],\"id\":\"resp_outer\"}", "resp_outer")] + [InlineData("{\"output\":[{\"id\":\"nested\"}]}", null)] + [InlineData("{\"id\":\"resp_invalid\",not-json}", null)] + [InlineData("{\"id\":\"resp_incomplete\"", null)] + [InlineData("[{\"id\":\"resp_array\"}]", null)] + [InlineData("{\"id\":42}", null)] + [InlineData("{\"id\":\"\"}", null)] + public void JsonResponseIdCapture_FragmentedBody_OnlyCapturesValidTopLevelId(string json, string? expectedId) + { + // Arrange + var capture = new JsonResponseIdCapture(); + + // Act + foreach (var value in Encoding.UTF8.GetBytes(json)) + { + capture.Append([value]); + } + + // Assert + Assert.Equal(expectedId, capture.Complete()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void JsonResponseIdCapture_LargeBody_OnlyCapturesIdWithinBoundedPrefix(bool idBeforeOutput) + { + // Arrange + var capture = new JsonResponseIdCapture(); + var output = new string('x', 2 * 1024 * 1024); + var json = idBeforeOutput + ? $"{{\"id\":\"resp_large\",\"output\":\"{output}\"}}" + : $"{{\"output\":\"{output}\",\"id\":\"resp_large\"}}"; + var bytes = Encoding.UTF8.GetBytes(json); + + // Act + for (var offset = 0; offset < bytes.Length; offset += 1024) + { + capture.Append(bytes.AsSpan(offset, Math.Min(1024, bytes.Length - offset))); + } + + // Assert + Assert.Equal(idBeforeOutput ? "resp_large" : null, capture.Complete()); + } + + [Fact] + public async Task CopyResponseAsync_LargeBody_ForwardsPrefixBeforeReturningIdAsync() + { + // Arrange + var pipe = new Pipe(); + using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(pipe.Reader.AsStream()) }; + using var output = new MemoryStream(); + var context = new DefaultHttpContext(); + context.Response.Body = output; + var prefix = "{\"id\":\"resp_copy\",\"output\":\""u8.ToArray(); + var suffix = Encoding.UTF8.GetBytes(new string('x', 2 * 1024 * 1024) + "\"}"); + await pipe.Writer.WriteAsync(prefix); + + // Act +#pragma warning disable CA2025 // The copy intentionally overlaps producer writes and is awaited in finally before disposal. + var copy = DevUIAggregatorHostedService.CopyResponseAsync(response, context, captureResponseId: true); +#pragma warning restore CA2025 + string? responseId; + try + { + // The backend is still open: forwarding must already have started, but no ID can be returned yet. + Assert.False(copy.IsCompleted); + Assert.Equal(prefix, output.ToArray()); + await pipe.Writer.WriteAsync(suffix); + } + finally + { + await pipe.Writer.CompleteAsync(); + responseId = await copy; + } + + // Assert + Assert.Equal("resp_copy", responseId); + Assert.Equal(prefix.Concat(suffix), output.ToArray()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CopyResponseAsync_InterruptedBody_DoesNotReturnCapturedIdAsync(bool cancelRequest) + { + // Arrange + var pipe = new Pipe(); + using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StreamContent(pipe.Reader.AsStream()) }; + using var output = new MemoryStream(); + using var cancellation = new CancellationTokenSource(); + var context = new DefaultHttpContext { RequestAborted = cancellation.Token }; + context.Response.Body = output; + var prefix = "{\"id\":\"resp_interrupted\","u8.ToArray(); + await pipe.Writer.WriteAsync(prefix); +#pragma warning disable CA2025 // The interrupted copy is awaited in finally before disposing the response. + var copy = DevUIAggregatorHostedService.CopyResponseAsync(response, context, captureResponseId: true); +#pragma warning restore CA2025 + + // Act / Assert + try + { + Assert.False(copy.IsCompleted); + Assert.Equal(prefix, output.ToArray()); + if (cancelRequest) + { + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => copy); + } + else + { + await pipe.Writer.CompleteAsync(new IOException("The backend disconnected.")); + await Assert.ThrowsAsync(() => copy); + } + } + finally + { + cancellation.Cancel(); + await pipe.Writer.CompleteAsync(); + await Record.ExceptionAsync(async () => await copy); + } + } + + [Fact] + public void SseResponseIdCapture_FragmentedResponseCreatedEvent_CapturesResponseId() + { + // Arrange + var capture = new SseResponseIdCapture(); + + // Act + capture.Append(Encoding.UTF8.GetBytes("data: {\"type\":\"response.cre")); + capture.Append(Encoding.UTF8.GetBytes("ated\",\"response\":{\"id\":\"resp_123\"}}\r")); + capture.Append(Encoding.UTF8.GetBytes("\n\r\ndata: [DONE]\n\n")); + + // Assert + Assert.Equal("resp_123", capture.ResponseId); + } + + [Fact] + public void SseResponseIdCapture_MalformedEvent_DoesNotDiscardLaterValidEvent() + { + // Arrange + var capture = new SseResponseIdCapture(); + + // Act + capture.Append(Encoding.UTF8.GetBytes("data: {not-json}\n\n")); + capture.Append(Encoding.UTF8.GetBytes("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_valid\"}}\n\n")); + + // Assert + Assert.Equal("resp_valid", capture.ResponseId); + } + + [Fact] + public void SseResponseIdCapture_MalformedEventAfterResponseId_DoesNotCaptureInvalidId() + { + // Arrange + var capture = new SseResponseIdCapture(); + + // Act + capture.Append(Encoding.UTF8.GetBytes("data: {\"response\":{\"id\":\"resp_invalid\"},not-json}\n\n")); + capture.Append(Encoding.UTF8.GetBytes("data: {\"response\":{\"id\":\"resp_valid\"}}\n\n")); + + // Assert + Assert.Equal("resp_valid", capture.ResponseId); + } + + [Fact] + public void SseResponseIdCapture_NestedResponseIdTakesPriorityOverDirectFallback() + { + // Arrange + var capture = new SseResponseIdCapture(); + + // Act + capture.Append(Encoding.UTF8.GetBytes( + "data: {\"response\":{\"id\":\"resp_nested\"},\"id\":\"resp_direct\"}\n\n")); + + // Assert + Assert.Equal("resp_nested", capture.ResponseId); + } + + [Fact] + public void SseResponseIdCapture_OversizedFragmentedResponseCreatedEvent_CapturesResponseId() + { + // Arrange + var capture = new SseResponseIdCapture(); + var oversizedEvent = Encoding.UTF8.GetBytes( + $"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"resp_oversized\",\"instructions\":\"{new string('x', 80 * 1024)}\"}}}}\n\n"); + + // Act + for (var offset = 0; offset < oversizedEvent.Length; offset += 1024) + { + capture.Append(oversizedEvent.AsSpan(offset, Math.Min(1024, oversizedEvent.Length - offset))); + } + + // Assert + Assert.Equal("resp_oversized", capture.ResponseId); + } + + [Fact] + public void ConvertToTraceEvents_OtlpSpan_PreservesHierarchyTimingAttributesAndError() + { + // Arrange + using var document = JsonDocument.Parse(""" + { + "resourceSpans": [{ + "scopeSpans": [{ + "spans": [{ + "traceId": "00112233445566778899aabbccddeeff", + "spanId": "0011223344556677", + "parentSpanId": "8899aabbccddeeff", + "name": "invoke_agent", + "startTimeUnixNano": "1000000000", + "endTimeUnixNano": "2500000000", + "attributes": [ + {"key": "gen_ai.usage.input_tokens", "value": {"intValue": "12"}}, + {"key": "agent.name", "value": {"stringValue": "writer"}}, + {"key": "cached", "value": {"boolValue": true}} + ], + "status": {"code": 2, "message": "model failed"}, + "events": [{ + "name": "exception", + "timeUnixNano": "2000000000", + "attributes": [{"key": "exception.type", "value": {"stringValue": "System.Exception"}}] + }] + }] + }] + }] + } + """); + + // Act + var events = AspireDashboardTraceClient.ConvertToTraceEvents( + document.RootElement, + responseId: "resp_123", + entityId: "writer-service/writer"); + + // Assert + var traceEvent = Assert.Single(events); + Assert.Equal("response.trace.completed", traceEvent["type"]?.GetValue()); + + var data = traceEvent["data"]!.AsObject(); + Assert.Equal("trace_span", data["type"]?.GetValue()); + Assert.Equal("00112233445566778899aabbccddeeff", data["trace_id"]?.GetValue()); + Assert.Equal("0011223344556677", data["span_id"]?.GetValue()); + Assert.Equal("8899aabbccddeeff", data["parent_span_id"]?.GetValue()); + Assert.Equal("invoke_agent", data["operation_name"]?.GetValue()); + Assert.Equal(1.0, data["start_time"]?.GetValue()); + Assert.Equal(2.5, data["end_time"]?.GetValue()); + Assert.Equal(1500.0, data["duration_ms"]?.GetValue()); + Assert.Equal("ERROR", data["status"]?.GetValue()); + Assert.Equal("model failed", data["error"]?.GetValue()); + Assert.Equal("resp_123", data["response_id"]?.GetValue()); + Assert.Equal("writer-service/writer", data["entity_id"]?.GetValue()); + + var attributes = data["attributes"]!.AsObject(); + Assert.Equal(12, attributes["gen_ai.usage.input_tokens"]?.GetValue()); + Assert.Equal("writer", attributes["agent.name"]?.GetValue()); + Assert.True(attributes["cached"]?.GetValue()); + + var spanEvent = Assert.Single(data["events"]!.AsArray())!.AsObject(); + Assert.Equal("exception", spanEvent["name"]?.GetValue()); + Assert.Equal(2.0, spanEvent["timestamp"]?.GetValue()); + Assert.Equal( + "System.Exception", + spanEvent["attributes"]?["exception.type"]?.GetValue()); + } + + [Fact] + public void ConvertToTraceEvents_UnsetStatuses_UseFrontendContractValue() + { + // Arrange + using var document = JsonDocument.Parse(""" + { + "resourceSpans": [{ + "scopeSpans": [{ + "spans": [{ + "traceId": "00112233445566778899aabbccddeeff", + "spanId": "0011223344556677", + "name": "invoke_agent" + }, { + "traceId": "00112233445566778899aabbccddeeff", + "spanId": "8899aabbccddeeff", + "name": "invoke_tool", + "status": {"code": 0} + }] + }] + }] + } + """); + + // Act + var events = AspireDashboardTraceClient.ConvertToTraceEvents( + document.RootElement, + responseId: "resp_123", + entityId: "writer-service/writer"); + + // Assert + Assert.Equal(2, events.Count); + Assert.All(events, traceEvent => + Assert.Equal("StatusCode.UNSET", traceEvent["data"]?["status"]?.GetValue())); + } + + [Fact] + public async Task GetTraceEventsAsync_UsesTraceByIdEndpointAndKeepsDashboardKeyServerSideAsync() + { + // Arrange + HttpRequestMessage? observedRequest = null; + using var client = new HttpClient(new StubHttpMessageHandler(request => + { + observedRequest = request; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "data": { + "resourceSpans": [{ + "scopeSpans": [{ + "spans": [{ + "traceId": "00112233445566778899aabbccddeeff", + "spanId": "0011223344556677", + "name": "invoke_agent" + }] + }] + }] + }, + "totalCount": 1, + "returnedCount": 1 + } + """, Encoding.UTF8, "application/json") + }; + })); + + // Act + var events = await AspireDashboardTraceClient.GetTraceEventsAsync( + client, + new Uri("https://localhost:18888"), + "dashboard-secret", + "00112233445566778899aabbccddeeff", + "resp_123", + "writer-service/writer", + CancellationToken.None); + + // Assert + Assert.NotNull(events); + Assert.Single(events); + Assert.NotNull(observedRequest); + Assert.Equal( + "/api/telemetry/traces/00112233445566778899aabbccddeeff", + observedRequest.RequestUri?.PathAndQuery); + Assert.Equal("dashboard-secret", Assert.Single(observedRequest.Headers.GetValues("x-api-key"))); + Assert.DoesNotContain("dashboard-secret", observedRequest.RequestUri?.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task GetTraceEventsAsync_TruncatedTraceResponse_ReturnsUnavailableAsync() + { + // Arrange + using var client = new HttpClient(new StubHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "data": {"resourceSpans": []}, + "totalCount": 2, + "returnedCount": 1 + } + """, Encoding.UTF8, "application/json") + })); + + // Act + var events = await AspireDashboardTraceClient.GetTraceEventsAsync( + client, + new Uri("https://localhost:18888"), + "dashboard-secret", + "00112233445566778899aabbccddeeff", + "resp_123", + "writer-service/writer", + CancellationToken.None); + + // Assert + Assert.Null(events); + } + + [Fact] + public async Task GetTraceEventsAsync_DashboardFailure_ReturnsUnavailableWithoutThrowingAsync() + { + // Arrange + using var client = new HttpClient(new StubHttpMessageHandler(_ => + new HttpResponseMessage(HttpStatusCode.ServiceUnavailable))); + + // Act + var events = await AspireDashboardTraceClient.GetTraceEventsAsync( + client, + new Uri("https://localhost:18888"), + "dashboard-secret", + "00112233445566778899aabbccddeeff", + "resp_123", + "writer-service/writer", + CancellationToken.None); + + // Assert + Assert.Null(events); + } + + [Fact] + public void SseResponseIdCapture_ConcurrentStreams_RemainIsolated() + { + // Arrange + var capturedIds = new ConcurrentBag(); + + // Act + Parallel.For(0, 100, index => + { + var capture = new SseResponseIdCapture(); + capture.Append(Encoding.UTF8.GetBytes($"data: {{\"response\":{{\"id\":\"resp_{index}\"}}}}\n\n")); + capturedIds.Add(capture.ResponseId!); + }); + + // Assert + Assert.Equal(100, capturedIds.Distinct().Count()); + } + + [Fact] + public void TryResolveDashboardConnection_AppHostUrls_UsesLoopbackEndpointAndApiKey() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "https://localhost:16500;http://localhost:16501", + ["AppHost:DashboardApiKey"] = "dashboard-secret" + }) + .Build(); + var aggregator = new DevUIAggregatorHostedService( + new DevUIResource("test-devui"), + NullLogger.Instance, + configuration); + + // Act + var resolved = aggregator.TryResolveDashboardConnection(out var dashboardBaseUri, out var dashboardApiKey); + + // Assert + Assert.True(resolved); + Assert.Equal(new Uri("https://localhost:16500"), dashboardBaseUri); + Assert.Equal("dashboard-secret", dashboardApiKey); + } + + [Fact] + public void TryResolveDashboardConnection_NonLoopbackUrl_DoesNotExposeApiKey() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ASPNETCORE_URLS"] = "https://example.com:16500", + ["AppHost:DashboardApiKey"] = "dashboard-secret" + }) + .Build(); + var aggregator = new DevUIAggregatorHostedService( + new DevUIResource("test-devui"), + NullLogger.Instance, + configuration); + + // Act + var resolved = aggregator.TryResolveDashboardConnection(out _, out var dashboardApiKey); + + // Assert + Assert.False(resolved); + Assert.Null(dashboardApiKey); + } + + [Fact] + public async Task Aggregator_ResponseTraceFlow_PropagatesTraceAndReturnsDashboardSpansAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(); + using var metaResponse = await context.Client.GetAsync(new Uri("/meta", UriKind.Relative)); + metaResponse.EnsureSuccessStatusCode(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/responses") + { + Content = new StringContent( + """{"metadata":{"entity_id":"writer-service/writer"},"input":"hello","stream":true}""", + Encoding.UTF8, + "application/json") + }; + + // Act + using var response = await context.Client.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + using var tracesResponse = await context.Client.GetAsync(new Uri("/v1/responses/resp_integration/traces", UriKind.Relative)); + using var tracesDocument = JsonDocument.Parse(await tracesResponse.Content.ReadAsStreamAsync()); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("resp_integration", responseBody, StringComparison.Ordinal); + Assert.NotNull(context.AgentTraceParent); + Assert.Matches("^00-[0-9a-f]{32}-[0-9a-f]{16}-01$", context.AgentTraceParent); + Assert.Equal("dashboard-secret", context.DashboardApiKey); + Assert.Equal(context.AgentTraceParent![3..35], context.DashboardTraceId); + Assert.Equal(HttpStatusCode.OK, tracesResponse.StatusCode); + Assert.Equal( + "response.trace.completed", + tracesDocument.RootElement.GetProperty("data")[0].GetProperty("type").GetString()); + } + + [Fact] + public async Task Aggregator_ResponseBeforeMeta_StillPropagatesTraceAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(); + using var request = CreateAgentRequest("resp_before_meta"); + + // Act + using var response = await context.Client.SendAsync(request); + await response.Content.LoadIntoBufferAsync(); + using var tracesResponse = await context.Client.GetAsync( + new Uri("/v1/responses/resp_before_meta/traces", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.OK, tracesResponse.StatusCode); + Assert.Equal(0, context.DashboardResourceProbeCount); + Assert.Equal(context.AgentTraceParents["resp_before_meta"][3..35], context.DashboardTraceId); + } + + [Fact] + public async Task Aggregator_MetaProbeSuccess_IsLatchedAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(); + + // Act + using var firstResponse = await context.Client.GetAsync(new Uri("/meta", UriKind.Relative)); + using var firstDocument = JsonDocument.Parse(await firstResponse.Content.ReadAsStreamAsync()); + context.FailDashboardResourceProbe(); + using var secondResponse = await context.Client.GetAsync(new Uri("/meta", UriKind.Relative)); + using var secondDocument = JsonDocument.Parse(await secondResponse.Content.ReadAsStreamAsync()); + + // Assert + Assert.True(firstDocument.RootElement.GetProperty("capabilities").GetProperty("trace_retrieval").GetBoolean()); + Assert.True(secondDocument.RootElement.GetProperty("capabilities").GetProperty("trace_retrieval").GetBoolean()); + Assert.Equal(1, context.DashboardResourceProbeCount); + } + + [Fact] + public async Task Aggregator_NonStreamingResponse_CapturesResponseIdAndReturnsTracesAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(); + using var metaResponse = await context.Client.GetAsync(new Uri("/meta", UriKind.Relative)); + metaResponse.EnsureSuccessStatusCode(); + using var request = CreateAgentRequest("resp_non_streaming", streaming: false); + + // Act + using var response = await context.Client.SendAsync(request); + using var responseDocument = JsonDocument.Parse(await response.Content.ReadAsStreamAsync()); + using var tracesResponse = await context.Client.GetAsync( + new Uri("/v1/responses/resp_non_streaming/traces", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("resp_non_streaming", responseDocument.RootElement.GetProperty("id").GetString()); + Assert.Equal(HttpStatusCode.OK, tracesResponse.StatusCode); + Assert.Equal(context.AgentTraceParents["resp_non_streaming"][3..35], context.DashboardTraceId); + } + + [Theory] + [InlineData(true, "gzip")] + [InlineData(true, "br")] + [InlineData(false, "gzip")] + [InlineData(false, "br")] + public async Task Aggregator_ResponseCapture_RequestsIdentityEncodingAsync( + bool streaming, + string acceptedEncoding) + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync( + responseCompressionEncoding: acceptedEncoding); + var responseId = $"resp_{(streaming ? "streaming" : "non_streaming")}_{acceptedEncoding}"; + using var request = CreateAgentRequest(responseId, streaming); + request.Headers.AcceptEncoding.ParseAdd(acceptedEncoding); + + // Act + using var response = await context.Client.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + using var tracesResponse = await context.Client.GetAsync( + new Uri($"/v1/responses/{responseId}/traces", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(responseId, responseBody, StringComparison.Ordinal); + Assert.Empty(response.Content.Headers.ContentEncoding); + Assert.Equal("identity", context.AgentAcceptEncoding); + Assert.Equal(HttpStatusCode.OK, tracesResponse.StatusCode); + } + + [Fact] + public async Task Aggregator_LargeNonStreamingResponse_ForwardsBeforeCompletionAndThenRegistersTraceAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(pauseNonStreamingResponseAfterId: true); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var request = CreateAgentRequest("resp_large", streaming: false); + + // Act: the backend waits for the test before sending the large output field. + using var response = await context.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation.Token); + using var responseStream = await response.Content.ReadAsStreamAsync(cancellation.Token); + using var reader = new StreamReader(responseStream); + var prefix = await reader.ReadLineAsync(cancellation.Token); + using var pendingTraces = await context.Client.GetAsync(new Uri("/v1/responses/resp_large/traces", UriKind.Relative)); + context.ReleaseNonStreamingResponse(); + var remainder = await reader.ReadToEndAsync(cancellation.Token); + using var completedTraces = await context.Client.GetAsync(new Uri("/v1/responses/resp_large/traces", UriKind.Relative)); + + // Assert + Assert.Equal("{\"id\":\"resp_large\",", prefix); + Assert.Equal($"\"output\":\"{new string('x', 2 * 1024 * 1024)}\"}}", remainder); + Assert.Equal(HttpStatusCode.NotFound, pendingTraces.StatusCode); + Assert.Equal(HttpStatusCode.OK, completedTraces.StatusCode); + Assert.Equal(context.AgentTraceParents["resp_large"][3..35], context.DashboardTraceId); + } + + [Fact] + public async Task Aggregator_CancelledNonStreamingResponse_DoesNotRegisterTraceMappingAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(pauseNonStreamingResponseAfterId: true); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var request = CreateAgentRequest("resp_cancelled_json", streaming: false); + using var response = await context.Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation.Token); + using var responseStream = await response.Content.ReadAsStreamAsync(cancellation.Token); + using var reader = new StreamReader(responseStream); + Assert.Contains("resp_cancelled_json", await reader.ReadLineAsync(cancellation.Token), StringComparison.Ordinal); + + // Act + cancellation.Cancel(); + response.Dispose(); + await context.WaitForNonStreamingRequestCancellationAsync(); + using var tracesResponse = await context.Client.GetAsync(new Uri("/v1/responses/resp_cancelled_json/traces", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, tracesResponse.StatusCode); + Assert.Null(context.DashboardTraceId); + } + + [Fact] + public async Task Aggregator_DashboardTraceTimeout_ReturnsServiceUnavailableAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(dashboardTraceTimeout: true); + using var metaResponse = await context.Client.GetAsync(new Uri("/meta", UriKind.Relative)); + metaResponse.EnsureSuccessStatusCode(); + using var request = CreateAgentRequest("resp_dashboard_timeout"); + using var response = await context.Client.SendAsync(request); + await response.Content.LoadIntoBufferAsync(); + + // Act + using var tracesResponse = await context.Client.GetAsync( + new Uri("/v1/responses/resp_dashboard_timeout/traces", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(HttpStatusCode.ServiceUnavailable, tracesResponse.StatusCode); + } + + [Fact] + public async Task Aggregator_DashboardUnavailable_DoesNotBreakAgentResponseAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(dashboardUnavailable: true); + using var metaResponse = await context.Client.GetAsync(new Uri("/meta", UriKind.Relative)); + metaResponse.EnsureSuccessStatusCode(); + using var request = CreateAgentRequest("resp_dashboard_down"); + + // Act + using var response = await context.Client.SendAsync(request); + var responseBody = await response.Content.ReadAsStringAsync(); + using var tracesResponse = await context.Client.GetAsync( + new Uri("/v1/responses/resp_dashboard_down/traces", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains("resp_dashboard_down", responseBody, StringComparison.Ordinal); + Assert.Equal(HttpStatusCode.ServiceUnavailable, tracesResponse.StatusCode); + } + + [Fact] + public async Task Aggregator_CancelledStreamingResponse_DoesNotRegisterTraceMappingAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(pauseStreamingResponseAfterCreated: true); + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + using var request = CreateAgentRequest("resp_cancelled"); + using var response = await context.Client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellation.Token); + using var responseStream = await response.Content.ReadAsStreamAsync(cancellation.Token); + using var reader = new StreamReader(responseStream); + var createdEvent = await reader.ReadLineAsync(cancellation.Token); + Assert.Contains("resp_cancelled", createdEvent, StringComparison.Ordinal); + + // Act + cancellation.Cancel(); + response.Dispose(); + await context.WaitForStreamingRequestCancellationAsync(); + using var tracesResponse = await context.Client.GetAsync( + new Uri("/v1/responses/resp_cancelled/traces", UriKind.Relative)); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, tracesResponse.StatusCode); + Assert.Null(context.DashboardTraceId); + } + + [Fact] + public async Task Aggregator_ConcurrentResponses_DoNotCrossWireTraceIdsAsync() + { + // Arrange + await using var context = await TracingProxyTestContext.StartAsync(); + using var metaResponse = await context.Client.GetAsync(new Uri("/meta", UriKind.Relative)); + metaResponse.EnsureSuccessStatusCode(); + var responseIds = Enumerable.Range(0, 12).Select(index => $"resp_concurrent_{index}").ToArray(); + + // Act + await Task.WhenAll(responseIds.Select(async responseId => + { + using var request = CreateAgentRequest(responseId); + using var response = await context.Client.SendAsync(request); + response.EnsureSuccessStatusCode(); + await response.Content.LoadIntoBufferAsync(); + })); + + var traceIds = new ConcurrentDictionary(); + await Task.WhenAll(responseIds.Select(async responseId => + { + using var response = await context.Client.GetAsync( + new Uri($"/v1/responses/{responseId}/traces", UriKind.Relative)); + response.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(await response.Content.ReadAsStreamAsync()); + var data = document.RootElement.GetProperty("data")[0].GetProperty("data"); + Assert.Equal(responseId, data.GetProperty("response_id").GetString()); + traceIds[responseId] = data.GetProperty("trace_id").GetString()!; + })); + + // Assert + Assert.Equal(responseIds.Length, traceIds.Values.Distinct().Count()); + foreach (var responseId in responseIds) + { + Assert.Equal(context.AgentTraceParents[responseId][3..35], traceIds[responseId]); + } + } + + private static HttpRequestMessage CreateAgentRequest(string responseId, bool streaming = true) + => new(HttpMethod.Post, "/v1/responses") + { + Content = new StringContent( + $$"""{"metadata":{"entity_id":"writer-service/writer","test_response_id":"{{responseId}}"},"input":"hello","stream":{{(streaming ? "true" : "false")}}}""", + Encoding.UTF8, + "application/json") + }; + + private sealed class StubHttpMessageHandler(Func handler) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + => Task.FromResult(handler(request)); + } + + private sealed class TracingProxyTestContext : IAsyncDisposable + { + private readonly WebApplication _agentBackend; + private readonly WebApplication _dashboard; + private readonly DevUIAggregatorHostedService _aggregator; + private readonly TaskCompletionSource _streamingRequestCancelled = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _nonStreamingResponseReleased = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _nonStreamingRequestCancelled = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _dashboardResourceProbeCount; + private volatile bool _failDashboardResourceProbe; + + private TracingProxyTestContext( + WebApplication agentBackend, + WebApplication dashboard, + DevUIAggregatorHostedService aggregator, + HttpClient client) + { + this._agentBackend = agentBackend; + this._dashboard = dashboard; + this._aggregator = aggregator; + this.Client = client; + } + + public HttpClient Client { get; } + + public string? AgentTraceParent { get; private set; } + + public string? AgentAcceptEncoding { get; private set; } + + public ConcurrentDictionary AgentTraceParents { get; } = new(StringComparer.Ordinal); + + public string? DashboardApiKey { get; private set; } + + public string? DashboardTraceId { get; private set; } + + public int DashboardResourceProbeCount => Volatile.Read(ref this._dashboardResourceProbeCount); + + public void FailDashboardResourceProbe() => this._failDashboardResourceProbe = true; + + public Task WaitForStreamingRequestCancellationAsync() + => this._streamingRequestCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + public void ReleaseNonStreamingResponse() => this._nonStreamingResponseReleased.TrySetResult(true); + + public Task WaitForNonStreamingRequestCancellationAsync() + => this._nonStreamingRequestCancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + public static async Task StartAsync( + bool dashboardUnavailable = false, + bool dashboardTraceTimeout = false, + bool pauseStreamingResponseAfterCreated = false, + string? responseCompressionEncoding = null, + bool pauseNonStreamingResponseAfterId = false) + { + var agentBackend = CreateWebApplication(); + var dashboard = CreateWebApplication(); + TracingProxyTestContext? testContext = null; + + agentBackend.MapPost("/v1/responses", async context => + { + using var requestDocument = await JsonDocument.ParseAsync(context.Request.Body, cancellationToken: context.RequestAborted); + var responseId = requestDocument.RootElement + .GetProperty("metadata") + .TryGetProperty("test_response_id", out var configuredResponseId) + ? configuredResponseId.GetString()! + : "resp_integration"; + var traceParent = context.Request.Headers.TraceParent.FirstOrDefault(); + testContext!.AgentTraceParent = traceParent; + testContext.AgentAcceptEncoding = context.Request.Headers.AcceptEncoding.ToString(); + testContext.AgentTraceParents[responseId] = traceParent!; + + if (requestDocument.RootElement.TryGetProperty("stream", out var stream) && !stream.GetBoolean()) + { + context.Response.ContentType = "application/json"; + if (pauseNonStreamingResponseAfterId) + { + await context.Response.WriteAsync($"{{\"id\":\"{responseId}\",\n", context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + try + { + await testContext._nonStreamingResponseReleased.Task.WaitAsync(context.RequestAborted); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + testContext._nonStreamingRequestCancelled.TrySetResult(true); + throw; + } + + await context.Response.WriteAsync($"\"output\":\"{new string('x', 2 * 1024 * 1024)}\"}}", context.RequestAborted); + return; + } + + await WriteAgentResponseAsync( + context, + JsonSerializer.SerializeToUtf8Bytes(new { id = responseId, output = Array.Empty() }), + responseCompressionEncoding); + return; + } + + context.Response.ContentType = "text/event-stream"; + var createdEvent = + $"data: {{\"type\":\"response.created\",\"response\":{{\"id\":\"{responseId}\"}}}}\n\n"; + + if (responseCompressionEncoding is not null && + context.Request.Headers.AcceptEncoding.ToString().Contains( + responseCompressionEncoding, + StringComparison.OrdinalIgnoreCase)) + { + await WriteAgentResponseAsync( + context, + Encoding.UTF8.GetBytes(createdEvent + "data: [DONE]\n\n"), + responseCompressionEncoding); + return; + } + + await context.Response.WriteAsync(createdEvent, context.RequestAborted); + + if (pauseStreamingResponseAfterCreated) + { + await context.Response.Body.FlushAsync(context.RequestAborted); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); + } + catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) + { + testContext._streamingRequestCancelled.TrySetResult(true); + throw; + } + } + + await context.Response.WriteAsync("data: [DONE]\n\n", context.RequestAborted); + }); + + dashboard.MapGet("/api/telemetry/traces/{traceId}", async Task (HttpContext context, string traceId) => + { + testContext!.DashboardApiKey = context.Request.Headers["x-api-key"].FirstOrDefault(); + testContext.DashboardTraceId = traceId; + + if (dashboardUnavailable) + { + return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + } + + if (dashboardTraceTimeout) + { + await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); + return Results.Empty; + } + + return Results.Json(new + { + data = new + { + resourceSpans = new[] + { + new + { + scopeSpans = new[] + { + new + { + spans = new[] + { + new + { + traceId, + spanId = "0011223344556677", + name = "invoke_agent" + } + } + } + } + } + } + }, + totalCount = 1, + returnedCount = 1 + }); + }); + dashboard.MapGet("/api/telemetry/resources", () => + { + Interlocked.Increment(ref testContext!._dashboardResourceProbeCount); + return testContext._failDashboardResourceProbe + ? Results.StatusCode(StatusCodes.Status503ServiceUnavailable) + : Results.Json(Array.Empty()); + }); + + await agentBackend.StartAsync(); + await dashboard.StartAsync(); + + var resource = new DevUIResource("test-devui"); + resource.Annotations.Add(new AgentServiceAnnotation(CreateBackendResource("writer-service", GetBaseAddress(agentBackend)))); + + var loggerFactory = LoggerFactory.Create(_ => { }); + var aggregator = new DevUIAggregatorHostedService( + resource, + loggerFactory.CreateLogger(), + dashboardConnectionOverride: new AspireDashboardConnection( + new Uri(GetBaseAddress(dashboard)), + "dashboard-secret")); + await aggregator.StartAsync(CancellationToken.None); + + testContext = new TracingProxyTestContext( + agentBackend, + dashboard, + aggregator, + new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{aggregator.AllocatedPort}") }); + return testContext; + } + + public async ValueTask DisposeAsync() + { + this.Client.Dispose(); + await this._aggregator.DisposeAsync(); + await this._agentBackend.StopAsync(); + await this._agentBackend.DisposeAsync(); + await this._dashboard.StopAsync(); + await this._dashboard.DisposeAsync(); + } + + private static WebApplication CreateWebApplication() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + var app = builder.Build(); + app.Urls.Add("http://127.0.0.1:0"); + return app; + } + + private static async Task WriteAgentResponseAsync( + HttpContext context, + byte[] responseBody, + string? compressionEncoding) + { + if (compressionEncoding is null || + !context.Request.Headers.AcceptEncoding.ToString().Contains( + compressionEncoding, + StringComparison.OrdinalIgnoreCase)) + { + await context.Response.Body.WriteAsync(responseBody, context.RequestAborted); + return; + } + + context.Response.Headers.ContentEncoding = compressionEncoding; + await using Stream compressionStream = compressionEncoding switch + { + "gzip" => new GZipStream(context.Response.Body, CompressionLevel.Fastest, leaveOpen: true), + "br" => new BrotliStream(context.Response.Body, CompressionLevel.Fastest, leaveOpen: true), + _ => throw new InvalidOperationException($"Unsupported test compression encoding '{compressionEncoding}'.") + }; + await compressionStream.WriteAsync(responseBody, context.RequestAborted); + } + + private static TestBackendResource CreateBackendResource(string name, string backendUrl) + { + var backendUri = new Uri(backendUrl); + var resource = new TestBackendResource(name); + var endpoint = new EndpointAnnotation( + ProtocolType.Tcp, + uriScheme: backendUri.Scheme, + name: "http", + port: backendUri.Port, + isProxied: false) + { + TargetHost = backendUri.Host + }; + endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, backendUri.Host, backendUri.Port); + resource.Annotations.Add(endpoint); + return resource; + } + + private static string GetBaseAddress(WebApplication app) + { + var addresses = app.Services.GetRequiredService() + .Features.Get()!; + return addresses.Addresses.Single(); + } + + private sealed class TestBackendResource(string name) : Resource(name), IResourceWithEndpoints; + } +} diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.css b/python/packages/devui/agent_framework_devui/ui/assets/index.css index 0c68fc8ab05..06ace2eaad2 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.css +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.css @@ -1 +1,2 @@ -/*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75/1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75/1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--drop-shadow-lg:0 4px 4px #00000026;--ease-out:cubic-bezier(0,0,.2,1);--ease-in-out:cubic-bezier(.4,0,.2,1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4,0,.6,1)infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring)50%,transparent)}}body{background-color:var(--background);color:var(--foreground)}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:calc(var(--spacing)*0)}.inset-2{inset:calc(var(--spacing)*2)}.inset-y-0{inset-block:calc(var(--spacing)*0)}.top-0{top:calc(var(--spacing)*0)}.top-1{top:calc(var(--spacing)*1)}.top-2{top:calc(var(--spacing)*2)}.top-4{top:calc(var(--spacing)*4)}.top-\[30px\]{top:30px}.-right-2{right:calc(var(--spacing)*-2)}.right-0{right:calc(var(--spacing)*0)}.right-1{right:calc(var(--spacing)*1)}.right-2{right:calc(var(--spacing)*2)}.right-4{right:calc(var(--spacing)*4)}.bottom-0{bottom:calc(var(--spacing)*0)}.bottom-24{bottom:calc(var(--spacing)*24)}.-left-2{left:calc(var(--spacing)*-2)}.left-0{left:calc(var(--spacing)*0)}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing)*2)}.left-\[18px\]{left:18px}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (min-width:40rem){.container\!{max-width:40rem!important}}@media (min-width:48rem){.container\!{max-width:48rem!important}}@media (min-width:64rem){.container\!{max-width:64rem!important}}@media (min-width:80rem){.container\!{max-width:80rem!important}}@media (min-width:96rem){.container\!{max-width:96rem!important}}.m-2{margin:calc(var(--spacing)*2)}.-mx-1{margin-inline:calc(var(--spacing)*-1)}.mx-0\.5{margin-inline:calc(var(--spacing)*.5)}.mx-4{margin-inline:calc(var(--spacing)*4)}.mx-auto{margin-inline:auto}.my-1{margin-block:calc(var(--spacing)*1)}.my-2{margin-block:calc(var(--spacing)*2)}.my-3{margin-block:calc(var(--spacing)*3)}.my-4{margin-block:calc(var(--spacing)*4)}.mt-0{margin-top:calc(var(--spacing)*0)}.mt-0\.5{margin-top:calc(var(--spacing)*.5)}.mt-1{margin-top:calc(var(--spacing)*1)}.mt-2{margin-top:calc(var(--spacing)*2)}.mt-3{margin-top:calc(var(--spacing)*3)}.mt-4{margin-top:calc(var(--spacing)*4)}.mt-12{margin-top:calc(var(--spacing)*12)}.mr-1{margin-right:calc(var(--spacing)*1)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-1{margin-bottom:calc(var(--spacing)*1)}.mb-2{margin-bottom:calc(var(--spacing)*2)}.mb-3{margin-bottom:calc(var(--spacing)*3)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.mb-6{margin-bottom:calc(var(--spacing)*6)}.mb-8{margin-bottom:calc(var(--spacing)*8)}.ml-0{margin-left:calc(var(--spacing)*0)}.ml-1{margin-left:calc(var(--spacing)*1)}.ml-2{margin-left:calc(var(--spacing)*2)}.ml-3{margin-left:calc(var(--spacing)*3)}.ml-4{margin-left:calc(var(--spacing)*4)}.ml-5{margin-left:calc(var(--spacing)*5)}.ml-6{margin-left:calc(var(--spacing)*6)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing)*2);height:calc(var(--spacing)*2)}.size-3\.5{width:calc(var(--spacing)*3.5);height:calc(var(--spacing)*3.5)}.size-4{width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.size-9{width:calc(var(--spacing)*9);height:calc(var(--spacing)*9)}.\!h-2{height:calc(var(--spacing)*2)!important}.h-0{height:calc(var(--spacing)*0)}.h-0\.5{height:calc(var(--spacing)*.5)}.h-1{height:calc(var(--spacing)*1)}.h-2{height:calc(var(--spacing)*2)}.h-2\.5{height:calc(var(--spacing)*2.5)}.h-3{height:calc(var(--spacing)*3)}.h-3\.5{height:calc(var(--spacing)*3.5)}.h-4{height:calc(var(--spacing)*4)}.h-5{height:calc(var(--spacing)*5)}.h-6{height:calc(var(--spacing)*6)}.h-7{height:calc(var(--spacing)*7)}.h-8{height:calc(var(--spacing)*8)}.h-9{height:calc(var(--spacing)*9)}.h-10{height:calc(var(--spacing)*10)}.h-12{height:calc(var(--spacing)*12)}.h-14{height:calc(var(--spacing)*14)}.h-16{height:calc(var(--spacing)*16)}.h-32{height:calc(var(--spacing)*32)}.h-\[1\.2rem\]{height:1.2rem}.h-\[1px\]{height:1px}.h-\[85vh\]{height:85vh}.h-\[500px\]{height:500px}.h-\[calc\(100\%\+8px\)\]{height:calc(100% + 8px)}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[calc\(100vh-3\.7rem\)\]{height:calc(100vh - 3.7rem)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-32{max-height:calc(var(--spacing)*32)}.max-h-40{max-height:calc(var(--spacing)*40)}.max-h-48{max-height:calc(var(--spacing)*48)}.max-h-60{max-height:calc(var(--spacing)*60)}.max-h-64{max-height:calc(var(--spacing)*64)}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-\[400px\]{max-height:400px}.max-h-none{max-height:none}.max-h-screen{max-height:100vh}.\!min-h-0{min-height:calc(var(--spacing)*0)!important}.min-h-0{min-height:calc(var(--spacing)*0)}.min-h-16{min-height:calc(var(--spacing)*16)}.min-h-\[36px\]{min-height:36px}.min-h-\[40px\]{min-height:40px}.min-h-\[50vh\]{min-height:50vh}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.\!w-2{width:calc(var(--spacing)*2)!important}.w-1{width:calc(var(--spacing)*1)}.w-2{width:calc(var(--spacing)*2)}.w-2\.5{width:calc(var(--spacing)*2.5)}.w-3{width:calc(var(--spacing)*3)}.w-3\.5{width:calc(var(--spacing)*3.5)}.w-4{width:calc(var(--spacing)*4)}.w-5{width:calc(var(--spacing)*5)}.w-6{width:calc(var(--spacing)*6)}.w-8{width:calc(var(--spacing)*8)}.w-9{width:calc(var(--spacing)*9)}.w-10{width:calc(var(--spacing)*10)}.w-12{width:calc(var(--spacing)*12)}.w-16{width:calc(var(--spacing)*16)}.w-20{width:calc(var(--spacing)*20)}.w-56{width:calc(var(--spacing)*56)}.w-64{width:calc(var(--spacing)*64)}.w-80{width:calc(var(--spacing)*80)}.w-\[1\.2rem\]{width:1.2rem}.w-\[1px\]{width:1px}.w-\[28rem\]{width:28rem}.w-\[90vw\]{width:90vw}.w-\[600px\]{width:600px}.w-\[800px\]{width:800px}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-\[200px\]{max-width:200px}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.\!min-w-0{min-width:calc(var(--spacing)*0)!important}.min-w-0{min-width:calc(var(--spacing)*0)}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[8rem\]{min-width:8rem}.min-w-\[50px\]{min-width:50px}.min-w-\[80px\]{min-width:80px}.min-w-\[300px\]{min-width:300px}.min-w-\[400px\]{min-width:400px}.min-w-\[800px\]{min-width:800px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.origin-bottom{transform-origin:bottom}.-translate-x-1\/2{--tw-translate-x: -50% ;translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-0{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-75{--tw-scale-x:75%;--tw-scale-y:75%;--tw-scale-z:75%;scale:var(--tw-scale-x)var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.rotate-0{rotate:none}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing)*1)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-\[auto_auto_1fr_auto\]{grid-template-columns:auto auto 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing)*0)}.gap-1{gap:calc(var(--spacing)*1)}.gap-1\.5{gap:calc(var(--spacing)*1.5)}.gap-2{gap:calc(var(--spacing)*2)}.gap-3{gap:calc(var(--spacing)*3)}.gap-4{gap:calc(var(--spacing)*4)}.gap-6{gap:calc(var(--spacing)*6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*1.5)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*1.5)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*2)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*3)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*3)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*4)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*4)*calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing)*6)*var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing)*6)*calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing)*4)}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*1)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*1)*calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing)*2)*var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing)*2)*calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:calc(var(--spacing)*1)}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.\!rounded-full{border-radius:3.40282e38px!important}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.\!border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-gray-600{border-color:var(--color-gray-600)!important}.border-\[\#643FB2\]{border-color:#643fb2}.border-\[\#643FB2\]\/20{border-color:#643fb233}.border-\[\#643FB2\]\/30{border-color:#643fb24d}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-500{border-color:var(--color-blue-500)}.border-border,.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab,red,red)){.border-border\/50{border-color:color-mix(in oklab,var(--border)50%,transparent)}}.border-current\/30{border-color:currentColor}@supports (color:color-mix(in lab,red,red)){.border-current\/30{border-color:color-mix(in oklab,currentcolor 30%,transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.border-destructive\/30{border-color:color-mix(in oklab,var(--destructive)30%,transparent)}}.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/5{border-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/10{border-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.border-foreground\/20{border-color:color-mix(in oklab,var(--foreground)20%,transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab,red,red)){.border-gray-500\/20{border-color:color-mix(in oklab,var(--color-gray-500)20%,transparent)}}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-green-500\/20{border-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.border-green-500\/20{border-color:color-mix(in oklab,var(--color-green-500)20%,transparent)}}.border-green-500\/40{border-color:#00c75866}@supports (color:color-mix(in lab,red,red)){.border-green-500\/40{border-color:color-mix(in oklab,var(--color-green-500)40%,transparent)}}.border-green-600\/20{border-color:#00a54433}@supports (color:color-mix(in lab,red,red)){.border-green-600\/20{border-color:color-mix(in oklab,var(--color-green-600)20%,transparent)}}.border-input{border-color:var(--input)}.border-muted,.border-muted\/50{border-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.border-muted\/50{border-color:color-mix(in oklab,var(--muted)50%,transparent)}}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-300{border-color:var(--color-orange-300)}.border-orange-500{border-color:var(--color-orange-500)}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/20{border-color:color-mix(in oklab,var(--color-orange-500)20%,transparent)}}.border-orange-500\/40{border-color:#fe6e0066}@supports (color:color-mix(in lab,red,red)){.border-orange-500\/40{border-color:color-mix(in oklab,var(--color-orange-500)40%,transparent)}}.border-orange-600\/20{border-color:#f0510033}@supports (color:color-mix(in lab,red,red)){.border-orange-600\/20{border-color:color-mix(in oklab,var(--color-orange-600)20%,transparent)}}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.border-primary\/20{border-color:color-mix(in oklab,var(--primary)20%,transparent)}}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.border-red-500\/20{border-color:color-mix(in oklab,var(--color-red-500)20%,transparent)}}.border-transparent{border-color:#0000}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#643FB2\]{background-color:#643fb2}.bg-\[\#643FB2\]\/10{background-color:#643fb21a}.bg-accent\/10{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.bg-accent\/10{background-color:color-mix(in oklab,var(--accent)10%,transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab,red,red)){.bg-amber-500\/10{background-color:color-mix(in oklab,var(--color-amber-500)10%,transparent)}}.bg-background,.bg-background\/50{background-color:var(--background)}@supports (color:color-mix(in lab,red,red)){.bg-background\/50{background-color:color-mix(in oklab,var(--background)50%,transparent)}}.bg-black{background-color:var(--color-black)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black)50%,transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab,red,red)){.bg-black\/60{background-color:color-mix(in oklab,var(--color-black)60%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab,red,red)){.bg-blue-50\/80{background-color:color-mix(in oklab,var(--color-blue-50)80%,transparent)}}.bg-blue-50\/95{background-color:#eff6fff2}@supports (color:color-mix(in lab,red,red)){.bg-blue-50\/95{background-color:color-mix(in oklab,var(--color-blue-50)95%,transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/5{background-color:color-mix(in oklab,var(--color-blue-500)5%,transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.bg-blue-500\/10{background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.bg-destructive\/10{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-foreground\/5{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/5{background-color:color-mix(in oklab,var(--foreground)5%,transparent)}}.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.bg-foreground\/10{background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab,red,red)){.bg-gray-500\/10{background-color:color-mix(in oklab,var(--color-gray-500)10%,transparent)}}.bg-gray-900\/90{background-color:#101828e6}@supports (color:color-mix(in lab,red,red)){.bg-gray-900\/90{background-color:color-mix(in oklab,var(--color-gray-900)90%,transparent)}}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/5{background-color:#00c7580d}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/5{background-color:color-mix(in oklab,var(--color-green-500)5%,transparent)}}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.bg-green-500\/10{background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/20{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/20{background-color:color-mix(in oklab,var(--muted-foreground)20%,transparent)}}.bg-muted-foreground\/30{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.bg-muted-foreground\/30{background-color:color-mix(in oklab,var(--muted-foreground)30%,transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/30{background-color:color-mix(in oklab,var(--muted)30%,transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-50\/50{background-color:#fff7ed80}@supports (color:color-mix(in lab,red,red)){.bg-orange-50\/50{background-color:color-mix(in oklab,var(--color-orange-50)50%,transparent)}}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-100\/50{background-color:#ffedd580}@supports (color:color-mix(in lab,red,red)){.bg-orange-100\/50{background-color:color-mix(in oklab,var(--color-orange-100)50%,transparent)}}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/5{background-color:#fe6e000d}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/5{background-color:color-mix(in oklab,var(--color-orange-500)5%,transparent)}}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.bg-orange-500\/10{background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/10{background-color:color-mix(in oklab,var(--primary)10%,transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/30{background-color:color-mix(in oklab,var(--primary)30%,transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.bg-primary\/40{background-color:color-mix(in oklab,var(--primary)40%,transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-500{background-color:var(--color-purple-500)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab,red,red)){.bg-red-500\/10{background-color:color-mix(in oklab,var(--color-red-500)10%,transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab,red,red)){.bg-white\/60{background-color:color-mix(in oklab,var(--color-white)60%,transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab,red,red)){.bg-white\/90{background-color:color-mix(in oklab,var(--color-white)90%,transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.fill-current{fill:currentColor}.object-cover{object-fit:cover}.p-0{padding:calc(var(--spacing)*0)}.p-1{padding:calc(var(--spacing)*1)}.p-1\.5{padding:calc(var(--spacing)*1.5)}.p-2{padding:calc(var(--spacing)*2)}.p-3{padding:calc(var(--spacing)*3)}.p-4{padding:calc(var(--spacing)*4)}.p-6{padding:calc(var(--spacing)*6)}.p-8{padding:calc(var(--spacing)*8)}.p-\[1px\]{padding:1px}.px-1{padding-inline:calc(var(--spacing)*1)}.px-1\.5{padding-inline:calc(var(--spacing)*1.5)}.px-2{padding-inline:calc(var(--spacing)*2)}.px-2\.5{padding-inline:calc(var(--spacing)*2.5)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.px-6{padding-inline:calc(var(--spacing)*6)}.px-8{padding-inline:calc(var(--spacing)*8)}.py-0{padding-block:calc(var(--spacing)*0)}.py-0\.5{padding-block:calc(var(--spacing)*.5)}.py-1{padding-block:calc(var(--spacing)*1)}.py-1\.5{padding-block:calc(var(--spacing)*1.5)}.py-2{padding-block:calc(var(--spacing)*2)}.py-2\.5{padding-block:calc(var(--spacing)*2.5)}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-6{padding-block:calc(var(--spacing)*6)}.py-8{padding-block:calc(var(--spacing)*8)}.pt-0{padding-top:calc(var(--spacing)*0)}.pt-1{padding-top:calc(var(--spacing)*1)}.pt-2{padding-top:calc(var(--spacing)*2)}.pt-3{padding-top:calc(var(--spacing)*3)}.pt-4{padding-top:calc(var(--spacing)*4)}.pt-6{padding-top:calc(var(--spacing)*6)}.pt-8{padding-top:calc(var(--spacing)*8)}.pt-9{padding-top:calc(var(--spacing)*9)}.pr-2{padding-right:calc(var(--spacing)*2)}.pr-4{padding-right:calc(var(--spacing)*4)}.pr-8{padding-right:calc(var(--spacing)*8)}.pb-2{padding-bottom:calc(var(--spacing)*2)}.pb-3{padding-bottom:calc(var(--spacing)*3)}.pb-4{padding-bottom:calc(var(--spacing)*4)}.pb-6{padding-bottom:calc(var(--spacing)*6)}.pl-2{padding-left:calc(var(--spacing)*2)}.pl-3{padding-left:calc(var(--spacing)*3)}.pl-4{padding-left:calc(var(--spacing)*4)}.pl-5{padding-left:calc(var(--spacing)*5)}.pl-8{padding-left:calc(var(--spacing)*8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#643FB2\]{color:#643fb2}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-600\/80{color:#dd7400cc}@supports (color:color-mix(in lab,red,red)){.text-amber-600\/80{color:color-mix(in oklab,var(--color-amber-600)80%,transparent)}}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-500{color:var(--color-blue-500)}.text-blue-500\/80{color:#3080ffcc}@supports (color:color-mix(in lab,red,red)){.text-blue-500\/80{color:color-mix(in oklab,var(--color-blue-500)80%,transparent)}}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive,.text-destructive\/70{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/70{color:color-mix(in oklab,var(--destructive)70%,transparent)}}.text-destructive\/90{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.text-destructive\/90{color:color-mix(in oklab,var(--destructive)90%,transparent)}}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-green-900{color:var(--color-green-900)}.text-muted-foreground,.text-muted-foreground\/60{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/60{color:color-mix(in oklab,var(--muted-foreground)60%,transparent)}}.text-muted-foreground\/70{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/70{color:color-mix(in oklab,var(--muted-foreground)70%,transparent)}}.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.text-muted-foreground\/80{color:color-mix(in oklab,var(--muted-foreground)80%,transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-orange-900{color:var(--color-orange-900)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-purple-800{color:var(--color-purple-800)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a),0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(0px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-\[\#643FB2\]\/20{--tw-shadow-color:#643fb233}@supports (color:color-mix(in lab,red,red)){.shadow-\[\#643FB2\]\/20{--tw-shadow-color:color-mix(in oklab,oklab(47.4316% .069152 -.159147/.2) var(--tw-shadow-alpha),transparent)}}.shadow-green-500\/20{--tw-shadow-color:#00c75833}@supports (color:color-mix(in lab,red,red)){.shadow-green-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-green-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab,red,red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-orange-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-primary\/25{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.shadow-primary\/25{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)25%,transparent)var(--tw-shadow-alpha),transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab,red,red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--color-red-500)20%,transparent)var(--tw-shadow-alpha),transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-blue-500\/20{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab,red,red)){.ring-blue-500\/20{--tw-ring-color:color-mix(in oklab,var(--color-blue-500)20%,transparent)}}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,)var(--tw-backdrop-brightness,)var(--tw-backdrop-contrast,)var(--tw-backdrop-grayscale,)var(--tw-backdrop-hue-rotate,)var(--tw-backdrop-invert,)var(--tw-backdrop-opacity,)var(--tw-backdrop-saturate,)var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,visibility,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.fade-in-0{--tw-enter-opacity:0}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.zoom-in-95{--tw-enter-scale:.95}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.fade-in{--tw-enter-opacity:0}.running{animation-play-state:running}.slide-in-from-bottom-2{--tw-enter-translate-y:calc(2*var(--spacing))}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media (hover:hover){.group-hover\:bg-primary:is(:where(.group):hover *){background-color:var(--primary)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-md:is(:where(.group):hover *){--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab,color-mix(in oklab,var(--primary)20%,transparent)var(--tw-shadow-alpha),transparent)}}}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:var(--primary)}.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing)*7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.first\:mt-0:first-child{margin-top:calc(var(--spacing)*0)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:scale-y-\[1\.15\]:hover{--tw-scale-y:1.15;scale:var(--tw-scale-x)var(--tw-scale-y)}.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-muted-foreground\/30:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab,red,red)){.hover\:border-muted-foreground\/30:hover{border-color:color-mix(in oklab,var(--muted-foreground)30%,transparent)}}.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-destructive\/80:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/80:hover{background-color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive)90%,transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/30:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab,var(--muted)30%,transparent)}}.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted)50%,transparent)}}.hover\:bg-muted\/70:hover{background-color:var(--muted)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-muted\/70:hover{background-color:color-mix(in oklab,var(--muted)70%,transparent)}}.hover\:bg-orange-100:hover{background-color:var(--color-orange-100)}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab,var(--primary)20%,transparent)}}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab,var(--primary)80%,transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary)90%,transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary)80%,transparent)}}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-destructive\/80:hover{color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.hover\:text-destructive\/80:hover{color:color-mix(in oklab,var(--destructive)80%,transparent)}}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-orange-900:hover{color:var(--color-orange-900)}.hover\:text-primary:hover{color:var(--primary)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a),0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(1px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(2px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,)0 0 0 calc(3px + var(--tw-ring-offset-width))var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab,var(--ring)50%,transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,)0 0 0 var(--tw-ring-offset-width)var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing)*2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing)*3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing)*4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing)*8)}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:calc(var(--spacing)*1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing)*-1);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing)*9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing)*8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing)*2)}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x:calc(var(--spacing)*4);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:calc(var(--spacing)*0);translate:var(--tw-translate-x)var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)10%,transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media (min-width:40rem){.sm\:w-64{width:calc(var(--spacing)*64)}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (min-width:48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-start-2{grid-column-start:2}.md\:inline{display:inline}.md\:max-w-2xl{max-width:var(--container-2xl)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (min-width:64rem){.lg\:col-span-3{grid-column:span 3/span 3}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}}@media (min-width:80rem){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:max-w-5xl{max-width:var(--container-5xl)}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:scale-0:is(.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:scale-100:is(.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x)var(--tw-scale-y)}.dark\:-rotate-90:is(.dark *){rotate:-90deg}.dark\:rotate-0:is(.dark *){rotate:none}.dark\:\!border-gray-500:is(.dark *){border-color:var(--color-gray-500)!important}.dark\:\!border-gray-600:is(.dark *){border-color:var(--color-gray-600)!important}.dark\:border-\[\#8B5CF6\]:is(.dark *){border-color:#8b5cf6}.dark\:border-\[\#8B5CF6\]\/20:is(.dark *){border-color:#8b5cf633}.dark\:border-\[\#8B5CF6\]\/30:is(.dark *){border-color:#8b5cf64d}.dark\:border-amber-800:is(.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-900:is(.dark *){border-color:var(--color-amber-900)}.dark\:border-blue-400:is(.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:is(.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:is(.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:is(.dark *){border-color:var(--color-blue-800)}.dark\:border-gray-500:is(.dark *){border-color:var(--color-gray-500)}.dark\:border-gray-600:is(.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:is(.dark *){border-color:var(--color-gray-700)}.dark\:border-green-400:is(.dark *){border-color:var(--color-green-400)}.dark\:border-green-800:is(.dark *){border-color:var(--color-green-800)}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:border-orange-400:is(.dark *){border-color:var(--color-orange-400)}.dark\:border-orange-700:is(.dark *){border-color:var(--color-orange-700)}.dark\:border-orange-800:is(.dark *){border-color:var(--color-orange-800)}.dark\:border-red-400:is(.dark *){border-color:var(--color-red-400)}.dark\:border-red-800:is(.dark *){border-color:var(--color-red-800)}.dark\:\!bg-gray-800\/90:is(.dark *){background-color:#1e2939e6!important}@supports (color:color-mix(in lab,red,red)){.dark\:\!bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)!important}}.dark\:bg-\[\#8B5CF6\]:is(.dark *){background-color:#8b5cf6}.dark\:bg-\[\#8B5CF6\]\/10:is(.dark *){background-color:#8b5cf61a}.dark\:bg-amber-600:is(.dark *){background-color:var(--color-amber-600)}.dark\:bg-amber-950\/20:is(.dark *){background-color:#46190133}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)20%,transparent)}}.dark\:bg-amber-950\/50:is(.dark *){background-color:#46190180}@supports (color:color-mix(in lab,red,red)){.dark\:bg-amber-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-amber-950)50%,transparent)}}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-500)10%,transparent)}}.dark\:bg-blue-600:is(.dark *){background-color:var(--color-blue-600)}.dark\:bg-blue-900:is(.dark *){background-color:var(--color-blue-900)}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-900\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-900)20%,transparent)}}.dark\:bg-blue-950\/20:is(.dark *){background-color:#16245633}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)20%,transparent)}}.dark\:bg-blue-950\/40:is(.dark *){background-color:#16245666}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/40:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)40%,transparent)}}.dark\:bg-blue-950\/50:is(.dark *){background-color:#16245680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)50%,transparent)}}.dark\:bg-blue-950\/95:is(.dark *){background-color:#162456f2}@supports (color:color-mix(in lab,red,red)){.dark\:bg-blue-950\/95:is(.dark *){background-color:color-mix(in oklab,var(--color-blue-950)95%,transparent)}}.dark\:bg-card:is(.dark *){background-color:var(--card)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive)60%,transparent)}}.dark\:bg-emerald-600:is(.dark *){background-color:var(--color-emerald-600)}.dark\:bg-foreground\/10:is(.dark *){background-color:var(--foreground)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-foreground\/10:is(.dark *){background-color:color-mix(in oklab,var(--foreground)10%,transparent)}}.dark\:bg-gray-500:is(.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:is(.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-800\/90:is(.dark *){background-color:#1e2939e6}@supports (color:color-mix(in lab,red,red)){.dark\:bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-800)90%,transparent)}}.dark\:bg-gray-900:is(.dark *){background-color:var(--color-gray-900)}.dark\:bg-gray-900\/30:is(.dark *){background-color:#1018284d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-gray-900\/30:is(.dark *){background-color:color-mix(in oklab,var(--color-gray-900)30%,transparent)}}.dark\:bg-green-400:is(.dark *){background-color:var(--color-green-400)}.dark\:bg-green-500\/10:is(.dark *){background-color:#00c7581a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-green-500)10%,transparent)}}.dark\:bg-green-900:is(.dark *){background-color:var(--color-green-900)}.dark\:bg-green-950:is(.dark *){background-color:var(--color-green-950)}.dark\:bg-green-950\/20:is(.dark *){background-color:#032e1533}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)20%,transparent)}}.dark\:bg-green-950\/50:is(.dark *){background-color:#032e1580}@supports (color:color-mix(in lab,red,red)){.dark\:bg-green-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-green-950)50%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input)30%,transparent)}}.dark\:bg-orange-400:is(.dark *){background-color:var(--color-orange-400)}.dark\:bg-orange-500\/10:is(.dark *){background-color:#fe6e001a}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-500\/10:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-500)10%,transparent)}}.dark\:bg-orange-600:is(.dark *){background-color:var(--color-orange-600)}.dark\:bg-orange-900:is(.dark *){background-color:var(--color-orange-900)}.dark\:bg-orange-950:is(.dark *){background-color:var(--color-orange-950)}.dark\:bg-orange-950\/20:is(.dark *){background-color:#44130633}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)20%,transparent)}}.dark\:bg-orange-950\/30:is(.dark *){background-color:#4413064d}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/30:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)30%,transparent)}}.dark\:bg-orange-950\/50:is(.dark *){background-color:#44130680}@supports (color:color-mix(in lab,red,red)){.dark\:bg-orange-950\/50:is(.dark *){background-color:color-mix(in oklab,var(--color-orange-950)50%,transparent)}}.dark\:bg-purple-600:is(.dark *){background-color:var(--color-purple-600)}.dark\:bg-purple-900:is(.dark *){background-color:var(--color-purple-900)}.dark\:bg-red-400:is(.dark *){background-color:var(--color-red-400)}.dark\:bg-red-900:is(.dark *){background-color:var(--color-red-900)}.dark\:bg-red-950:is(.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/20:is(.dark *){background-color:#46080933}@supports (color:color-mix(in lab,red,red)){.dark\:bg-red-950\/20:is(.dark *){background-color:color-mix(in oklab,var(--color-red-950)20%,transparent)}}.dark\:text-\[\#8B5CF6\]:is(.dark *){color:#8b5cf6}.dark\:text-amber-100:is(.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:is(.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:is(.dark *){color:var(--color-amber-300)}.dark\:text-amber-400:is(.dark *){color:var(--color-amber-400)}.dark\:text-amber-400\/80:is(.dark *){color:#fcbb00cc}@supports (color:color-mix(in lab,red,red)){.dark\:text-amber-400\/80:is(.dark *){color:color-mix(in oklab,var(--color-amber-400)80%,transparent)}}.dark\:text-amber-500:is(.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:is(.dark *){color:var(--color-blue-100)}.dark\:text-blue-200:is(.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:is(.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:is(.dark *){color:var(--color-blue-400)}.dark\:text-blue-400\/70:is(.dark *){color:#54a2ffb3}@supports (color:color-mix(in lab,red,red)){.dark\:text-blue-400\/70:is(.dark *){color:color-mix(in oklab,var(--color-blue-400)70%,transparent)}}.dark\:text-blue-500:is(.dark *){color:var(--color-blue-500)}.dark\:text-emerald-400:is(.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:is(.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:is(.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:is(.dark *){color:var(--color-gray-400)}.dark\:text-green-100:is(.dark *){color:var(--color-green-100)}.dark\:text-green-200:is(.dark *){color:var(--color-green-200)}.dark\:text-green-300:is(.dark *){color:var(--color-green-300)}.dark\:text-green-400:is(.dark *){color:var(--color-green-400)}.dark\:text-orange-100:is(.dark *){color:var(--color-orange-100)}.dark\:text-orange-200:is(.dark *){color:var(--color-orange-200)}.dark\:text-orange-300:is(.dark *){color:var(--color-orange-300)}.dark\:text-orange-400:is(.dark *){color:var(--color-orange-400)}.dark\:text-purple-200:is(.dark *){color:var(--color-purple-200)}.dark\:text-purple-400:is(.dark *){color:var(--color-purple-400)}.dark\:text-red-200:is(.dark *){color:var(--color-red-200)}.dark\:text-red-400:is(.dark *){color:var(--color-red-400)}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}.dark\:opacity-30:is(.dark *){opacity:.3}@media (hover:hover){.dark\:hover\:border-gray-600:is(.dark *):hover{border-color:var(--color-gray-600)}.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent)50%,transparent)}}.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:#4619014d}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-amber-950)30%,transparent)}}.dark\:hover\:bg-gray-800:is(.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input)50%,transparent)}}.dark\:hover\:bg-orange-950\/40:is(.dark *):hover{background-color:#44130666}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-orange-950\/40:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-orange-950)40%,transparent)}}.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab,red,red)){.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:color-mix(in oklab,var(--color-red-900)20%,transparent)}}.dark\:hover\:text-orange-200:is(.dark *):hover{color:var(--color-orange-200)}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab,var(--destructive)40%,transparent)}}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab,red,red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab,var(--destructive)20%,transparent)}}.\[\&_p\]\:leading-relaxed p{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing)*4);height:calc(var(--spacing)*4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing)*6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing)*6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing)*2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:top-4>svg{top:calc(var(--spacing)*4)}.\[\&\>svg\]\:left-4>svg{left:calc(var(--spacing)*4)}.\[\&\>svg\]\:text-foreground>svg{color:var(--foreground)}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;translate:var(--tw-translate-x)var(--tw-translate-y)}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:calc(var(--spacing)*7)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(48% .18 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(62% .2 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.6% 0 0)}.workflow-chat-view .border-green-200{border-color:var(--color-emerald-200)}.workflow-chat-view .bg-green-50{background-color:var(--color-emerald-50)}.workflow-chat-view .bg-green-100{background-color:var(--color-emerald-100)}.workflow-chat-view .text-green-600{color:var(--color-emerald-600)}.workflow-chat-view .text-green-700{color:var(--color-emerald-700)}.workflow-chat-view .text-green-800{color:var(--color-emerald-800)}.highlight-attention{animation:1s ease-out highlight-flash}@keyframes highlight-flash{0%{background-color:#fb923c4d;transform:scale(1.02)}to{background-color:#0000;transform:scale(1)}}.hil-waiting-glow{animation:2s infinite pulse-glow;box-shadow:0 0 #fb923c66,inset 0 0 0 1px #fb923c33}@keyframes pulse-glow{0%,to{box-shadow:0 0 #fb923c66,inset 0 0 0 1px #fb923c33}50%{box-shadow:0 0 20px 5px #fb923c33,inset 0 0 0 2px #fb923c4d}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}.react-flow{direction:ltr;--xy-edge-stroke-default: #b1b1b7;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #555;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(255, 255, 255, .5);--xy-minimap-background-color-default: #fff;--xy-minimap-mask-background-color-default: rgba(240, 240, 240, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #e2e2e2;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: transparent;--xy-background-pattern-dots-color-default: #91919a;--xy-background-pattern-lines-color-default: #eee;--xy-background-pattern-cross-color-default: #e2e2e2;background-color:var(--xy-background-color, var(--xy-background-color-default));--xy-node-color-default: inherit;--xy-node-border-default: 1px solid #1a192b;--xy-node-background-color-default: #fff;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(0, 0, 0, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #1a192b;--xy-node-border-radius-default: 3px;--xy-handle-background-color-default: #1a192b;--xy-handle-border-color-default: #fff;--xy-selection-background-color-default: rgba(0, 89, 220, .08);--xy-selection-border-default: 1px dotted rgba(0, 89, 220, .8);--xy-controls-button-background-color-default: #fefefe;--xy-controls-button-background-color-hover-default: #f4f4f4;--xy-controls-button-color-default: inherit;--xy-controls-button-color-hover-default: inherit;--xy-controls-button-border-color-default: #eee;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #ffffff;--xy-edge-label-color-default: inherit;--xy-resize-background-color-default: #3367d9}.react-flow.dark{--xy-edge-stroke-default: #3e3e3e;--xy-edge-stroke-width-default: 1;--xy-edge-stroke-selected-default: #727272;--xy-connectionline-stroke-default: #b1b1b7;--xy-connectionline-stroke-width-default: 1;--xy-attribution-background-color-default: rgba(150, 150, 150, .25);--xy-minimap-background-color-default: #141414;--xy-minimap-mask-background-color-default: rgba(60, 60, 60, .6);--xy-minimap-mask-stroke-color-default: transparent;--xy-minimap-mask-stroke-width-default: 1;--xy-minimap-node-background-color-default: #2b2b2b;--xy-minimap-node-stroke-color-default: transparent;--xy-minimap-node-stroke-width-default: 2;--xy-background-color-default: #141414;--xy-background-pattern-dots-color-default: #777;--xy-background-pattern-lines-color-default: #777;--xy-background-pattern-cross-color-default: #777;--xy-node-color-default: #f8f8f8;--xy-node-border-default: 1px solid #3c3c3c;--xy-node-background-color-default: #1e1e1e;--xy-node-group-background-color-default: rgba(240, 240, 240, .25);--xy-node-boxshadow-hover-default: 0 1px 4px 1px rgba(255, 255, 255, .08);--xy-node-boxshadow-selected-default: 0 0 0 .5px #999;--xy-handle-background-color-default: #bebebe;--xy-handle-border-color-default: #1e1e1e;--xy-selection-background-color-default: rgba(200, 200, 220, .08);--xy-selection-border-default: 1px dotted rgba(200, 200, 220, .8);--xy-controls-button-background-color-default: #2b2b2b;--xy-controls-button-background-color-hover-default: #3e3e3e;--xy-controls-button-color-default: #f8f8f8;--xy-controls-button-color-hover-default: #fff;--xy-controls-button-border-color-default: #5b5b5b;--xy-controls-box-shadow-default: 0 0 2px 1px rgba(0, 0, 0, .08);--xy-edge-label-background-color-default: #141414;--xy-edge-label-color-default: #f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props, var(--xy-background-color, var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{position:absolute;width:100%;height:100%;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width, var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke, var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width, var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{overflow:visible;position:absolute;pointer-events:none}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:dashdraw .5s linear infinite}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected, var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke, var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:dashdraw .5s linear infinite}svg.react-flow__connectionline{z-index:1001;overflow:visible;position:absolute}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{position:absolute;-webkit-user-select:none;-moz-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:left top;pointer-events:none}.react-flow__nodesselection-rect{position:absolute;pointer-events:all;cursor:grab}.react-flow__handle{position:absolute;pointer-events:none;min-width:5px;min-height:5px;width:6px;height:6px;background-color:var(--xy-handle-background-color, var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color, var(--xy-handle-border-color-default));border-radius:100%}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;left:50%;bottom:0;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{position:absolute;z-index:5;margin:15px}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px) translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px) translateY(-50%)}.react-flow__attribution{font-size:10px;background:var(--xy-attribution-background-color, var(--xy-attribution-background-color-default));padding:2px 3px;margin:0}.react-flow__attribution a{text-decoration:none;color:#999}@keyframes dashdraw{0%{stroke-dashoffset:10}}.react-flow__edgelabel-renderer{position:absolute;width:100%;height:100%;pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;left:0;top:0}.react-flow__viewport-portal{position:absolute;width:100%;height:100%;left:0;top:0;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__minimap{background:var( --xy-minimap-background-color-props, var(--xy-minimap-background-color, var(--xy-minimap-background-color-default)) )}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var( --xy-minimap-mask-background-color-props, var(--xy-minimap-mask-background-color, var(--xy-minimap-mask-background-color-default)) );stroke:var( --xy-minimap-mask-stroke-color-props, var(--xy-minimap-mask-stroke-color, var(--xy-minimap-mask-stroke-color-default)) );stroke-width:var( --xy-minimap-mask-stroke-width-props, var(--xy-minimap-mask-stroke-width, var(--xy-minimap-mask-stroke-width-default)) )}.react-flow__minimap-node{fill:var( --xy-minimap-node-background-color-props, var(--xy-minimap-node-background-color, var(--xy-minimap-node-background-color-default)) );stroke:var( --xy-minimap-node-stroke-color-props, var(--xy-minimap-node-stroke-color, var(--xy-minimap-node-stroke-color-default)) );stroke-width:var( --xy-minimap-node-stroke-width-props, var(--xy-minimap-node-stroke-width, var(--xy-minimap-node-stroke-width-default)) )}.react-flow__background-pattern.dots{fill:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-dots-color-default)) )}.react-flow__background-pattern.lines{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-lines-color-default)) )}.react-flow__background-pattern.cross{stroke:var( --xy-background-pattern-color-props, var(--xy-background-pattern-color, var(--xy-background-pattern-cross-color-default)) )}.react-flow__controls{display:flex;flex-direction:column;box-shadow:var(--xy-controls-box-shadow, var(--xy-controls-box-shadow-default))}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{display:flex;justify-content:center;align-items:center;height:26px;width:26px;padding:4px;border:none;background:var(--xy-controls-button-background-color, var(--xy-controls-button-background-color-default));border-bottom:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) );color:var( --xy-controls-button-color-props, var(--xy-controls-button-color, var(--xy-controls-button-color-default)) );cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.react-flow__controls-button svg{width:100%;max-width:12px;max-height:12px;fill:currentColor}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{padding:10px;border-radius:var(--xy-node-border-radius, var(--xy-node-border-radius-default));width:150px;font-size:12px;color:var(--xy-node-color, var(--xy-node-color-default));text-align:center;border:var(--xy-node-border, var(--xy-node-border-default));background-color:var(--xy-node-background-color, var(--xy-node-background-color-default))}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover, var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected, var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color, var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color, var(--xy-selection-background-color-default));border:var(--xy-selection-border, var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var( --xy-controls-button-background-color-hover-props, var(--xy-controls-button-background-color-hover, var(--xy-controls-button-background-color-hover-default)) );color:var( --xy-controls-button-color-hover-props, var(--xy-controls-button-color-hover, var(--xy-controls-button-color-hover-default)) )}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var( --xy-controls-button-border-color-props, var(--xy-controls-button-border-color, var(--xy-controls-button-border-color-default)) )}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{width:5px;height:5px;border:1px solid #fff;border-radius:1px;background-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));translate:-50% -50%}.react-flow__resize-control.handle.left{left:0;top:50%}.react-flow__resize-control.handle.right{left:100%;top:50%}.react-flow__resize-control.handle.top{left:50%;top:0}.react-flow__resize-control.handle.bottom{left:50%;top:100%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color, var(--xy-resize-background-color-default));border-width:0;border-style:solid}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;transform:translate(-50%);top:0;height:100%}.react-flow__resize-control.line.left{left:0;border-left-width:1px}.react-flow__resize-control.line.right{left:100%;border-right-width:1px}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{height:1px;transform:translateY(-50%);left:0;width:100%}.react-flow__resize-control.line.top{top:0;border-top-width:1px}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color, var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color, var(--xy-edge-label-color-default))} +/*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-animation-delay:0s;--tw-animation-direction:normal;--tw-animation-duration:initial;--tw-animation-fill-mode:none;--tw-animation-iteration-count:1;--tw-enter-blur:0;--tw-enter-opacity:1;--tw-enter-rotate:0;--tw-enter-scale:1;--tw-enter-translate-x:0;--tw-enter-translate-y:0;--tw-exit-blur:0;--tw-exit-opacity:1;--tw-exit-rotate:0;--tw-exit-scale:1;--tw-exit-translate-x:0;--tw-exit-translate-y:0}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-red-950:oklch(25.8% .092 26.042);--color-orange-50:oklch(98% .016 73.684);--color-orange-100:oklch(95.4% .038 75.164);--color-orange-200:oklch(90.1% .076 70.697);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-orange-700:oklch(55.3% .195 38.402);--color-orange-800:oklch(47% .157 37.304);--color-orange-900:oklch(40.8% .123 38.172);--color-orange-950:oklch(26.6% .079 36.259);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-amber-950:oklch(27.9% .077 45.635);--color-yellow-100:oklch(97.3% .071 103.193);--color-yellow-200:oklch(94.5% .129 101.54);--color-yellow-400:oklch(85.2% .199 91.936);--color-yellow-600:oklch(68.1% .162 75.834);--color-yellow-700:oklch(55.4% .135 66.442);--color-green-50:oklch(98.2% .018 155.826);--color-green-100:oklch(96.2% .044 156.743);--color-green-200:oklch(92.5% .084 155.995);--color-green-300:oklch(87.1% .15 154.449);--color-green-400:oklch(79.2% .209 151.711);--color-green-500:oklch(72.3% .219 149.579);--color-green-600:oklch(62.7% .194 149.214);--color-green-700:oklch(52.7% .154 150.069);--color-green-800:oklch(44.8% .119 151.328);--color-green-900:oklch(39.3% .095 152.535);--color-green-950:oklch(26.6% .065 152.934);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-400:oklch(70.7% .165 254.624);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-blue-800:oklch(42.4% .199 265.638);--color-blue-900:oklch(37.9% .146 265.522);--color-blue-950:oklch(28.2% .091 267.935);--color-purple-50:oklch(97.7% .014 308.299);--color-purple-100:oklch(94.6% .033 307.174);--color-purple-200:oklch(90.2% .063 306.703);--color-purple-400:oklch(71.4% .203 305.504);--color-purple-500:oklch(62.7% .265 303.9);--color-purple-600:oklch(55.8% .288 302.321);--color-purple-800:oklch(43.8% .218 303.724);--color-purple-900:oklch(38.1% .176 304.987);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-300:oklch(87.2% .01 258.338);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-600:oklch(44.6% .03 256.802);--color-gray-700:oklch(37.3% .034 259.733);--color-gray-800:oklch(27.8% .033 256.848);--color-gray-900:oklch(21% .034 264.665);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-3xl:48rem;--container-4xl:56rem;--container-5xl:64rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-tight:1.25;--leading-relaxed:1.625;--drop-shadow-lg:0 4px 4px #00000026;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--animate-bounce:bounce 1s infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}*{border-color:var(--border);outline-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){*{outline-color:color-mix(in oklab, var(--ring) 50%, transparent)}}body{background-color:var(--background);color:var(--foreground)}}@layer components;@layer utilities{.\@container\/card-header{container:card-header/inline-size}.pointer-events-none{pointer-events:none}.collapse{visibility:collapse}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.inset-0{inset:0}.inset-2{inset:calc(var(--spacing) * 2)}.inset-y-0{inset-block:0}.top-0{top:0}.top-1{top:var(--spacing)}.top-2{top:calc(var(--spacing) * 2)}.top-4{top:calc(var(--spacing) * 4)}.top-\[30px\]{top:30px}.-right-2{right:calc(var(--spacing) * -2)}.right-0{right:0}.right-1{right:var(--spacing)}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-0{bottom:0}.bottom-24{bottom:calc(var(--spacing) * 24)}.-left-2{left:calc(var(--spacing) * -2)}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:calc(var(--spacing) * 2)}.left-\[18px\]{left:18px}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2/span 2}.row-start-1{grid-row-start:1}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.container\!{width:100%!important}@media (width>=40rem){.container\!{max-width:40rem!important}}@media (width>=48rem){.container\!{max-width:48rem!important}}@media (width>=64rem){.container\!{max-width:64rem!important}}@media (width>=80rem){.container\!{max-width:80rem!important}}@media (width>=96rem){.container\!{max-width:96rem!important}}.m-2{margin:calc(var(--spacing) * 2)}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-0\.5{margin-inline:calc(var(--spacing) * .5)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-3{margin-block:calc(var(--spacing) * 3)}.my-4{margin-block:calc(var(--spacing) * 4)}.mt-0{margin-top:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-12{margin-top:calc(var(--spacing) * 12)}.mr-1{margin-right:var(--spacing)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-0{margin-left:0}.ml-1{margin-left:var(--spacing)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-5{margin-left:calc(var(--spacing) * 5)}.ml-6{margin-left:calc(var(--spacing) * 6)}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.field-sizing-content{field-sizing:content}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.\!h-2{height:calc(var(--spacing) * 2)!important}.h-0{height:0}.h-0\.5{height:calc(var(--spacing) * .5)}.h-1{height:var(--spacing)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-\[1\.2rem\]{height:1.2rem}.h-\[1px\]{height:1px}.h-\[85vh\]{height:85vh}.h-\[500px\]{height:500px}.h-\[calc\(100\%\+8px\)\]{height:calc(100% + 8px)}.h-\[calc\(100vh-3\.5rem\)\]{height:calc(100vh - 3.5rem)}.h-\[calc\(100vh-3\.7rem\)\]{height:calc(100vh - 3.7rem)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-dropdown-menu-content-available-height\){max-height:var(--radix-dropdown-menu-content-available-height)}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-32{max-height:calc(var(--spacing) * 32)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-48{max-height:calc(var(--spacing) * 48)}.max-h-60{max-height:calc(var(--spacing) * 60)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-\[85vh\]{max-height:85vh}.max-h-\[90vh\]{max-height:90vh}.max-h-\[200px\]{max-height:200px}.max-h-\[400px\]{max-height:400px}.max-h-none{max-height:none}.max-h-screen{max-height:100vh}.\!min-h-0{min-height:0!important}.min-h-0{min-height:0}.min-h-16{min-height:calc(var(--spacing) * 16)}.min-h-\[36px\]{min-height:36px}.min-h-\[40px\]{min-height:40px}.min-h-\[50vh\]{min-height:50vh}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.\!w-2{width:calc(var(--spacing) * 2)!important}.w-1{width:var(--spacing)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-12{width:calc(var(--spacing) * 12)}.w-16{width:calc(var(--spacing) * 16)}.w-20{width:calc(var(--spacing) * 20)}.w-56{width:calc(var(--spacing) * 56)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-\[1\.2rem\]{width:1.2rem}.w-\[1px\]{width:1px}.w-\[28rem\]{width:28rem}.w-\[90vw\]{width:90vw}.w-\[600px\]{width:600px}.w-\[800px\]{width:800px}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[80\%\]{max-width:80%}.max-w-\[90vw\]{max-width:90vw}.max-w-\[200px\]{max-width:200px}.max-w-full{max-width:100%}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.\!min-w-0{min-width:0!important}.min-w-0{min-width:0}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[8rem\]{min-width:8rem}.min-w-\[50px\]{min-width:50px}.min-w-\[80px\]{min-width:80px}.min-w-\[300px\]{min-width:300px}.min-w-\[400px\]{min-width:400px}.min-w-\[800px\]{min-width:800px}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.min-w-full{min-width:100%}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.origin-\(--radix-dropdown-menu-content-transform-origin\){transform-origin:var(--radix-dropdown-menu-content-transform-origin)}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.origin-bottom{transform-origin:bottom}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-0{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-x-4{--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-0{--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-75{--tw-scale-x:75%;--tw-scale-y:75%;--tw-scale-z:75%;scale:var(--tw-scale-x) var(--tw-scale-y)}.scale-100{--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.rotate-0{rotate:0deg}.rotate-90{rotate:90deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-bounce{animation:var(--animate-bounce)}.animate-in{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-col-resize{cursor:col-resize}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.resize{resize:both}.resize-none{resize:none}.scroll-my-1{scroll-margin-block:var(--spacing)}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.list-none{list-style-type:none}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-\[auto_auto_1fr_auto\]{grid-template-columns:auto auto 1fr auto}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-row-reverse{flex-direction:row-reverse}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:0}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(var(--spacing) * var(--tw-space-x-reverse));margin-inline-end:calc(var(--spacing) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.gap-y-1{row-gap:var(--spacing)}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.\!rounded-full{border-radius:3.40282e38px!important}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}.rounded-r-none{border-top-right-radius:0;border-bottom-right-radius:0}.\!border{border-style:var(--tw-border-style)!important;border-width:1px!important}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-0{border-left-style:var(--tw-border-style);border-left-width:0}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-l-4{border-left-style:var(--tw-border-style);border-left-width:4px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.\!border-gray-600{border-color:var(--color-gray-600)!important}.border-\[\#643FB2\]{border-color:#643fb2}.border-\[\#643FB2\]\/20{border-color:oklab(47.4316% .069152 -.159147/.2)}.border-\[\#643FB2\]\/30{border-color:oklab(47.4316% .069152 -.159147/.3)}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-300{border-color:var(--color-blue-300)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-500{border-color:var(--color-blue-500)}.border-border,.border-border\/50{border-color:var(--border)}@supports (color:color-mix(in lab, red, red)){.border-border\/50{border-color:color-mix(in oklab, var(--border) 50%, transparent)}}.border-current\/30{border-color:currentColor}@supports (color:color-mix(in lab, red, red)){.border-current\/30{border-color:color-mix(in oklab, currentcolor 30%, transparent)}}.border-destructive\/30{border-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.border-destructive\/30{border-color:color-mix(in oklab, var(--destructive) 30%, transparent)}}.border-foreground\/5{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/5{border-color:color-mix(in oklab, var(--foreground) 5%, transparent)}}.border-foreground\/10{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/10{border-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.border-foreground\/20{border-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.border-foreground\/20{border-color:color-mix(in oklab, var(--foreground) 20%, transparent)}}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-500\/20{border-color:#6a728233}@supports (color:color-mix(in lab, red, red)){.border-gray-500\/20{border-color:color-mix(in oklab, var(--color-gray-500) 20%, transparent)}}.border-green-200{border-color:var(--color-green-200)}.border-green-500{border-color:var(--color-green-500)}.border-green-500\/20{border-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.border-green-500\/20{border-color:color-mix(in oklab, var(--color-green-500) 20%, transparent)}}.border-green-500\/40{border-color:#00c75866}@supports (color:color-mix(in lab, red, red)){.border-green-500\/40{border-color:color-mix(in oklab, var(--color-green-500) 40%, transparent)}}.border-green-600\/20{border-color:#00a54433}@supports (color:color-mix(in lab, red, red)){.border-green-600\/20{border-color:color-mix(in oklab, var(--color-green-600) 20%, transparent)}}.border-input{border-color:var(--input)}.border-muted,.border-muted\/50{border-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.border-muted\/50{border-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.border-orange-200{border-color:var(--color-orange-200)}.border-orange-300{border-color:var(--color-orange-300)}.border-orange-500{border-color:var(--color-orange-500)}.border-orange-500\/20{border-color:#fe6e0033}@supports (color:color-mix(in lab, red, red)){.border-orange-500\/20{border-color:color-mix(in oklab, var(--color-orange-500) 20%, transparent)}}.border-orange-500\/40{border-color:#fe6e0066}@supports (color:color-mix(in lab, red, red)){.border-orange-500\/40{border-color:color-mix(in oklab, var(--color-orange-500) 40%, transparent)}}.border-orange-600\/20{border-color:#f0510033}@supports (color:color-mix(in lab, red, red)){.border-orange-600\/20{border-color:color-mix(in oklab, var(--color-orange-600) 20%, transparent)}}.border-primary,.border-primary\/20{border-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.border-primary\/20{border-color:color-mix(in oklab, var(--primary) 20%, transparent)}}.border-red-200{border-color:var(--color-red-200)}.border-red-500{border-color:var(--color-red-500)}.border-red-500\/20{border-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.border-red-500\/20{border-color:color-mix(in oklab, var(--color-red-500) 20%, transparent)}}.border-transparent{border-color:#0000}.border-yellow-200{border-color:var(--color-yellow-200)}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#643FB2\]{background-color:#643fb2}.bg-\[\#643FB2\]\/10{background-color:oklab(47.4316% .069152 -.159147/.1)}.bg-accent\/10{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/10{background-color:color-mix(in oklab, var(--accent) 10%, transparent)}}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-background,.bg-background\/50{background-color:var(--background)}@supports (color:color-mix(in lab, red, red)){.bg-background\/50{background-color:color-mix(in oklab, var(--background) 50%, transparent)}}.bg-black{background-color:var(--color-black)}.bg-black\/50{background-color:#00000080}@supports (color:color-mix(in lab, red, red)){.bg-black\/50{background-color:color-mix(in oklab, var(--color-black) 50%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-50\/80{background-color:#eff6ffcc}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/80{background-color:color-mix(in oklab, var(--color-blue-50) 80%, transparent)}}.bg-blue-50\/95{background-color:#eff6fff2}@supports (color:color-mix(in lab, red, red)){.bg-blue-50\/95{background-color:color-mix(in oklab, var(--color-blue-50) 95%, transparent)}}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-blue-500\/5{background-color:#3080ff0d}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/5{background-color:color-mix(in oklab, var(--color-blue-500) 5%, transparent)}}.bg-blue-500\/10{background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.bg-blue-500\/10{background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.bg-blue-600{background-color:var(--color-blue-600)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive,.bg-destructive\/10{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.bg-destructive\/10{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-foreground\/5{background-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.bg-foreground\/5{background-color:color-mix(in oklab, var(--foreground) 5%, transparent)}}.bg-foreground\/10{background-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.bg-foreground\/10{background-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-500\/10{background-color:#6a72821a}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/10{background-color:color-mix(in oklab, var(--color-gray-500) 10%, transparent)}}.bg-gray-900\/90{background-color:#101828e6}@supports (color:color-mix(in lab, red, red)){.bg-gray-900\/90{background-color:color-mix(in oklab, var(--color-gray-900) 90%, transparent)}}.bg-green-50{background-color:var(--color-green-50)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-500\/5{background-color:#00c7580d}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/5{background-color:color-mix(in oklab, var(--color-green-500) 5%, transparent)}}.bg-green-500\/10{background-color:#00c7581a}@supports (color:color-mix(in lab, red, red)){.bg-green-500\/10{background-color:color-mix(in oklab, var(--color-green-500) 10%, transparent)}}.bg-muted{background-color:var(--muted)}.bg-muted-foreground\/20{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.bg-muted-foreground\/20{background-color:color-mix(in oklab, var(--muted-foreground) 20%, transparent)}}.bg-muted-foreground\/30{background-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.bg-muted-foreground\/30{background-color:color-mix(in oklab, var(--muted-foreground) 30%, transparent)}}.bg-muted\/30{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/30{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.bg-muted\/50{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/50{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-orange-50\/50{background-color:#fff7ed80}@supports (color:color-mix(in lab, red, red)){.bg-orange-50\/50{background-color:color-mix(in oklab, var(--color-orange-50) 50%, transparent)}}.bg-orange-100{background-color:var(--color-orange-100)}.bg-orange-100\/50{background-color:#ffedd580}@supports (color:color-mix(in lab, red, red)){.bg-orange-100\/50{background-color:color-mix(in oklab, var(--color-orange-100) 50%, transparent)}}.bg-orange-500{background-color:var(--color-orange-500)}.bg-orange-500\/5{background-color:#fe6e000d}@supports (color:color-mix(in lab, red, red)){.bg-orange-500\/5{background-color:color-mix(in oklab, var(--color-orange-500) 5%, transparent)}}.bg-orange-500\/10{background-color:#fe6e001a}@supports (color:color-mix(in lab, red, red)){.bg-orange-500\/10{background-color:color-mix(in oklab, var(--color-orange-500) 10%, transparent)}}.bg-popover{background-color:var(--popover)}.bg-primary,.bg-primary\/10{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--primary) 10%, transparent)}}.bg-primary\/30{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.bg-primary\/30{background-color:color-mix(in oklab, var(--primary) 30%, transparent)}}.bg-primary\/40{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.bg-primary\/40{background-color:color-mix(in oklab, var(--primary) 40%, transparent)}}.bg-purple-50{background-color:var(--color-purple-50)}.bg-purple-100{background-color:var(--color-purple-100)}.bg-purple-500{background-color:var(--color-purple-500)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-500\/10{background-color:#fb2c361a}@supports (color:color-mix(in lab, red, red)){.bg-red-500\/10{background-color:color-mix(in oklab, var(--color-red-500) 10%, transparent)}}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-white\/60{background-color:#fff9}@supports (color:color-mix(in lab, red, red)){.bg-white\/60{background-color:color-mix(in oklab, var(--color-white) 60%, transparent)}}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.bg-yellow-100{background-color:var(--color-yellow-100)}.fill-current{fill:currentColor}.object-cover{object-fit:cover}.p-0{padding:0}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-\[1px\]{padding:1px}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0{padding-block:0}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.pt-0{padding-top:0}.pt-1{padding-top:var(--spacing)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-8{padding-top:calc(var(--spacing) * 8)}.pt-9{padding-top:calc(var(--spacing) * 9)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-8{padding-left:calc(var(--spacing) * 8)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-\[\#643FB2\]{color:#643fb2}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-600\/80{color:#dd7400cc}@supports (color:color-mix(in lab, red, red)){.text-amber-600\/80{color:color-mix(in oklab, var(--color-amber-600) 80%, transparent)}}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-blue-500{color:var(--color-blue-500)}.text-blue-500\/80{color:#3080ffcc}@supports (color:color-mix(in lab, red, red)){.text-blue-500\/80{color:color-mix(in oklab, var(--color-blue-500) 80%, transparent)}}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-destructive,.text-destructive\/70{color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.text-destructive\/70{color:color-mix(in oklab, var(--destructive) 70%, transparent)}}.text-destructive\/90{color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.text-destructive\/90{color:color-mix(in oklab, var(--destructive) 90%, transparent)}}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-green-800{color:var(--color-green-800)}.text-green-900{color:var(--color-green-900)}.text-muted-foreground,.text-muted-foreground\/60{color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/60{color:color-mix(in oklab, var(--muted-foreground) 60%, transparent)}}.text-muted-foreground\/70{color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/70{color:color-mix(in oklab, var(--muted-foreground) 70%, transparent)}}.text-muted-foreground\/80{color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.text-muted-foreground\/80{color:color-mix(in oklab, var(--muted-foreground) 80%, transparent)}}.text-orange-500{color:var(--color-orange-500)}.text-orange-600{color:var(--color-orange-600)}.text-orange-700{color:var(--color-orange-700)}.text-orange-800{color:var(--color-orange-800)}.text-orange-900{color:var(--color-orange-900)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-purple-500{color:var(--color-purple-500)}.text-purple-600{color:var(--color-purple-600)}.text-purple-800{color:var(--color-purple-800)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.text-yellow-600{color:var(--color-yellow-600)}.text-yellow-700{color:var(--color-yellow-700)}.capitalize{text-transform:capitalize}.lowercase{text-transform:lowercase}.uppercase{text-transform:uppercase}.italic{font-style:italic}.underline-offset-4{text-underline-offset:4px}.opacity-0{opacity:0}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-100{opacity:1}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xs{--tw-shadow:0 1px 2px 0 var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-0{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[\#643FB2\]\/20{--tw-shadow-color:#643fb233}@supports (color:color-mix(in lab, red, red)){.shadow-\[\#643FB2\]\/20{--tw-shadow-color:color-mix(in oklab, oklab(47.4316% .069152 -.159147/.2) var(--tw-shadow-alpha), transparent)}}.shadow-green-500\/20{--tw-shadow-color:#00c75833}@supports (color:color-mix(in lab, red, red)){.shadow-green-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-green-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-orange-500\/20{--tw-shadow-color:#fe6e0033}@supports (color:color-mix(in lab, red, red)){.shadow-orange-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-orange-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-primary\/25{--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.shadow-primary\/25{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--primary) 25%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-red-500\/20{--tw-shadow-color:#fb2c3633}@supports (color:color-mix(in lab, red, red)){.shadow-red-500\/20{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-blue-500{--tw-ring-color:var(--color-blue-500)}.ring-blue-500\/20{--tw-ring-color:#3080ff33}@supports (color:color-mix(in lab, red, red)){.ring-blue-500\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-500) 20%, transparent)}}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-background{--tw-ring-offset-color:var(--background)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.drop-shadow-lg{--tw-drop-shadow-size:drop-shadow(0 4px 4px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-lg));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-none{transition-property:none}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.fade-in-0{--tw-enter-opacity:0}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.zoom-in-95{--tw-enter-scale:.95}.\[animation-delay\:-0\.3s\]{animation-delay:-.3s}.\[animation-delay\:-0\.15s\]{animation-delay:-.15s}.fade-in{--tw-enter-opacity:0}.running{animation-play-state:running}.slide-in-from-bottom-2{--tw-enter-translate-y:calc(2*var(--spacing))}.group-open\:rotate-90:is(:where(.group):is([open],:popover-open,:open) *){rotate:90deg}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media (hover:hover){.group-hover\:bg-primary:is(:where(.group):hover *){background-color:var(--primary)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}.group-hover\:shadow-md:is(:where(.group):hover *){--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.group-hover\:shadow-primary\/20:is(:where(.group):hover *){--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--primary) 20%, transparent) var(--tw-shadow-alpha), transparent)}}}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.selection\:bg-primary ::selection{background-color:var(--primary)}.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection{color:var(--primary-foreground)}.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.first\:mt-0:first-child{margin-top:0}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:border-r-0:last-child{border-right-style:var(--tw-border-style);border-right-width:0}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (hover:hover){.hover\:scale-y-\[1\.15\]:hover{--tw-scale-y:1.15;scale:var(--tw-scale-x) var(--tw-scale-y)}.hover\:border-gray-300:hover{border-color:var(--color-gray-300)}.hover\:border-muted-foreground\/30:hover{border-color:var(--muted-foreground)}@supports (color:color-mix(in lab, red, red)){.hover\:border-muted-foreground\/30:hover{border-color:color-mix(in oklab, var(--muted-foreground) 30%, transparent)}}.hover\:bg-accent:hover,.hover\:bg-accent\/50:hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/50:hover{background-color:color-mix(in oklab, var(--accent) 50%, transparent)}}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}.hover\:bg-destructive\/80:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/80:hover{background-color:color-mix(in oklab, var(--destructive) 80%, transparent)}}.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab, var(--destructive) 90%, transparent)}}.hover\:bg-muted:hover,.hover\:bg-muted\/30:hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-muted\/30:hover{background-color:color-mix(in oklab, var(--muted) 30%, transparent)}}.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab, var(--muted) 50%, transparent)}}.hover\:bg-muted\/70:hover{background-color:var(--muted)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-muted\/70:hover{background-color:color-mix(in oklab, var(--muted) 70%, transparent)}}.hover\:bg-orange-100:hover{background-color:var(--color-orange-100)}.hover\:bg-primary\/20:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/20:hover{background-color:color-mix(in oklab, var(--primary) 20%, transparent)}}.hover\:bg-primary\/80:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/80:hover{background-color:color-mix(in oklab, var(--primary) 80%, transparent)}}.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab, var(--primary) 90%, transparent)}}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab, var(--secondary) 80%, transparent)}}.hover\:bg-white:hover{background-color:var(--color-white)}.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}.hover\:text-destructive\/80:hover{color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.hover\:text-destructive\/80:hover{color:color-mix(in oklab, var(--destructive) 80%, transparent)}}.hover\:text-foreground:hover{color:var(--foreground)}.hover\:text-orange-900:hover{color:var(--color-orange-900)}.hover\:text-primary:hover{color:var(--primary)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-70:hover{opacity:.7}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:brightness-110:hover{--tw-brightness:brightness(110%);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-ring:focus{--tw-ring-color:var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-1:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring:focus-visible,.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:var(--ring)}@supports (color:color-mix(in lab, red, red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color:color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color:var(--background)}.focus-visible\:outline-none:focus-visible{--tw-outline-style:none;outline-style:none}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[inset\]\:pl-8[data-inset]{padding-left:calc(var(--spacing) * 8)}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y:calc(2*var(--spacing)*-1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x:calc(2*var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x:var(--spacing);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x:calc(2*var(--spacing)*-1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y:calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y:calc(2*var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:var(--background)}.data-\[state\=active\]\:text-foreground[data-state=active]{color:var(--foreground)}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x:calc(var(--spacing) * 4);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity:0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale:.95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-animation-duration,var(--tw-duration,.15s))var(--tw-ease,ease)var(--tw-animation-delay,0s)var(--tw-animation-iteration-count,1)var(--tw-animation-direction,normal)var(--tw-animation-fill-mode,none)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-accent-foreground[data-state=open]{color:var(--accent-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity:0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale:.95}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x:0;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:var(--input)}.data-\[variant\=destructive\]\:text-destructive[data-variant=destructive]{color:var(--destructive)}.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.data-\[variant\=destructive\]\:focus\:bg-destructive\/10[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 10%, transparent)}}.data-\[variant\=destructive\]\:focus\:text-destructive[data-variant=destructive]:focus{color:var(--destructive)}@media (width>=40rem){.sm\:w-64{width:calc(var(--spacing) * 64)}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:flex-none{flex:none}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}}@media (width>=48rem){.md\:col-span-2{grid-column:span 2/span 2}.md\:col-start-2{grid-column-start:2}.md\:inline{display:inline}.md\:max-w-2xl{max-width:var(--container-2xl)}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}}@media (width>=64rem){.lg\:col-span-3{grid-column:span 3/span 3}.lg\:max-w-4xl{max-width:var(--container-4xl)}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}}@media (width>=80rem){.xl\:col-span-2{grid-column:span 2/span 2}.xl\:col-span-4{grid-column:span 4/span 4}.xl\:max-w-5xl{max-width:var(--container-5xl)}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:scale-0:is(.dark *){--tw-scale-x:0%;--tw-scale-y:0%;--tw-scale-z:0%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:scale-100:is(.dark *){--tw-scale-x:100%;--tw-scale-y:100%;--tw-scale-z:100%;scale:var(--tw-scale-x) var(--tw-scale-y)}.dark\:-rotate-90:is(.dark *){rotate:-90deg}.dark\:rotate-0:is(.dark *){rotate:0deg}.dark\:\!border-gray-500:is(.dark *){border-color:var(--color-gray-500)!important}.dark\:\!border-gray-600:is(.dark *){border-color:var(--color-gray-600)!important}.dark\:border-\[\#8B5CF6\]:is(.dark *){border-color:#8b5cf6}.dark\:border-\[\#8B5CF6\]\/20:is(.dark *){border-color:oklab(60.5631% .0845415 -.201932/.2)}.dark\:border-\[\#8B5CF6\]\/30:is(.dark *){border-color:oklab(60.5631% .0845415 -.201932/.3)}.dark\:border-amber-800:is(.dark *){border-color:var(--color-amber-800)}.dark\:border-amber-900:is(.dark *){border-color:var(--color-amber-900)}.dark\:border-blue-400:is(.dark *){border-color:var(--color-blue-400)}.dark\:border-blue-500:is(.dark *){border-color:var(--color-blue-500)}.dark\:border-blue-700:is(.dark *){border-color:var(--color-blue-700)}.dark\:border-blue-800:is(.dark *){border-color:var(--color-blue-800)}.dark\:border-gray-500:is(.dark *){border-color:var(--color-gray-500)}.dark\:border-gray-600:is(.dark *){border-color:var(--color-gray-600)}.dark\:border-gray-700:is(.dark *){border-color:var(--color-gray-700)}.dark\:border-green-400:is(.dark *){border-color:var(--color-green-400)}.dark\:border-green-800:is(.dark *){border-color:var(--color-green-800)}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:border-orange-400:is(.dark *){border-color:var(--color-orange-400)}.dark\:border-orange-700:is(.dark *){border-color:var(--color-orange-700)}.dark\:border-orange-800:is(.dark *){border-color:var(--color-orange-800)}.dark\:border-red-400:is(.dark *){border-color:var(--color-red-400)}.dark\:border-red-800:is(.dark *){border-color:var(--color-red-800)}.dark\:\!bg-gray-800\/90:is(.dark *){background-color:#1e2939e6!important}@supports (color:color-mix(in lab, red, red)){.dark\:\!bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab, var(--color-gray-800) 90%, transparent)!important}}.dark\:bg-\[\#8B5CF6\]:is(.dark *){background-color:#8b5cf6}.dark\:bg-\[\#8B5CF6\]\/10:is(.dark *){background-color:oklab(60.5631% .0845415 -.201932/.1)}.dark\:bg-amber-600:is(.dark *){background-color:var(--color-amber-600)}.dark\:bg-amber-950\/20:is(.dark *){background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/20:is(.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.dark\:bg-amber-950\/50:is(.dark *){background-color:#46190180}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-950\/50:is(.dark *){background-color:color-mix(in oklab, var(--color-amber-950) 50%, transparent)}}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3080ff1a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-500\/10:is(.dark *){background-color:color-mix(in oklab, var(--color-blue-500) 10%, transparent)}}.dark\:bg-blue-600:is(.dark *){background-color:var(--color-blue-600)}.dark\:bg-blue-900:is(.dark *){background-color:var(--color-blue-900)}.dark\:bg-blue-900\/20:is(.dark *){background-color:#1c398e33}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-900\/20:is(.dark *){background-color:color-mix(in oklab, var(--color-blue-900) 20%, transparent)}}.dark\:bg-blue-950\/20:is(.dark *){background-color:#16245633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/20:is(.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 20%, transparent)}}.dark\:bg-blue-950\/40:is(.dark *){background-color:#16245666}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/40:is(.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 40%, transparent)}}.dark\:bg-blue-950\/50:is(.dark *){background-color:#16245680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/50:is(.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 50%, transparent)}}.dark\:bg-blue-950\/95:is(.dark *){background-color:#162456f2}@supports (color:color-mix(in lab, red, red)){.dark\:bg-blue-950\/95:is(.dark *){background-color:color-mix(in oklab, var(--color-blue-950) 95%, transparent)}}.dark\:bg-card:is(.dark *){background-color:var(--card)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab, var(--destructive) 60%, transparent)}}.dark\:bg-emerald-600:is(.dark *){background-color:var(--color-emerald-600)}.dark\:bg-foreground\/10:is(.dark *){background-color:var(--foreground)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-foreground\/10:is(.dark *){background-color:color-mix(in oklab, var(--foreground) 10%, transparent)}}.dark\:bg-gray-500:is(.dark *){background-color:var(--color-gray-500)}.dark\:bg-gray-800:is(.dark *){background-color:var(--color-gray-800)}.dark\:bg-gray-800\/90:is(.dark *){background-color:#1e2939e6}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-800\/90:is(.dark *){background-color:color-mix(in oklab, var(--color-gray-800) 90%, transparent)}}.dark\:bg-gray-900:is(.dark *){background-color:var(--color-gray-900)}.dark\:bg-gray-900\/30:is(.dark *){background-color:#1018284d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-gray-900\/30:is(.dark *){background-color:color-mix(in oklab, var(--color-gray-900) 30%, transparent)}}.dark\:bg-green-400:is(.dark *){background-color:var(--color-green-400)}.dark\:bg-green-500\/10:is(.dark *){background-color:#00c7581a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-500\/10:is(.dark *){background-color:color-mix(in oklab, var(--color-green-500) 10%, transparent)}}.dark\:bg-green-900:is(.dark *){background-color:var(--color-green-900)}.dark\:bg-green-950:is(.dark *){background-color:var(--color-green-950)}.dark\:bg-green-950\/20:is(.dark *){background-color:#032e1533}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-950\/20:is(.dark *){background-color:color-mix(in oklab, var(--color-green-950) 20%, transparent)}}.dark\:bg-green-950\/50:is(.dark *){background-color:#032e1580}@supports (color:color-mix(in lab, red, red)){.dark\:bg-green-950\/50:is(.dark *){background-color:color-mix(in oklab, var(--color-green-950) 50%, transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab, var(--input) 30%, transparent)}}.dark\:bg-orange-400:is(.dark *){background-color:var(--color-orange-400)}.dark\:bg-orange-500\/10:is(.dark *){background-color:#fe6e001a}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-500\/10:is(.dark *){background-color:color-mix(in oklab, var(--color-orange-500) 10%, transparent)}}.dark\:bg-orange-600:is(.dark *){background-color:var(--color-orange-600)}.dark\:bg-orange-900:is(.dark *){background-color:var(--color-orange-900)}.dark\:bg-orange-950:is(.dark *){background-color:var(--color-orange-950)}.dark\:bg-orange-950\/20:is(.dark *){background-color:#44130633}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-950\/20:is(.dark *){background-color:color-mix(in oklab, var(--color-orange-950) 20%, transparent)}}.dark\:bg-orange-950\/30:is(.dark *){background-color:#4413064d}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-950\/30:is(.dark *){background-color:color-mix(in oklab, var(--color-orange-950) 30%, transparent)}}.dark\:bg-orange-950\/50:is(.dark *){background-color:#44130680}@supports (color:color-mix(in lab, red, red)){.dark\:bg-orange-950\/50:is(.dark *){background-color:color-mix(in oklab, var(--color-orange-950) 50%, transparent)}}.dark\:bg-purple-600:is(.dark *){background-color:var(--color-purple-600)}.dark\:bg-purple-900:is(.dark *){background-color:var(--color-purple-900)}.dark\:bg-red-400:is(.dark *){background-color:var(--color-red-400)}.dark\:bg-red-900:is(.dark *){background-color:var(--color-red-900)}.dark\:bg-red-950:is(.dark *){background-color:var(--color-red-950)}.dark\:bg-red-950\/20:is(.dark *){background-color:#46080933}@supports (color:color-mix(in lab, red, red)){.dark\:bg-red-950\/20:is(.dark *){background-color:color-mix(in oklab, var(--color-red-950) 20%, transparent)}}.dark\:text-\[\#8B5CF6\]:is(.dark *){color:#8b5cf6}.dark\:text-amber-100:is(.dark *){color:var(--color-amber-100)}.dark\:text-amber-200:is(.dark *){color:var(--color-amber-200)}.dark\:text-amber-300:is(.dark *){color:var(--color-amber-300)}.dark\:text-amber-400:is(.dark *){color:var(--color-amber-400)}.dark\:text-amber-400\/80:is(.dark *){color:#fcbb00cc}@supports (color:color-mix(in lab, red, red)){.dark\:text-amber-400\/80:is(.dark *){color:color-mix(in oklab, var(--color-amber-400) 80%, transparent)}}.dark\:text-amber-500:is(.dark *){color:var(--color-amber-500)}.dark\:text-blue-100:is(.dark *){color:var(--color-blue-100)}.dark\:text-blue-200:is(.dark *){color:var(--color-blue-200)}.dark\:text-blue-300:is(.dark *){color:var(--color-blue-300)}.dark\:text-blue-400:is(.dark *){color:var(--color-blue-400)}.dark\:text-blue-400\/70:is(.dark *){color:#54a2ffb3}@supports (color:color-mix(in lab, red, red)){.dark\:text-blue-400\/70:is(.dark *){color:color-mix(in oklab, var(--color-blue-400) 70%, transparent)}}.dark\:text-blue-500:is(.dark *){color:var(--color-blue-500)}.dark\:text-emerald-400:is(.dark *){color:var(--color-emerald-400)}.dark\:text-gray-100:is(.dark *){color:var(--color-gray-100)}.dark\:text-gray-300:is(.dark *){color:var(--color-gray-300)}.dark\:text-gray-400:is(.dark *){color:var(--color-gray-400)}.dark\:text-green-100:is(.dark *){color:var(--color-green-100)}.dark\:text-green-200:is(.dark *){color:var(--color-green-200)}.dark\:text-green-300:is(.dark *){color:var(--color-green-300)}.dark\:text-green-400:is(.dark *){color:var(--color-green-400)}.dark\:text-orange-100:is(.dark *){color:var(--color-orange-100)}.dark\:text-orange-200:is(.dark *){color:var(--color-orange-200)}.dark\:text-orange-300:is(.dark *){color:var(--color-orange-300)}.dark\:text-orange-400:is(.dark *){color:var(--color-orange-400)}.dark\:text-purple-200:is(.dark *){color:var(--color-purple-200)}.dark\:text-purple-400:is(.dark *){color:var(--color-purple-400)}.dark\:text-red-200:is(.dark *){color:var(--color-red-200)}.dark\:text-red-400:is(.dark *){color:var(--color-red-400)}.dark\:text-yellow-400:is(.dark *){color:var(--color-yellow-400)}.dark\:opacity-30:is(.dark *){opacity:.3}@media (hover:hover){.dark\:hover\:border-gray-600:is(.dark *):hover{border-color:var(--color-gray-600)}.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab, var(--accent) 50%, transparent)}}.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:#4619014d}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-amber-950\/30:is(.dark *):hover{background-color:color-mix(in oklab, var(--color-amber-950) 30%, transparent)}}.dark\:hover\:bg-gray-800:is(.dark *):hover{background-color:var(--color-gray-800)}.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab, var(--input) 50%, transparent)}}.dark\:hover\:bg-orange-950\/40:is(.dark *):hover{background-color:#44130666}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-orange-950\/40:is(.dark *):hover{background-color:color-mix(in oklab, var(--color-orange-950) 40%, transparent)}}.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:#82181a33}@supports (color:color-mix(in lab, red, red)){.dark\:hover\:bg-red-900\/20:is(.dark *):hover{background-color:color-mix(in oklab, var(--color-red-900) 20%, transparent)}}.dark\:hover\:text-orange-200:is(.dark *):hover{color:var(--color-orange-200)}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color:color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:var(--destructive)}@supports (color:color-mix(in lab, red, red)){.dark\:data-\[variant\=destructive\]\:focus\:bg-destructive\/20:is(.dark *)[data-variant=destructive]:focus{background-color:color-mix(in oklab, var(--destructive) 20%, transparent)}}.\[\&_p\]\:leading-relaxed p{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}:is(.data-\[variant\=destructive\]\:\*\:\[svg\]\:\!text-destructive[data-variant=destructive]>*):is(svg){color:var(--destructive)!important}.\[\&\>svg\]\:absolute>svg{position:absolute}.\[\&\>svg\]\:top-4>svg{top:calc(var(--spacing) * 4)}.\[\&\>svg\]\:left-4>svg{left:calc(var(--spacing) * 4)}.\[\&\>svg\]\:text-foreground>svg{color:var(--foreground)}.\[\&\>svg\+div\]\:translate-y-\[-3px\]>svg+div{--tw-translate-y:-3px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>svg\~\*\]\:pl-7>svg~*{padding-left:calc(var(--spacing) * 7)}}@property --tw-animation-delay{syntax:"*";inherits:false;initial-value:0s}@property --tw-animation-direction{syntax:"*";inherits:false;initial-value:normal}@property --tw-animation-duration{syntax:"*";inherits:false}@property --tw-animation-fill-mode{syntax:"*";inherits:false;initial-value:none}@property --tw-animation-iteration-count{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-enter-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-enter-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-blur{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-opacity{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-rotate{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-scale{syntax:"*";inherits:false;initial-value:1}@property --tw-exit-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-exit-translate-y{syntax:"*";inherits:false;initial-value:0}:root{--radius:.625rem;--background:oklch(100% 0 0);--foreground:oklch(14.5% 0 0);--card:oklch(100% 0 0);--card-foreground:oklch(14.5% 0 0);--popover:oklch(100% 0 0);--popover-foreground:oklch(14.5% 0 0);--primary:oklch(48% .18 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(97% 0 0);--secondary-foreground:oklch(20.5% 0 0);--muted:oklch(97% 0 0);--muted-foreground:oklch(55.6% 0 0);--accent:oklch(97% 0 0);--accent-foreground:oklch(20.5% 0 0);--destructive:oklch(57.7% .245 27.325);--border:oklch(92.2% 0 0);--input:oklch(92.2% 0 0);--ring:oklch(70.8% 0 0);--chart-1:oklch(64.6% .222 41.116);--chart-2:oklch(60% .118 184.704);--chart-3:oklch(39.8% .07 227.392);--chart-4:oklch(82.8% .189 84.429);--chart-5:oklch(76.9% .188 70.08);--sidebar:oklch(98.5% 0 0);--sidebar-foreground:oklch(14.5% 0 0);--sidebar-primary:oklch(20.5% 0 0);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(97% 0 0);--sidebar-accent-foreground:oklch(20.5% 0 0);--sidebar-border:oklch(92.2% 0 0);--sidebar-ring:oklch(70.8% 0 0)}.dark{--background:oklch(14.5% 0 0);--foreground:oklch(98.5% 0 0);--card:oklch(20.5% 0 0);--card-foreground:oklch(98.5% 0 0);--popover:oklch(20.5% 0 0);--popover-foreground:oklch(98.5% 0 0);--primary:oklch(62% .2 290);--primary-foreground:oklch(98.5% 0 0);--secondary:oklch(26.9% 0 0);--secondary-foreground:oklch(98.5% 0 0);--muted:oklch(26.9% 0 0);--muted-foreground:oklch(70.8% 0 0);--accent:oklch(26.9% 0 0);--accent-foreground:oklch(98.5% 0 0);--destructive:oklch(70.4% .191 22.216);--border:oklch(100% 0 0/.1);--input:oklch(100% 0 0/.15);--ring:oklch(55.6% 0 0);--chart-1:oklch(48.8% .243 264.376);--chart-2:oklch(69.6% .17 162.48);--chart-3:oklch(76.9% .188 70.08);--chart-4:oklch(62.7% .265 303.9);--chart-5:oklch(64.5% .246 16.439);--sidebar:oklch(20.5% 0 0);--sidebar-foreground:oklch(98.5% 0 0);--sidebar-primary:oklch(48.8% .243 264.376);--sidebar-primary-foreground:oklch(98.5% 0 0);--sidebar-accent:oklch(26.9% 0 0);--sidebar-accent-foreground:oklch(98.5% 0 0);--sidebar-border:oklch(100% 0 0/.1);--sidebar-ring:oklch(55.6% 0 0)}.workflow-chat-view .border-green-200{border-color:var(--color-emerald-200)}.workflow-chat-view .bg-green-50{background-color:var(--color-emerald-50)}.workflow-chat-view .bg-green-100{background-color:var(--color-emerald-100)}.workflow-chat-view .text-green-600{color:var(--color-emerald-600)}.workflow-chat-view .text-green-700{color:var(--color-emerald-700)}.workflow-chat-view .text-green-800{color:var(--color-emerald-800)}.highlight-attention{animation:1s ease-out highlight-flash}@keyframes highlight-flash{0%{background-color:#fb923c4d;transform:scale(1.02)}to{background-color:#0000;transform:scale(1)}}.hil-waiting-glow{animation:2s infinite pulse-glow;box-shadow:0 0 #fb923c66,inset 0 0 0 1px #fb923c33}@keyframes pulse-glow{0%,to{box-shadow:0 0 #fb923c66,inset 0 0 0 1px #fb923c33}50%{box-shadow:0 0 20px 5px #fb923c33,inset 0 0 0 2px #fb923c4d}}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}}@keyframes bounce{0%,to{animation-timing-function:cubic-bezier(.8,0,1,1);transform:translateY(-25%)}50%{animation-timing-function:cubic-bezier(0,0,.2,1);transform:none}}@keyframes enter{0%{opacity:var(--tw-enter-opacity,1);transform:translate3d(var(--tw-enter-translate-x,0),var(--tw-enter-translate-y,0),0)scale3d(var(--tw-enter-scale,1),var(--tw-enter-scale,1),var(--tw-enter-scale,1))rotate(var(--tw-enter-rotate,0));filter:blur(var(--tw-enter-blur,0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity,1);transform:translate3d(var(--tw-exit-translate-x,0),var(--tw-exit-translate-y,0),0)scale3d(var(--tw-exit-scale,1),var(--tw-exit-scale,1),var(--tw-exit-scale,1))rotate(var(--tw-exit-rotate,0));filter:blur(var(--tw-exit-blur,0))}}.react-flow{--xy-edge-stroke-default:#b1b1b7;--xy-edge-stroke-width-default:1;--xy-edge-stroke-selected-default:#555;--xy-connectionline-stroke-default:#b1b1b7;--xy-connectionline-stroke-width-default:1;--xy-attribution-background-color-default:#ffffff80;--xy-minimap-background-color-default:#fff;--xy-minimap-mask-background-color-default:#f0f0f099;--xy-minimap-mask-stroke-color-default:transparent;--xy-minimap-mask-stroke-width-default:1;--xy-minimap-node-background-color-default:#e2e2e2;--xy-minimap-node-stroke-color-default:transparent;--xy-minimap-node-stroke-width-default:2;--xy-background-color-default:transparent;--xy-background-pattern-dots-color-default:#91919a;--xy-background-pattern-lines-color-default:#eee;--xy-background-pattern-cross-color-default:#e2e2e2;background-color:var(--xy-background-color,var(--xy-background-color-default));--xy-node-color-default:inherit;--xy-node-border-default:1px solid #1a192b;--xy-node-background-color-default:#fff;--xy-node-group-background-color-default:#f0f0f040;--xy-node-boxshadow-hover-default:0 1px 4px 1px #00000014;--xy-node-boxshadow-selected-default:0 0 0 .5px #1a192b;--xy-node-border-radius-default:3px;--xy-handle-background-color-default:#1a192b;--xy-handle-border-color-default:#fff;--xy-selection-background-color-default:#0059dc14;--xy-selection-border-default:1px dotted #0059dccc;--xy-controls-button-background-color-default:#fefefe;--xy-controls-button-background-color-hover-default:#f4f4f4;--xy-controls-button-color-default:inherit;--xy-controls-button-color-hover-default:inherit;--xy-controls-button-border-color-default:#eee;--xy-controls-box-shadow-default:0 0 2px 1px #00000014;--xy-edge-label-background-color-default:#fff;--xy-edge-label-color-default:inherit;--xy-resize-background-color-default:#3367d9;direction:ltr}.react-flow.dark{--xy-edge-stroke-default:#3e3e3e;--xy-edge-stroke-width-default:1;--xy-edge-stroke-selected-default:#727272;--xy-connectionline-stroke-default:#b1b1b7;--xy-connectionline-stroke-width-default:1;--xy-attribution-background-color-default:#96969640;--xy-minimap-background-color-default:#141414;--xy-minimap-mask-background-color-default:#3c3c3c99;--xy-minimap-mask-stroke-color-default:transparent;--xy-minimap-mask-stroke-width-default:1;--xy-minimap-node-background-color-default:#2b2b2b;--xy-minimap-node-stroke-color-default:transparent;--xy-minimap-node-stroke-width-default:2;--xy-background-color-default:#141414;--xy-background-pattern-dots-color-default:#777;--xy-background-pattern-lines-color-default:#777;--xy-background-pattern-cross-color-default:#777;--xy-node-color-default:#f8f8f8;--xy-node-border-default:1px solid #3c3c3c;--xy-node-background-color-default:#1e1e1e;--xy-node-group-background-color-default:#f0f0f040;--xy-node-boxshadow-hover-default:0 1px 4px 1px #ffffff14;--xy-node-boxshadow-selected-default:0 0 0 .5px #999;--xy-handle-background-color-default:#bebebe;--xy-handle-border-color-default:#1e1e1e;--xy-selection-background-color-default:#c8c8dc14;--xy-selection-border-default:1px dotted #c8c8dccc;--xy-controls-button-background-color-default:#2b2b2b;--xy-controls-button-background-color-hover-default:#3e3e3e;--xy-controls-button-color-default:#f8f8f8;--xy-controls-button-color-hover-default:#fff;--xy-controls-button-border-color-default:#5b5b5b;--xy-controls-box-shadow-default:0 0 2px 1px #00000014;--xy-edge-label-background-color-default:#141414;--xy-edge-label-color-default:#f8f8f8}.react-flow__background{background-color:var(--xy-background-color-props,var(--xy-background-color,var(--xy-background-color-default)));pointer-events:none;z-index:-1}.react-flow__container{width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__pane{z-index:1}.react-flow__pane.draggable{cursor:grab}.react-flow__pane.dragging{cursor:grabbing}.react-flow__pane.selection{cursor:pointer}.react-flow__viewport{transform-origin:0 0;z-index:2;pointer-events:none}.react-flow__renderer{z-index:4}.react-flow__selection{z-index:6}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible{outline:none}.react-flow__edge-path{stroke:var(--xy-edge-stroke,var(--xy-edge-stroke-default));stroke-width:var(--xy-edge-stroke-width,var(--xy-edge-stroke-width-default));fill:none}.react-flow__connection-path{stroke:var(--xy-connectionline-stroke,var(--xy-connectionline-stroke-default));stroke-width:var(--xy-connectionline-stroke-width,var(--xy-connectionline-stroke-width-default));fill:none}.react-flow .react-flow__edges{position:absolute}.react-flow .react-flow__edges svg{pointer-events:none;position:absolute;overflow:visible}.react-flow__edge{pointer-events:visibleStroke}.react-flow__edge.selectable{cursor:pointer}.react-flow__edge.animated path{stroke-dasharray:5;animation:.5s linear infinite dashdraw}.react-flow__edge.animated path.react-flow__edge-interaction{stroke-dasharray:none;animation:none}.react-flow__edge.inactive{pointer-events:none}.react-flow__edge.selected,.react-flow__edge:focus,.react-flow__edge:focus-visible{outline:none}.react-flow__edge.selected .react-flow__edge-path,.react-flow__edge.selectable:focus .react-flow__edge-path,.react-flow__edge.selectable:focus-visible .react-flow__edge-path{stroke:var(--xy-edge-stroke-selected,var(--xy-edge-stroke-selected-default))}.react-flow__edge-textwrapper{pointer-events:all}.react-flow__edge .react-flow__edge-text{pointer-events:none;-webkit-user-select:none;user-select:none}.react-flow__arrowhead polyline{stroke:var(--xy-edge-stroke,var(--xy-edge-stroke-default))}.react-flow__arrowhead polyline.arrowclosed{fill:var(--xy-edge-stroke,var(--xy-edge-stroke-default))}.react-flow__connection{pointer-events:none}.react-flow__connection .animated{stroke-dasharray:5;animation:.5s linear infinite dashdraw}svg.react-flow__connectionline{z-index:1001;position:absolute;overflow:visible}.react-flow__nodes{pointer-events:none;transform-origin:0 0}.react-flow__node{-webkit-user-select:none;user-select:none;pointer-events:all;transform-origin:0 0;box-sizing:border-box;cursor:default;position:absolute}.react-flow__node.selectable{cursor:pointer}.react-flow__node.draggable{cursor:grab;pointer-events:all}.react-flow__node.draggable.dragging{cursor:grabbing}.react-flow__nodesselection{z-index:3;transform-origin:0 0;pointer-events:none}.react-flow__nodesselection-rect{pointer-events:all;cursor:grab;position:absolute}.react-flow__handle{pointer-events:none;background-color:var(--xy-handle-background-color,var(--xy-handle-background-color-default));border:1px solid var(--xy-handle-border-color,var(--xy-handle-border-color-default));border-radius:100%;width:6px;min-width:5px;height:6px;min-height:5px;position:absolute}.react-flow__handle.connectingfrom{pointer-events:all}.react-flow__handle.connectionindicator{pointer-events:all;cursor:crosshair}.react-flow__handle-bottom{top:auto;bottom:0;left:50%;transform:translate(-50%,50%)}.react-flow__handle-top{top:0;left:50%;transform:translate(-50%,-50%)}.react-flow__handle-left{top:50%;left:0;transform:translate(-50%,-50%)}.react-flow__handle-right{top:50%;right:0;transform:translate(50%,-50%)}.react-flow__edgeupdater{cursor:move;pointer-events:all}.react-flow__pane.selection .react-flow__panel{pointer-events:none}.react-flow__panel{z-index:5;margin:15px;position:absolute}.react-flow__panel.top{top:0}.react-flow__panel.bottom{bottom:0}.react-flow__panel.top.center,.react-flow__panel.bottom.center{left:50%;transform:translate(-15px)translate(-50%)}.react-flow__panel.left{left:0}.react-flow__panel.right{right:0}.react-flow__panel.left.center,.react-flow__panel.right.center{top:50%;transform:translateY(-15px)translateY(-50%)}.react-flow__attribution{background:var(--xy-attribution-background-color,var(--xy-attribution-background-color-default));margin:0;padding:2px 3px;font-size:10px}.react-flow__attribution a{color:#999;text-decoration:none}@keyframes dashdraw{0%{stroke-dashoffset:10px}}.react-flow__edgelabel-renderer{pointer-events:none;-webkit-user-select:none;user-select:none;width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__viewport-portal{-webkit-user-select:none;user-select:none;width:100%;height:100%;position:absolute;top:0;left:0}.react-flow__minimap{background:var(--xy-minimap-background-color-props,var(--xy-minimap-background-color,var(--xy-minimap-background-color-default)))}.react-flow__minimap-svg{display:block}.react-flow__minimap-mask{fill:var(--xy-minimap-mask-background-color-props,var(--xy-minimap-mask-background-color,var(--xy-minimap-mask-background-color-default)));stroke:var(--xy-minimap-mask-stroke-color-props,var(--xy-minimap-mask-stroke-color,var(--xy-minimap-mask-stroke-color-default)));stroke-width:var(--xy-minimap-mask-stroke-width-props,var(--xy-minimap-mask-stroke-width,var(--xy-minimap-mask-stroke-width-default)))}.react-flow__minimap-node{fill:var(--xy-minimap-node-background-color-props,var(--xy-minimap-node-background-color,var(--xy-minimap-node-background-color-default)));stroke:var(--xy-minimap-node-stroke-color-props,var(--xy-minimap-node-stroke-color,var(--xy-minimap-node-stroke-color-default)));stroke-width:var(--xy-minimap-node-stroke-width-props,var(--xy-minimap-node-stroke-width,var(--xy-minimap-node-stroke-width-default)))}.react-flow__background-pattern.dots{fill:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-dots-color-default)))}.react-flow__background-pattern.lines{stroke:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-lines-color-default)))}.react-flow__background-pattern.cross{stroke:var(--xy-background-pattern-color-props,var(--xy-background-pattern-color,var(--xy-background-pattern-cross-color-default)))}.react-flow__controls{box-shadow:var(--xy-controls-box-shadow,var(--xy-controls-box-shadow-default));flex-direction:column;display:flex}.react-flow__controls.horizontal{flex-direction:row}.react-flow__controls-button{background:var(--xy-controls-button-background-color,var(--xy-controls-button-background-color-default));border:none;border-bottom:1px solid var(--xy-controls-button-border-color-props,var(--xy-controls-button-border-color,var(--xy-controls-button-border-color-default)));width:26px;height:26px;color:var(--xy-controls-button-color-props,var(--xy-controls-button-color,var(--xy-controls-button-color-default)));cursor:pointer;-webkit-user-select:none;user-select:none;justify-content:center;align-items:center;padding:4px;display:flex}.react-flow__controls-button svg{fill:currentColor;width:100%;max-width:12px;max-height:12px}.react-flow__edge.updating .react-flow__edge-path{stroke:#777}.react-flow__edge-text{font-size:10px}.react-flow__node.selectable:focus,.react-flow__node.selectable:focus-visible{outline:none}.react-flow__node-input,.react-flow__node-default,.react-flow__node-output,.react-flow__node-group{border-radius:var(--xy-node-border-radius,var(--xy-node-border-radius-default));width:150px;color:var(--xy-node-color,var(--xy-node-color-default));text-align:center;border:var(--xy-node-border,var(--xy-node-border-default));background-color:var(--xy-node-background-color,var(--xy-node-background-color-default));padding:10px;font-size:12px}.react-flow__node-input.selectable:hover,.react-flow__node-default.selectable:hover,.react-flow__node-output.selectable:hover,.react-flow__node-group.selectable:hover{box-shadow:var(--xy-node-boxshadow-hover,var(--xy-node-boxshadow-hover-default))}.react-flow__node-input.selectable.selected,.react-flow__node-input.selectable:focus,.react-flow__node-input.selectable:focus-visible,.react-flow__node-default.selectable.selected,.react-flow__node-default.selectable:focus,.react-flow__node-default.selectable:focus-visible,.react-flow__node-output.selectable.selected,.react-flow__node-output.selectable:focus,.react-flow__node-output.selectable:focus-visible,.react-flow__node-group.selectable.selected,.react-flow__node-group.selectable:focus,.react-flow__node-group.selectable:focus-visible{box-shadow:var(--xy-node-boxshadow-selected,var(--xy-node-boxshadow-selected-default))}.react-flow__node-group{background-color:var(--xy-node-group-background-color,var(--xy-node-group-background-color-default))}.react-flow__nodesselection-rect,.react-flow__selection{background:var(--xy-selection-background-color,var(--xy-selection-background-color-default));border:var(--xy-selection-border,var(--xy-selection-border-default))}.react-flow__nodesselection-rect:focus,.react-flow__nodesselection-rect:focus-visible,.react-flow__selection:focus,.react-flow__selection:focus-visible{outline:none}.react-flow__controls-button:hover{background:var(--xy-controls-button-background-color-hover-props,var(--xy-controls-button-background-color-hover,var(--xy-controls-button-background-color-hover-default)));color:var(--xy-controls-button-color-hover-props,var(--xy-controls-button-color-hover,var(--xy-controls-button-color-hover-default)))}.react-flow__controls-button:disabled{pointer-events:none}.react-flow__controls-button:disabled svg{fill-opacity:.4}.react-flow__controls-button:last-child{border-bottom:none}.react-flow__controls.horizontal .react-flow__controls-button{border-bottom:none;border-right:1px solid var(--xy-controls-button-border-color-props,var(--xy-controls-button-border-color,var(--xy-controls-button-border-color-default)))}.react-flow__controls.horizontal .react-flow__controls-button:last-child{border-right:none}.react-flow__resize-control{position:absolute}.react-flow__resize-control.left,.react-flow__resize-control.right{cursor:ew-resize}.react-flow__resize-control.top,.react-flow__resize-control.bottom{cursor:ns-resize}.react-flow__resize-control.top.left,.react-flow__resize-control.bottom.right{cursor:nwse-resize}.react-flow__resize-control.bottom.left,.react-flow__resize-control.top.right{cursor:nesw-resize}.react-flow__resize-control.handle{background-color:var(--xy-resize-background-color,var(--xy-resize-background-color-default));border:1px solid #fff;border-radius:1px;width:5px;height:5px;translate:-50% -50%}.react-flow__resize-control.handle.left{top:50%;left:0}.react-flow__resize-control.handle.right{top:50%;left:100%}.react-flow__resize-control.handle.top{top:0;left:50%}.react-flow__resize-control.handle.bottom{top:100%;left:50%}.react-flow__resize-control.handle.top.left,.react-flow__resize-control.handle.bottom.left{left:0}.react-flow__resize-control.handle.top.right,.react-flow__resize-control.handle.bottom.right{left:100%}.react-flow__resize-control.line{border-color:var(--xy-resize-background-color,var(--xy-resize-background-color-default));border-style:solid;border-width:0}.react-flow__resize-control.line.left,.react-flow__resize-control.line.right{width:1px;height:100%;top:0;transform:translate(-50%)}.react-flow__resize-control.line.left{border-left-width:1px;left:0}.react-flow__resize-control.line.right{border-right-width:1px;left:100%}.react-flow__resize-control.line.top,.react-flow__resize-control.line.bottom{width:100%;height:1px;left:0;transform:translateY(-50%)}.react-flow__resize-control.line.top{border-top-width:1px;top:0}.react-flow__resize-control.line.bottom{border-bottom-width:1px;top:100%}.react-flow__edge-textbg{fill:var(--xy-edge-label-background-color,var(--xy-edge-label-background-color-default))}.react-flow__edge-text{fill:var(--xy-edge-label-color,var(--xy-edge-label-color-default))} diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index e40e8760262..087e11c5df4 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -51,11 +51,11 @@ Error generating stack: `+e.message+` [zustand devtools middleware] Unsupported __setState action format. When using 'store' option in devtools(), the 'state' should have only one key, which is a value of 'store' that was passed in devtools(), and value of this only key should be a state object. Example: { "type": "__setState", "state": { "abc123Store": { "foo": "bar" } } } - `);let t=e.state[s];if(t==null)return;JSON.stringify(i.getState())!==JSON.stringify(t)&&p(t);return}i.dispatchFromDevtools&&typeof i.dispatch==`function`&&i.dispatch(e)});case`DISPATCH`:switch(e.payload.type){case`RESET`:return p(m),s===void 0?u?.init(i.getState()):u?.init(dd(c.name));case`COMMIT`:if(s===void 0){u?.init(i.getState());return}return u?.init(dd(c.name));case`ROLLBACK`:return _d(e.state,e=>{if(s===void 0){p(e),u?.init(i.getState());return}p(e[s]),u?.init(dd(c.name))});case`JUMP_TO_STATE`:case`JUMP_TO_ACTION`:return _d(e.state,e=>{if(s===void 0){p(e);return}JSON.stringify(i.getState())!==JSON.stringify(e[s])&&p(e[s])});case`IMPORT_STATE`:{let{nextLiftedState:t}=e.payload,n=t.computedStates.slice(-1)[0]?.state;if(!n)return;p(s===void 0?n:n[s]),u?.send(null,t);return}case`PAUSE_RECORDING`:return f=!f}return}}),m},gd=hd,_d=(e,t)=>{let n;try{n=JSON.parse(e)}catch(e){console.error(`[zustand devtools middleware] Could not parse the received json`,e)}n!==void 0&&t(n)},vd=e=>t=>{try{let n=e(t);return n instanceof Promise?n:{then(e){return vd(e)(n)},catch(e){return this}}}catch(e){return{then(e){return this},catch(t){return vd(t)(e)}}}},yd=(e,t)=>(n,r,i)=>{let a={storage:ld(()=>localStorage),partialize:e=>e,version:0,merge:(e,t)=>({...t,...e}),...t},o=!1,s=new Set,c=new Set,l=a.storage;if(!l)return e((...e)=>{console.warn(`[zustand persist middleware] Unable to update item '${a.name}', the given storage is currently unavailable.`),n(...e)},r,i);let u=()=>{let e=a.partialize({...r()});return l.setItem(a.name,{state:e,version:a.version})},d=i.setState;i.setState=(e,t)=>(d(e,t),u());let f=e((...e)=>(n(...e),u()),r,i);i.getInitialState=()=>f;let p,m=()=>{if(!l)return;o=!1,s.forEach(e=>e(r()??f));let e=a.onRehydrateStorage?.call(a,r()??f)||void 0;return vd(l.getItem.bind(l))(a.name).then(e=>{if(e)if(typeof e.version==`number`&&e.version!==a.version){if(a.migrate){let t=a.migrate(e.state,e.version);return t instanceof Promise?t.then(e=>[!0,e]):[!0,t]}console.error(`State loaded from storage couldn't be migrated since no migrate function was provided`)}else return[!1,e.state];return[!1,void 0]}).then(e=>{let[t,i]=e;if(p=a.merge(i,r()??f),n(p,!0),t)return u()}).then(()=>{e?.(p,void 0),p=r(),o=!0,c.forEach(e=>e(p))}).catch(t=>{e?.(void 0,t)})};return i.persist={setOptions:e=>{a={...a,...e},e.storage&&(l=e.storage)},clearStorage:()=>{l?.removeItem(a.name)},getOptions:()=>a,rehydrate:()=>m(),hasHydrated:()=>o,onHydrate:e=>(s.add(e),()=>{s.delete(e)}),onFinishHydration:e=>(c.add(e),()=>{c.delete(e)})},a.skipHydration||m(),p||f},bd=yd}));function Sd(e){if(e.type!==`response.output_text.delta`||!(`delta`in e)||typeof e.delta!=`string`||e.delta.length<=wd)return e;let t=e.delta.length-wd;return{...e,delta:`${e.delta.slice(0,wd)}\n...[${t} chars omitted from debug view]`}}var Cd,wd,Q,Td=o((()=>{cd(),xd(),Cd=1e3,wd=2048,Q=od()(gd(bd(e=>({agents:[],workflows:[],entities:[],selectedAgent:void 0,isLoadingEntities:!0,entityError:null,currentConversation:void 0,availableConversations:[],chatItems:[],isStreaming:!1,isSubmitting:!1,loadingConversations:!1,inputValue:``,attachments:[],conversationUsage:{total_tokens:0,message_count:0},pendingApprovals:[],currentSession:void 0,availableSessions:[],sessionCheckpoints:[],loadingSessions:!1,loadingCheckpoints:!1,showDebugPanel:!0,debugPanelMinimized:!1,debugPanelWidth:320,debugEvents:[],isResizing:!1,showToolCalls:!0,streamingEnabled:!0,debugPanelTab:`events`,debugTraceSubTab:`spans`,contextInspectorViewMode:`tokens`,contextInspectorCumulative:!1,showAboutModal:!1,showGallery:!1,showDeployModal:!1,showEntityNotFoundToast:!1,toasts:[],oaiMode:{enabled:!1,model:`gpt-4o-mini`},uiMode:`developer`,runtime:`python`,serverCapabilities:{instrumentation:!1,openai_proxy:!1,deployment:!1},authRequired:!1,serverVersion:null,isDeploying:!1,deploymentLogs:[],lastDeployment:null,azureDeploymentEnabled:!1,setAgents:t=>e({agents:t}),setWorkflows:t=>e({workflows:t}),setEntities:t=>e({entities:t}),setSelectedAgent:t=>e({selectedAgent:t}),addAgent:t=>e(e=>({agents:[...e.agents,t]})),addWorkflow:t=>e(e=>({workflows:[...e.workflows,t]})),updateAgent:t=>e(e=>({agents:e.agents.map(e=>e.id===t.id?t:e),selectedAgent:e.selectedAgent?.id===t.id&&e.selectedAgent.type===`agent`?t:e.selectedAgent})),updateWorkflow:t=>e(e=>({workflows:e.workflows.map(e=>e.id===t.id?t:e),selectedAgent:e.selectedAgent?.id===t.id&&e.selectedAgent.type===`workflow`?t:e.selectedAgent})),removeEntity:t=>e(e=>({agents:e.agents.filter(e=>e.id!==t),workflows:e.workflows.filter(e=>e.id!==t),selectedAgent:e.selectedAgent?.id===t?void 0:e.selectedAgent})),setEntityError:t=>e({entityError:t}),setIsLoadingEntities:t=>e({isLoadingEntities:t}),setCurrentConversation:t=>e({currentConversation:t}),setAvailableConversations:t=>e({availableConversations:t}),setChatItems:t=>e({chatItems:t}),setIsStreaming:t=>e({isStreaming:t}),setIsSubmitting:t=>e({isSubmitting:t}),setLoadingConversations:t=>e({loadingConversations:t}),setInputValue:t=>e({inputValue:t}),setAttachments:t=>e({attachments:t}),updateConversationUsage:t=>e(e=>({conversationUsage:{total_tokens:e.conversationUsage.total_tokens+t,message_count:e.conversationUsage.message_count+1}})),setPendingApprovals:t=>e({pendingApprovals:t}),setCurrentSession:t=>e({currentSession:t}),setAvailableSessions:t=>e({availableSessions:t}),setSessionCheckpoints:t=>e({sessionCheckpoints:t}),setLoadingSessions:t=>e({loadingSessions:t}),setLoadingCheckpoints:t=>e({loadingCheckpoints:t}),addSession:t=>e(e=>({availableSessions:[t,...e.availableSessions]})),removeSession:t=>e(e=>({availableSessions:e.availableSessions.filter(e=>e.conversation_id!==t),currentSession:e.currentSession?.conversation_id===t?void 0:e.currentSession,sessionCheckpoints:e.currentSession?.conversation_id===t?[]:e.sessionCheckpoints})),setShowDebugPanel:t=>e({showDebugPanel:t}),setDebugPanelMinimized:t=>e({debugPanelMinimized:t}),setDebugPanelWidth:t=>e({debugPanelWidth:t}),setShowToolCalls:t=>e({showToolCalls:t}),setStreamingEnabled:t=>e({streamingEnabled:t}),addDebugEvent:t=>e(e=>{let n=Sd(t),r=Math.floor(Date.now()/1e3),i=(e.debugEvents.length>0?e.debugEvents[e.debugEvents.length-1]:null)?._uiTimestamp??0,a=Math.max(r,i+1);return{debugEvents:[...e.debugEvents.length>=Cd?e.debugEvents.slice(-999):e.debugEvents,{...n,_uiTimestamp:`created_at`in n&&n.created_at?n.created_at:a}]}}),clearDebugEvents:()=>e({debugEvents:[]}),setIsResizing:t=>e({isResizing:t}),setDebugPanelTab:t=>e({debugPanelTab:t}),setDebugTraceSubTab:t=>e({debugTraceSubTab:t}),setContextInspectorViewMode:t=>e({contextInspectorViewMode:t}),setContextInspectorCumulative:t=>e({contextInspectorCumulative:t}),setShowAboutModal:t=>e({showAboutModal:t}),setShowGallery:t=>e({showGallery:t}),setShowDeployModal:t=>e({showDeployModal:t}),setShowEntityNotFoundToast:t=>e({showEntityNotFoundToast:t}),addToast:t=>e(e=>({toasts:[...e.toasts,{id:`toast-${Date.now()}-${Math.random().toString(36).substr(2,9)}`,type:t.type||`info`,duration:t.duration||4e3,...t}]})),removeToast:t=>e(e=>({toasts:e.toasts.filter(e=>e.id!==t)})),setOAIMode:t=>e(e=>t.enabled&&!e.oaiMode.enabled||!t.enabled&&e.oaiMode.enabled?(Object.keys(localStorage).forEach(e=>{e.startsWith(`devui_convs_`)&&localStorage.removeItem(e)}),{oaiMode:t,currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:``,attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],debugEvents:[]}):{oaiMode:t}),toggleOAIMode:()=>e(e=>{let t=!e.oaiMode.enabled;return{oaiMode:{...e.oaiMode,enabled:t},currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:``,attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],debugEvents:[]}}),setServerMeta:t=>e({uiMode:t.uiMode,runtime:t.runtime,serverCapabilities:t.capabilities,authRequired:t.authRequired,serverVersion:t.version||null}),startDeployment:()=>e({isDeploying:!0,deploymentLogs:[],lastDeployment:null}),addDeploymentLog:t=>e(e=>({deploymentLogs:[...e.deploymentLogs,t]})),setDeploymentResult:t=>e({isDeploying:!1,lastDeployment:t}),stopDeployment:()=>e({isDeploying:!1}),clearDeploymentState:()=>e({isDeploying:!1,deploymentLogs:[],lastDeployment:null}),setAzureDeploymentEnabled:t=>e({azureDeploymentEnabled:t}),selectEntity:t=>{e({selectedAgent:t,currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:``,attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],currentSession:void 0,availableSessions:[],sessionCheckpoints:[],debugEvents:[]});let n=new URL(window.location.href);n.searchParams.set(`entity_id`,t.id),window.history.pushState({},``,n)}}),{name:`devui-storage`,partialize:e=>({showDebugPanel:e.showDebugPanel,debugPanelMinimized:e.debugPanelMinimized,debugPanelWidth:e.debugPanelWidth,showToolCalls:e.showToolCalls,streamingEnabled:e.streamingEnabled,oaiMode:e.oaiMode,azureDeploymentEnabled:e.azureDeploymentEnabled,debugPanelTab:e.debugPanelTab,debugTraceSubTab:e.debugTraceSubTab,contextInspectorViewMode:e.contextInspectorViewMode,contextInspectorCumulative:e.contextInspectorCumulative})}),{name:`DevUI Store`}))})),Ed=c({useDevUIStore:()=>Q}),Dd=o((()=>{Td()}));Dd();function Od({agents:e,workflows:t,entities:n,selectedItem:r,onSelect:i,onBrowseGallery:a,isLoading:o=!1,onSettingsClick:s}){let{oaiMode:c,serverVersion:l}=Q();return(0,T.jsxs)(`header`,{className:`flex h-14 items-center gap-4 border-b px-4`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 font-semibold`,children:[(0,T.jsxs)(`svg`,{width:`24`,height:`24`,viewBox:`0 0 805 805`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`,className:`flex-shrink-0`,children:[(0,T.jsx)(`path`,{d:`M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z`,fill:`url(#paint0_linear_510_1294)`}),(0,T.jsx)(`defs`,{children:(0,T.jsxs)(`linearGradient`,{id:`paint0_linear_510_1294`,x1:`255.628`,y1:`-34.3245`,x2:`618.483`,y2:`632.032`,gradientUnits:`userSpaceOnUse`,children:[(0,T.jsx)(`stop`,{stopColor:`#D59FFF`}),(0,T.jsx)(`stop`,{offset:`1`,stopColor:`#8562C5`})]})})]}),`Dev UI`,l&&(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground ml-1`,children:[`v`,l]}),c.enabled&&(0,T.jsxs)(ct,{variant:`secondary`,className:`gap-1 ml-2`,children:[(0,T.jsx)(Ou,{className:`h-3 w-3`}),`OpenAI: `,c.model]})]}),!c.enabled&&(0,T.jsx)(Lu,{agents:e,workflows:t,entities:n,selectedItem:r,onSelect:i,onBrowseGallery:a,isLoading:o}),(0,T.jsx)(`div`,{className:`flex-1`}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 ml-auto`,children:[(0,T.jsx)(Qu,{}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:e=>{e.stopPropagation(),s?.()},children:(0,T.jsx)(_u,{className:`h-4 w-4`})})]})]})}function kd(e,[t,n]){return Math.min(n,Math.max(t,e))}function Ad(e,t){return y.useReducer((e,n)=>t[e][n]??e,e)}var jd=`ScrollArea`,[Md,Nd]=lt(jd),[Pd,Fd]=Md(jd),Id=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,type:r=`hover`,dir:i,scrollHideDelay:a=600,...o}=e,[s,c]=y.useState(null),[l,u]=y.useState(null),[d,f]=y.useState(null),[p,m]=y.useState(null),[h,g]=y.useState(null),[_,v]=y.useState(0),[b,x]=y.useState(0),[S,w]=y.useState(!1),[E,D]=y.useState(!1),O=C(t,e=>c(e)),k=bt(i);return(0,T.jsx)(Pd,{scope:n,type:r,dir:k,scrollHideDelay:a,scrollArea:s,viewport:l,onViewportChange:u,content:d,onContentChange:f,scrollbarX:p,onScrollbarXChange:m,scrollbarXEnabled:S,onScrollbarXEnabledChange:w,scrollbarY:h,onScrollbarYChange:g,scrollbarYEnabled:E,onScrollbarYEnabledChange:D,onCornerWidthChange:v,onCornerHeightChange:x,children:(0,T.jsx)(J.div,{dir:k,...o,ref:O,style:{position:`relative`,"--radix-scroll-area-corner-width":_+`px`,"--radix-scroll-area-corner-height":b+`px`,...e.style}})})});Id.displayName=jd;var Ld=`ScrollAreaViewport`,Rd=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,children:r,nonce:i,...a}=e,o=Fd(Ld,n),s=C(t,y.useRef(null),o.onViewportChange);return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`style`,{dangerouslySetInnerHTML:{__html:`[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none}`},nonce:i}),(0,T.jsx)(J.div,{"data-radix-scroll-area-viewport":``,...a,ref:s,style:{overflowX:o.scrollbarXEnabled?`scroll`:`hidden`,overflowY:o.scrollbarYEnabled?`scroll`:`hidden`,...e.style},children:(0,T.jsx)(`div`,{ref:o.onContentChange,style:{minWidth:`100%`,display:`table`},children:r})})]})});Rd.displayName=Ld;var zd=`ScrollAreaScrollbar`,Bd=y.forwardRef((e,t)=>{let{forceMount:n,...r}=e,i=Fd(zd,e.__scopeScrollArea),{onScrollbarXEnabledChange:a,onScrollbarYEnabledChange:o}=i,s=e.orientation===`horizontal`;return y.useEffect(()=>(s?a(!0):o(!0),()=>{s?a(!1):o(!1)}),[s,a,o]),i.type===`hover`?(0,T.jsx)(Vd,{...r,ref:t,forceMount:n}):i.type===`scroll`?(0,T.jsx)(Hd,{...r,ref:t,forceMount:n}):i.type===`auto`?(0,T.jsx)(Ud,{...r,ref:t,forceMount:n}):i.type===`always`?(0,T.jsx)(Wd,{...r,ref:t}):null});Bd.displayName=zd;var Vd=y.forwardRef((e,t)=>{let{forceMount:n,...r}=e,i=Fd(zd,e.__scopeScrollArea),[a,o]=y.useState(!1);return y.useEffect(()=>{let e=i.scrollArea,t=0;if(e){let n=()=>{window.clearTimeout(t),o(!0)},r=()=>{t=window.setTimeout(()=>o(!1),i.scrollHideDelay)};return e.addEventListener(`pointerenter`,n),e.addEventListener(`pointerleave`,r),()=>{window.clearTimeout(t),e.removeEventListener(`pointerenter`,n),e.removeEventListener(`pointerleave`,r)}}},[i.scrollArea,i.scrollHideDelay]),(0,T.jsx)(Zi,{present:n||a,children:(0,T.jsx)(Ud,{"data-state":a?`visible`:`hidden`,...r,ref:t})})}),Hd=y.forwardRef((e,t)=>{let{forceMount:n,...r}=e,i=Fd(zd,e.__scopeScrollArea),a=e.orientation===`horizontal`,o=df(()=>c(`SCROLL_END`),100),[s,c]=Ad(`hidden`,{hidden:{SCROLL:`scrolling`},scrolling:{SCROLL_END:`idle`,POINTER_ENTER:`interacting`},interacting:{SCROLL:`interacting`,POINTER_LEAVE:`idle`},idle:{HIDE:`hidden`,SCROLL:`scrolling`,POINTER_ENTER:`interacting`}});return y.useEffect(()=>{if(s===`idle`){let e=window.setTimeout(()=>c(`HIDE`),i.scrollHideDelay);return()=>window.clearTimeout(e)}},[s,i.scrollHideDelay,c]),y.useEffect(()=>{let e=i.viewport,t=a?`scrollLeft`:`scrollTop`;if(e){let n=e[t],r=()=>{let r=e[t];n!==r&&(c(`SCROLL`),o()),n=r};return e.addEventListener(`scroll`,r),()=>e.removeEventListener(`scroll`,r)}},[i.viewport,a,c,o]),(0,T.jsx)(Zi,{present:n||s!==`hidden`,children:(0,T.jsx)(Wd,{"data-state":s===`hidden`?`hidden`:`visible`,...r,ref:t,onPointerEnter:q(e.onPointerEnter,()=>c(`POINTER_ENTER`)),onPointerLeave:q(e.onPointerLeave,()=>c(`POINTER_LEAVE`))})})}),Ud=y.forwardRef((e,t)=>{let n=Fd(zd,e.__scopeScrollArea),{forceMount:r,...i}=e,[a,o]=y.useState(!1),s=e.orientation===`horizontal`,c=df(()=>{if(n.viewport){let e=n.viewport.offsetWidth{let{orientation:n=`vertical`,...r}=e,i=Fd(zd,e.__scopeScrollArea),a=y.useRef(null),o=y.useRef(0),[s,c]=y.useState({content:0,viewport:0,scrollbar:{size:0,paddingStart:0,paddingEnd:0}}),l=rf(s.viewport,s.content),u={...r,sizes:s,onSizesChange:c,hasThumb:l>0&&l<1,onThumbChange:e=>a.current=e,onThumbPointerUp:()=>o.current=0,onThumbPointerDown:e=>o.current=e};function d(e,t){return of(e,o.current,s,t)}return n===`horizontal`?(0,T.jsx)(Gd,{...u,ref:t,onThumbPositionChange:()=>{if(i.viewport&&a.current){let e=i.viewport.scrollLeft,t=sf(e,s,i.dir);a.current.style.transform=`translate3d(${t}px, 0, 0)`}},onWheelScroll:e=>{i.viewport&&(i.viewport.scrollLeft=e)},onDragScroll:e=>{i.viewport&&(i.viewport.scrollLeft=d(e,i.dir))}}):n===`vertical`?(0,T.jsx)(Kd,{...u,ref:t,onThumbPositionChange:()=>{if(i.viewport&&a.current){let e=i.viewport.scrollTop,t=sf(e,s);a.current.style.transform=`translate3d(0, ${t}px, 0)`}},onWheelScroll:e=>{i.viewport&&(i.viewport.scrollTop=e)},onDragScroll:e=>{i.viewport&&(i.viewport.scrollTop=d(e))}}):null}),Gd=y.forwardRef((e,t)=>{let{sizes:n,onSizesChange:r,...i}=e,a=Fd(zd,e.__scopeScrollArea),[o,s]=y.useState(),c=y.useRef(null),l=C(t,c,a.onScrollbarXChange);return y.useEffect(()=>{c.current&&s(getComputedStyle(c.current))},[c]),(0,T.jsx)(Yd,{"data-orientation":`horizontal`,...i,ref:l,sizes:n,style:{bottom:0,left:a.dir===`rtl`?`var(--radix-scroll-area-corner-width)`:0,right:a.dir===`ltr`?`var(--radix-scroll-area-corner-width)`:0,"--radix-scroll-area-thumb-width":af(n)+`px`,...e.style},onThumbPointerDown:t=>e.onThumbPointerDown(t.x),onDragScroll:t=>e.onDragScroll(t.x),onWheelScroll:(t,n)=>{if(a.viewport){let r=a.viewport.scrollLeft+t.deltaX;e.onWheelScroll(r),lf(r,n)&&t.preventDefault()}},onResize:()=>{c.current&&a.viewport&&o&&r({content:a.viewport.scrollWidth,viewport:a.viewport.offsetWidth,scrollbar:{size:c.current.clientWidth,paddingStart:nf(o.paddingLeft),paddingEnd:nf(o.paddingRight)}})}})}),Kd=y.forwardRef((e,t)=>{let{sizes:n,onSizesChange:r,...i}=e,a=Fd(zd,e.__scopeScrollArea),[o,s]=y.useState(),c=y.useRef(null),l=C(t,c,a.onScrollbarYChange);return y.useEffect(()=>{c.current&&s(getComputedStyle(c.current))},[c]),(0,T.jsx)(Yd,{"data-orientation":`vertical`,...i,ref:l,sizes:n,style:{top:0,right:a.dir===`ltr`?0:void 0,left:a.dir===`rtl`?0:void 0,bottom:`var(--radix-scroll-area-corner-height)`,"--radix-scroll-area-thumb-height":af(n)+`px`,...e.style},onThumbPointerDown:t=>e.onThumbPointerDown(t.y),onDragScroll:t=>e.onDragScroll(t.y),onWheelScroll:(t,n)=>{if(a.viewport){let r=a.viewport.scrollTop+t.deltaY;e.onWheelScroll(r),lf(r,n)&&t.preventDefault()}},onResize:()=>{c.current&&a.viewport&&o&&r({content:a.viewport.scrollHeight,viewport:a.viewport.offsetHeight,scrollbar:{size:c.current.clientHeight,paddingStart:nf(o.paddingTop),paddingEnd:nf(o.paddingBottom)}})}})}),[qd,Jd]=Md(zd),Yd=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,sizes:r,hasThumb:i,onThumbChange:a,onThumbPointerUp:o,onThumbPointerDown:s,onThumbPositionChange:c,onDragScroll:l,onWheelScroll:u,onResize:d,...f}=e,p=Fd(zd,n),[m,h]=y.useState(null),g=C(t,e=>h(e)),_=y.useRef(null),v=y.useRef(``),b=p.viewport,x=r.content-r.viewport,S=xt(u),w=xt(c),E=df(d,10);function D(e){_.current&&l({x:e.clientX-_.current.left,y:e.clientY-_.current.top})}return y.useEffect(()=>{let e=e=>{let t=e.target;m?.contains(t)&&S(e,x)};return document.addEventListener(`wheel`,e,{passive:!1}),()=>document.removeEventListener(`wheel`,e,{passive:!1})},[b,m,x,S]),y.useEffect(w,[r,w]),ff(m,E),ff(p.content,E),(0,T.jsx)(qd,{scope:n,scrollbar:m,hasThumb:i,onThumbChange:xt(a),onThumbPointerUp:xt(o),onThumbPositionChange:w,onThumbPointerDown:xt(s),children:(0,T.jsx)(J.div,{...f,ref:g,style:{position:`absolute`,...f.style},onPointerDown:q(e.onPointerDown,e=>{e.button===0&&(e.target.setPointerCapture(e.pointerId),_.current=m.getBoundingClientRect(),v.current=document.body.style.webkitUserSelect,document.body.style.webkitUserSelect=`none`,p.viewport&&(p.viewport.style.scrollBehavior=`auto`),D(e))}),onPointerMove:q(e.onPointerMove,D),onPointerUp:q(e.onPointerUp,e=>{let t=e.target;t.hasPointerCapture(e.pointerId)&&t.releasePointerCapture(e.pointerId),document.body.style.webkitUserSelect=v.current,p.viewport&&(p.viewport.style.scrollBehavior=``),_.current=null})})})}),Xd=`ScrollAreaThumb`,Zd=y.forwardRef((e,t)=>{let{forceMount:n,...r}=e,i=Jd(Xd,e.__scopeScrollArea);return(0,T.jsx)(Zi,{present:n||i.hasThumb,children:(0,T.jsx)(Qd,{ref:t,...r})})}),Qd=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,style:r,...i}=e,a=Fd(Xd,n),o=Jd(Xd,n),{onThumbPositionChange:s}=o,c=C(t,e=>o.onThumbChange(e)),l=y.useRef(void 0),u=df(()=>{l.current&&=(l.current(),void 0)},100);return y.useEffect(()=>{let e=a.viewport;if(e){let t=()=>{u(),l.current||(l.current=uf(e,s),s())};return s(),e.addEventListener(`scroll`,t),()=>e.removeEventListener(`scroll`,t)}},[a.viewport,u,s]),(0,T.jsx)(J.div,{"data-state":o.hasThumb?`visible`:`hidden`,...i,ref:c,style:{width:`var(--radix-scroll-area-thumb-width)`,height:`var(--radix-scroll-area-thumb-height)`,...r},onPointerDownCapture:q(e.onPointerDownCapture,e=>{let t=e.target.getBoundingClientRect(),n=e.clientX-t.left,r=e.clientY-t.top;o.onThumbPointerDown({x:n,y:r})}),onPointerUp:q(e.onPointerUp,o.onThumbPointerUp)})});Zd.displayName=Xd;var $d=`ScrollAreaCorner`,ef=y.forwardRef((e,t)=>{let n=Fd($d,e.__scopeScrollArea),r=!!(n.scrollbarX&&n.scrollbarY);return n.type!==`scroll`&&r?(0,T.jsx)(tf,{...e,ref:t}):null});ef.displayName=$d;var tf=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,...r}=e,i=Fd($d,n),[a,o]=y.useState(0),[s,c]=y.useState(0),l=!!(a&&s);return ff(i.scrollbarX,()=>{let e=i.scrollbarX?.offsetHeight||0;i.onCornerHeightChange(e),c(e)}),ff(i.scrollbarY,()=>{let e=i.scrollbarY?.offsetWidth||0;i.onCornerWidthChange(e),o(e)}),l?(0,T.jsx)(J.div,{...r,ref:t,style:{width:a,height:s,position:`absolute`,right:i.dir===`ltr`?0:void 0,left:i.dir===`rtl`?0:void 0,bottom:0,...e.style}}):null});function nf(e){return e?parseInt(e,10):0}function rf(e,t){let n=e/t;return isNaN(n)?0:n}function af(e){let t=rf(e.viewport,e.content),n=e.scrollbar.paddingStart+e.scrollbar.paddingEnd,r=(e.scrollbar.size-n)*t;return Math.max(r,18)}function of(e,t,n,r=`ltr`){let i=af(n),a=i/2,o=t||a,s=i-o,c=n.scrollbar.paddingStart+o,l=n.scrollbar.size-n.scrollbar.paddingEnd-s,u=n.content-n.viewport,d=r===`ltr`?[0,u]:[u*-1,0];return cf([c,l],d)(e)}function sf(e,t,n=`ltr`){let r=af(t),i=t.scrollbar.paddingStart+t.scrollbar.paddingEnd,a=t.scrollbar.size-i,o=t.content-t.viewport,s=a-r,c=kd(e,n===`ltr`?[0,o]:[o*-1,0]);return cf([0,o],[0,s])(c)}function cf(e,t){return n=>{if(e[0]===e[1]||t[0]===t[1])return t[0];let r=(t[1]-t[0])/(e[1]-e[0]);return t[0]+r*(n-e[0])}}function lf(e,t){return e>0&&e{})=>{let n={left:e.scrollLeft,top:e.scrollTop},r=0;return(function i(){let a={left:e.scrollLeft,top:e.scrollTop},o=n.left!==a.left,s=n.top!==a.top;(o||s)&&t(),n=a,r=window.requestAnimationFrame(i)})(),()=>window.cancelAnimationFrame(r)};function df(e,t){let n=xt(e),r=y.useRef(0);return y.useEffect(()=>()=>window.clearTimeout(r.current),[]),y.useCallback(()=>{window.clearTimeout(r.current),r.current=window.setTimeout(n,t)},[n,t])}function ff(e,t){let n=xt(t);dt(()=>{let t=0;if(e){let r=new ResizeObserver(()=>{cancelAnimationFrame(t),t=window.requestAnimationFrame(n)});return r.observe(e),()=>{window.cancelAnimationFrame(t),r.unobserve(e)}}},[e,n])}var pf=Id,mf=Rd,hf=ef,gf=y.forwardRef(({className:e,children:t,...n},r)=>(0,T.jsxs)(pf,{ref:r,className:at(`relative overflow-hidden`,e),...n,children:[(0,T.jsx)(mf,{className:`h-full w-full rounded-[inherit]`,children:t}),(0,T.jsx)(_f,{}),(0,T.jsx)(hf,{})]}));gf.displayName=pf.displayName;var _f=y.forwardRef(({className:e,orientation:t=`vertical`,...n},r)=>(0,T.jsx)(Bd,{ref:r,orientation:t,className:at(`flex touch-none select-none transition-colors`,t===`vertical`&&`h-full w-2.5 border-l border-l-transparent p-[1px]`,t===`horizontal`&&`h-2.5 flex-col border-t border-t-transparent p-[1px]`,e),...n,children:(0,T.jsx)(Zd,{className:`relative flex-1 rounded-full bg-border`})}));_f.displayName=Bd.displayName;var vf=`Tabs`,[yf,bf]=lt(vf,[ca]),xf=ca(),[Sf,Cf]=yf(vf),wf=y.forwardRef((e,t)=>{let{__scopeTabs:n,value:r,onValueChange:i,defaultValue:a,orientation:o=`horizontal`,dir:s,activationMode:c=`automatic`,...l}=e,u=bt(s),[d,f]=pt({prop:r,onChange:i,defaultProp:a??``,caller:vf});return(0,T.jsx)(Sf,{scope:n,baseId:rn(),value:d,onValueChange:f,orientation:o,dir:u,activationMode:c,children:(0,T.jsx)(J.div,{dir:u,"data-orientation":o,...l,ref:t})})});wf.displayName=vf;var Tf=`TabsList`,Ef=y.forwardRef((e,t)=>{let{__scopeTabs:n,loop:r=!0,...i}=e,a=Cf(Tf,n),o=xf(n);return(0,T.jsx)(ba,{asChild:!0,...o,orientation:a.orientation,dir:a.dir,loop:r,children:(0,T.jsx)(J.div,{role:`tablist`,"aria-orientation":a.orientation,...i,ref:t})})});Ef.displayName=Tf;var Df=`TabsTrigger`,Of=y.forwardRef((e,t)=>{let{__scopeTabs:n,value:r,disabled:i=!1,...a}=e,o=Cf(Df,n),s=xf(n),c=jf(o.baseId,r),l=Mf(o.baseId,r),u=r===o.value;return(0,T.jsx)(xa,{asChild:!0,...s,focusable:!i,active:u,children:(0,T.jsx)(J.button,{type:`button`,role:`tab`,"aria-selected":u,"aria-controls":l,"data-state":u?`active`:`inactive`,"data-disabled":i?``:void 0,disabled:i,id:c,...a,ref:t,onMouseDown:q(e.onMouseDown,e=>{!i&&e.button===0&&e.ctrlKey===!1?o.onValueChange(r):e.preventDefault()}),onKeyDown:q(e.onKeyDown,e=>{[` `,`Enter`].includes(e.key)&&o.onValueChange(r)}),onFocus:q(e.onFocus,()=>{let e=o.activationMode!==`manual`;!u&&!i&&e&&o.onValueChange(r)})})})});Of.displayName=Df;var kf=`TabsContent`,Af=y.forwardRef((e,t)=>{let{__scopeTabs:n,value:r,forceMount:i,children:a,...o}=e,s=Cf(kf,n),c=jf(s.baseId,r),l=Mf(s.baseId,r),u=r===s.value,d=y.useRef(u);return y.useEffect(()=>{let e=requestAnimationFrame(()=>d.current=!1);return()=>cancelAnimationFrame(e)},[]),(0,T.jsx)(Zi,{present:i||u,children:({present:n})=>(0,T.jsx)(J.div,{"data-state":u?`active`:`inactive`,"data-orientation":s.orientation,role:`tabpanel`,"aria-labelledby":c,hidden:!n,id:l,tabIndex:0,...o,ref:t,style:{...e.style,animationDuration:d.current?`0s`:void 0},children:n&&a})})});Af.displayName=kf;function jf(e,t){return`${e}-trigger-${t}`}function Mf(e,t){return`${e}-content-${t}`}var Nf=wf,Pf=Ef,Ff=Of,If=Af,Lf=Nf,Rf=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(Pf,{ref:n,className:at(`inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground`,e),...t}));Rf.displayName=Pf.displayName;var zf=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(Ff,{ref:n,className:at(`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow`,e),...t}));zf.displayName=Ff.displayName;var Bf=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(If,{ref:n,className:at(`mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`,e),...t}));Bf.displayName=If.displayName;function Vf(e){let t=y.useRef({value:e,previous:e});return y.useMemo(()=>(t.current.value!==e&&(t.current.previous=t.current.value,t.current.value=e),t.current.previous),[e])}var Hf=`Checkbox`,[Uf,Wf]=lt(Hf),[Gf,Kf]=Uf(Hf);function qf(e){let{__scopeCheckbox:t,checked:n,children:r,defaultChecked:i,disabled:a,form:o,name:s,onCheckedChange:c,required:l,value:u=`on`,internal_do_not_use_render:d}=e,[f,p]=pt({prop:n,defaultProp:i??!1,onChange:c,caller:Hf}),[m,h]=y.useState(null),[g,_]=y.useState(null),v=y.useRef(!1),b=m?!!o||!!m.closest(`form`):!0,x={checked:f,disabled:a,setChecked:p,control:m,setControl:h,name:s,form:o,value:u,hasConsumerStoppedPropagationRef:v,required:l,defaultChecked:np(i)?!1:i,isFormControl:b,bubbleInput:g,setBubbleInput:_};return(0,T.jsx)(Gf,{scope:t,...x,children:tp(d)?d(x):r})}var Jf=`CheckboxTrigger`,Yf=y.forwardRef(({__scopeCheckbox:e,onKeyDown:t,onClick:n,...r},i)=>{let{control:a,value:o,disabled:s,checked:c,required:l,setControl:u,setChecked:d,hasConsumerStoppedPropagationRef:f,isFormControl:p,bubbleInput:m}=Kf(Jf,e),h=C(i,u),g=y.useRef(c);return y.useEffect(()=>{let e=a?.form;if(e){let t=()=>d(g.current);return e.addEventListener(`reset`,t),()=>e.removeEventListener(`reset`,t)}},[a,d]),(0,T.jsx)(J.button,{type:`button`,role:`checkbox`,"aria-checked":np(c)?`mixed`:c,"aria-required":l,"data-state":rp(c),"data-disabled":s?``:void 0,disabled:s,value:o,...r,ref:h,onKeyDown:q(t,e=>{e.key===`Enter`&&e.preventDefault()}),onClick:q(n,e=>{d(e=>np(e)?!0:!e),m&&p&&(f.current=e.isPropagationStopped(),f.current||e.stopPropagation())})})});Yf.displayName=Jf;var Xf=y.forwardRef((e,t)=>{let{__scopeCheckbox:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c,onCheckedChange:l,form:u,...d}=e;return(0,T.jsx)(qf,{__scopeCheckbox:n,checked:i,defaultChecked:a,disabled:s,required:o,onCheckedChange:l,name:r,form:u,value:c,internal_do_not_use_render:({isFormControl:e})=>(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Yf,{...d,ref:t,__scopeCheckbox:n}),e&&(0,T.jsx)(ep,{__scopeCheckbox:n})]})})});Xf.displayName=Hf;var Zf=`CheckboxIndicator`,Qf=y.forwardRef((e,t)=>{let{__scopeCheckbox:n,forceMount:r,...i}=e,a=Kf(Zf,n);return(0,T.jsx)(Zi,{present:r||np(a.checked)||a.checked===!0,children:(0,T.jsx)(J.span,{"data-state":rp(a.checked),"data-disabled":a.disabled?``:void 0,...i,ref:t,style:{pointerEvents:`none`,...e.style}})})});Qf.displayName=Zf;var $f=`CheckboxBubbleInput`,ep=y.forwardRef(({__scopeCheckbox:e,...t},n)=>{let{control:r,hasConsumerStoppedPropagationRef:i,checked:a,defaultChecked:o,required:s,disabled:c,name:l,value:u,form:d,bubbleInput:f,setBubbleInput:p}=Kf($f,e),m=C(n,p),h=Vf(a),g=Ti(r);y.useEffect(()=>{let e=f;if(!e)return;let t=window.HTMLInputElement.prototype,n=Object.getOwnPropertyDescriptor(t,`checked`).set,r=!i.current;if(h!==a&&n){let t=new Event(`click`,{bubbles:r});e.indeterminate=np(a),n.call(e,np(a)?!1:a),e.dispatchEvent(t)}},[f,h,a,i]);let _=y.useRef(np(a)?!1:a);return(0,T.jsx)(J.input,{type:`checkbox`,"aria-hidden":!0,defaultChecked:o??_.current,required:s,disabled:c,name:l,value:u,form:d,...t,tabIndex:-1,ref:m,style:{...t.style,...g,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0,transform:`translateX(-100%)`}})});ep.displayName=$f;function tp(e){return typeof e==`function`}function np(e){return e===`indeterminate`}function rp(e){return np(e)?`indeterminate`:e?`checked`:`unchecked`}function ip({className:e,...t}){return(0,T.jsx)(Xf,{"data-slot":`checkbox`,className:at(`peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...t,children:(0,T.jsx)(Qf,{"data-slot":`checkbox-indicator`,className:`flex items-center justify-center text-current transition-none`,children:(0,T.jsx)(Tl,{className:`size-3.5`})})})}var ap=Object.freeze({position:`absolute`,border:0,width:1,height:1,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,wordWrap:`normal`}),op=`VisuallyHidden`,sp=y.forwardRef((e,t)=>(0,T.jsx)(J.span,{...e,ref:t,style:{...ap,...e.style}}));sp.displayName=op;var cp=sp,[lp,up]=lt(`Tooltip`,[Oi]),dp=Oi(),fp=`TooltipProvider`,pp=700,mp=`tooltip.open`,[hp,gp]=lp(fp),_p=e=>{let{__scopeTooltip:t,delayDuration:n=pp,skipDelayDuration:r=300,disableHoverableContent:i=!1,children:a}=e,o=y.useRef(!0),s=y.useRef(!1),c=y.useRef(0);return y.useEffect(()=>{let e=c.current;return()=>window.clearTimeout(e)},[]),(0,T.jsx)(hp,{scope:t,isOpenDelayedRef:o,delayDuration:n,onOpen:y.useCallback(()=>{window.clearTimeout(c.current),o.current=!1},[]),onClose:y.useCallback(()=>{window.clearTimeout(c.current),c.current=window.setTimeout(()=>o.current=!0,r)},[r]),isPointerInTransitRef:s,onPointerInTransitChange:y.useCallback(e=>{s.current=e},[]),disableHoverableContent:i,children:a})};_p.displayName=fp;var vp=`Tooltip`,[yp,bp]=lp(vp),xp=e=>{let{__scopeTooltip:t,children:n,open:r,defaultOpen:i,onOpenChange:a,disableHoverableContent:o,delayDuration:s}=e,c=gp(vp,e.__scopeTooltip),l=dp(t),[u,d]=y.useState(null),f=rn(),p=y.useRef(0),m=o??c.disableHoverableContent,h=s??c.delayDuration,g=y.useRef(!1),[_,v]=pt({prop:r,defaultProp:i??!1,onChange:e=>{e?(c.onOpen(),document.dispatchEvent(new CustomEvent(mp))):c.onClose(),a?.(e)},caller:vp}),b=y.useMemo(()=>_?g.current?`delayed-open`:`instant-open`:`closed`,[_]),x=y.useCallback(()=>{window.clearTimeout(p.current),p.current=0,g.current=!1,v(!0)},[v]),S=y.useCallback(()=>{window.clearTimeout(p.current),p.current=0,v(!1)},[v]),C=y.useCallback(()=>{window.clearTimeout(p.current),p.current=window.setTimeout(()=>{g.current=!0,v(!0),p.current=0},h)},[h,v]);return y.useEffect(()=>()=>{p.current&&=(window.clearTimeout(p.current),0)},[]),(0,T.jsx)(Wi,{...l,children:(0,T.jsx)(yp,{scope:t,contentId:f,open:_,stateAttribute:b,trigger:u,onTriggerChange:d,onTriggerEnter:y.useCallback(()=>{c.isOpenDelayedRef.current?C():x()},[c.isOpenDelayedRef,C,x]),onTriggerLeave:y.useCallback(()=>{m?S():(window.clearTimeout(p.current),p.current=0)},[S,m]),onOpen:x,onClose:S,disableHoverableContent:m,children:n})})};xp.displayName=vp;var Sp=`TooltipTrigger`,Cp=y.forwardRef((e,t)=>{let{__scopeTooltip:n,...r}=e,i=bp(Sp,n),a=gp(Sp,n),o=dp(n),s=C(t,y.useRef(null),i.onTriggerChange),c=y.useRef(!1),l=y.useRef(!1),u=y.useCallback(()=>c.current=!1,[]);return y.useEffect(()=>()=>document.removeEventListener(`pointerup`,u),[u]),(0,T.jsx)(Gi,{asChild:!0,...o,children:(0,T.jsx)(J.button,{"aria-describedby":i.open?i.contentId:void 0,"data-state":i.stateAttribute,...r,ref:s,onPointerMove:q(e.onPointerMove,e=>{e.pointerType!==`touch`&&!l.current&&!a.isPointerInTransitRef.current&&(i.onTriggerEnter(),l.current=!0)}),onPointerLeave:q(e.onPointerLeave,()=>{i.onTriggerLeave(),l.current=!1}),onPointerDown:q(e.onPointerDown,()=>{i.open&&i.onClose(),c.current=!0,document.addEventListener(`pointerup`,u,{once:!0})}),onFocus:q(e.onFocus,()=>{c.current||i.onOpen()}),onBlur:q(e.onBlur,i.onClose),onClick:q(e.onClick,i.onClose)})})});Cp.displayName=Sp;var wp=`TooltipPortal`,[Tp,Ep]=lp(wp,{forceMount:void 0}),Dp=e=>{let{__scopeTooltip:t,forceMount:n,children:r,container:i}=e,a=bp(wp,t);return(0,T.jsx)(Tp,{scope:t,forceMount:n,children:(0,T.jsx)(Zi,{present:n||a.open,children:(0,T.jsx)(Yi,{asChild:!0,container:i,children:r})})})};Dp.displayName=wp;var Op=`TooltipContent`,kp=y.forwardRef((e,t)=>{let n=Ep(Op,e.__scopeTooltip),{forceMount:r=n.forceMount,side:i=`top`,...a}=e,o=bp(Op,e.__scopeTooltip);return(0,T.jsx)(Zi,{present:r||o.open,children:o.disableHoverableContent?(0,T.jsx)(Pp,{side:i,...a,ref:t}):(0,T.jsx)(Ap,{side:i,...a,ref:t})})}),Ap=y.forwardRef((e,t)=>{let n=bp(Op,e.__scopeTooltip),r=gp(Op,e.__scopeTooltip),i=y.useRef(null),a=C(t,i),[o,s]=y.useState(null),{trigger:c,onClose:l}=n,u=i.current,{onPointerInTransitChange:d}=r,f=y.useCallback(()=>{s(null),d(!1)},[d]),p=y.useCallback((e,t)=>{let n=e.currentTarget,r={x:e.clientX,y:e.clientY},i=Rp(r,Lp(r,n.getBoundingClientRect())),a=zp(t.getBoundingClientRect());s(Vp([...i,...a])),d(!0)},[d]);return y.useEffect(()=>()=>f(),[f]),y.useEffect(()=>{if(c&&u){let e=e=>p(e,u),t=e=>p(e,c);return c.addEventListener(`pointerleave`,e),u.addEventListener(`pointerleave`,t),()=>{c.removeEventListener(`pointerleave`,e),u.removeEventListener(`pointerleave`,t)}}},[c,u,p,f]),y.useEffect(()=>{if(o){let e=e=>{let t=e.target,n={x:e.clientX,y:e.clientY},r=c?.contains(t)||u?.contains(t),i=!Bp(n,o);r?f():i&&(f(),l())};return document.addEventListener(`pointermove`,e),()=>document.removeEventListener(`pointermove`,e)}},[c,u,o,l,f]),(0,T.jsx)(Pp,{...e,ref:a})}),[jp,Mp]=lp(vp,{isInside:!1}),Np=A(`TooltipContent`),Pp=y.forwardRef((e,t)=>{let{__scopeTooltip:n,children:r,"aria-label":i,onEscapeKeyDown:a,onPointerDownOutside:o,...s}=e,c=bp(Op,n),l=dp(n),{onClose:u}=c;return y.useEffect(()=>(document.addEventListener(mp,u),()=>document.removeEventListener(mp,u)),[u]),y.useEffect(()=>{if(c.trigger){let e=e=>{e.target?.contains(c.trigger)&&u()};return window.addEventListener(`scroll`,e,{capture:!0}),()=>window.removeEventListener(`scroll`,e,{capture:!0})}},[c.trigger,u]),(0,T.jsx)(kt,{asChild:!0,disableOutsidePointerEvents:!1,onEscapeKeyDown:a,onPointerDownOutside:o,onFocusOutside:e=>e.preventDefault(),onDismiss:u,children:(0,T.jsxs)(Ki,{"data-state":c.stateAttribute,...l,...s,ref:t,style:{...s.style,"--radix-tooltip-content-transform-origin":`var(--radix-popper-transform-origin)`,"--radix-tooltip-content-available-width":`var(--radix-popper-available-width)`,"--radix-tooltip-content-available-height":`var(--radix-popper-available-height)`,"--radix-tooltip-trigger-width":`var(--radix-popper-anchor-width)`,"--radix-tooltip-trigger-height":`var(--radix-popper-anchor-height)`},children:[(0,T.jsx)(Np,{children:r}),(0,T.jsx)(jp,{scope:n,isInside:!0,children:(0,T.jsx)(cp,{id:c.contentId,role:`tooltip`,children:i||r})})]})})});kp.displayName=Op;var Fp=`TooltipArrow`,Ip=y.forwardRef((e,t)=>{let{__scopeTooltip:n,...r}=e,i=dp(n);return Mp(Fp,n).isInside?null:(0,T.jsx)(qi,{...i,...r,ref:t})});Ip.displayName=Fp;function Lp(e,t){let n=Math.abs(t.top-e.y),r=Math.abs(t.bottom-e.y),i=Math.abs(t.right-e.x),a=Math.abs(t.left-e.x);switch(Math.min(n,r,i,a)){case a:return`left`;case i:return`right`;case n:return`top`;case r:return`bottom`;default:throw Error(`unreachable`)}}function Rp(e,t,n=5){let r=[];switch(t){case`top`:r.push({x:e.x-n,y:e.y+n},{x:e.x+n,y:e.y+n});break;case`bottom`:r.push({x:e.x-n,y:e.y-n},{x:e.x+n,y:e.y-n});break;case`left`:r.push({x:e.x+n,y:e.y-n},{x:e.x+n,y:e.y+n});break;case`right`:r.push({x:e.x-n,y:e.y-n},{x:e.x-n,y:e.y+n});break}return r}function zp(e){let{top:t,right:n,bottom:r,left:i}=e;return[{x:i,y:t},{x:n,y:t},{x:n,y:r},{x:i,y:r}]}function Bp(e,t){let{x:n,y:r}=e,i=!1;for(let e=0,a=t.length-1;er!=d>r&&n<(u-c)*(r-l)/(d-l)+c&&(i=!i)}return i}function Vp(e){let t=e.slice();return t.sort((e,t)=>e.xt.x?1:e.yt.y)),Hp(t)}function Hp(e){if(e.length<=1)return e.slice();let t=[];for(let n=0;n=2;){let e=t[t.length-1],n=t[t.length-2];if((e.x-n.x)*(r.y-n.y)>=(e.y-n.y)*(r.x-n.x))t.pop();else break}t.push(r)}t.pop();let n=[];for(let t=e.length-1;t>=0;t--){let r=e[t];for(;n.length>=2;){let e=n[n.length-1],t=n[n.length-2];if((e.x-t.x)*(r.y-t.y)>=(e.y-t.y)*(r.x-t.x))n.pop();else break}n.push(r)}return n.pop(),t.length===1&&n.length===1&&t[0].x===n[0].x&&t[0].y===n[0].y?t:t.concat(n)}var Up=_p,Wp=xp,Gp=Cp,Kp=Dp,qp=kp,Jp=Up,Yp=Wp,Xp=Gp,Zp=y.forwardRef(({className:e,sideOffset:t=4,...n},r)=>(0,T.jsx)(Kp,{children:(0,T.jsx)(qp,{ref:r,sideOffset:t,className:at(`z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,e),...n})}));Zp.displayName=qp.displayName;var Qp={MODEL:`gen_ai.request.model`,MAX_TOKENS:`gen_ai.request.max_tokens`,TEMPERATURE:`gen_ai.request.temperature`,TOP_P:`gen_ai.request.top_p`,SEED:`gen_ai.request.seed`,FREQUENCY_PENALTY:`gen_ai.request.frequency_penalty`,PRESENCE_PENALTY:`gen_ai.request.presence_penalty`,STOP_SEQUENCES:`gen_ai.request.stop_sequences`,FINISH_REASONS:`gen_ai.response.finish_reasons`,RESPONSE_ID:`gen_ai.response.id`,INPUT_TOKENS:`gen_ai.usage.input_tokens`,OUTPUT_TOKENS:`gen_ai.usage.output_tokens`,INPUT_MESSAGES:`gen_ai.input.messages`,OUTPUT_MESSAGES:`gen_ai.output.messages`,SYSTEM_INSTRUCTIONS:`gen_ai.system_instructions`,OUTPUT_TYPE:`gen_ai.output.type`,TOOL_CALL_ID:`gen_ai.tool.call.id`,TOOL_NAME:`gen_ai.tool.name`,TOOL_TYPE:`gen_ai.tool.type`,TOOL_DEFINITIONS:`gen_ai.tool.definitions`,TOOL_ARGUMENTS:`gen_ai.tool.call.arguments`,TOOL_RESULT:`gen_ai.tool.call.result`,AGENT_ID:`gen_ai.agent.id`,AGENT_NAME:`gen_ai.agent.name`,AGENT_DESCRIPTION:`gen_ai.agent.description`,CONVERSATION_ID:`gen_ai.conversation.id`,WORKFLOW_ID:`workflow.id`,WORKFLOW_NAME:`workflow.name`,EXECUTOR_ID:`executor.id`,EXECUTOR_TYPE:`executor.type`};function $p(e){return e.type===`text`}function em(e){return e.type===`tool_call`||e.type===`function_call`}function tm(e){return e.type===`tool_result`||e.type===`function_result`||e.type===`tool_call_response`}function nm(e){if(!e)return[];try{return JSON.parse(e)}catch{return[]}}Td();function rm(e){let t={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};try{let n;if(typeof e==`string`)n=nm(e);else if(Array.isArray(e))n=e;else return t;for(let e of n){if(!e||typeof e!=`object`)continue;let n=e.role,r=e.parts,i=0;if(Array.isArray(r)){for(let e of r)if(!(!e||typeof e!=`object`)){if($p(e)){let t=e.content||e.text||``;i+=t.length}else if(em(e)){let n=e.name||``,r=e.arguments||``;t.toolCalls+=n.length+r.length}else if(tm(e)){let n=e.result||e.response||``;t.toolResults+=n.length}}}n===`system`?t.system+=i:n===`user`?t.user+=i:n===`assistant`?t.assistant+=i:n===`tool`&&(t.toolResults+=i)}t.total=t.system+t.user+t.assistant+t.toolCalls+t.toolResults}catch{}return t}function im(e){let t=e.filter(e=>e.type===`response.trace.completed`),n=new Map;for(let e of t){if(!(`data`in e))continue;let t=e.data,r=t.response_id||`unknown`;n.has(r)||n.set(r,[]),n.get(r).push(t)}let r=[];for(let[e,t]of n){let n=0,i=0,a,o=Date.now()/1e3,s,c=0,l={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};for(let e of t){let t=e.attributes||{},r=t[Qp.INPUT_TOKENS],u=t[Qp.OUTPUT_TOKENS];r!==void 0&&(n+=Number(r)),u!==void 0&&(i+=Number(u)),t[Qp.MODEL]&&(a=String(t[Qp.MODEL])),e.start_time&&e.start_time0||i>0)&&r.push({response_id:e,timestamp:o,input_tokens:n,output_tokens:i,total_tokens:n+i,model:a,entity_id:s,duration_ms:c,composition:l})}return r.sort((e,t)=>e.timestamp-t.timestamp),r}function am(e){if(e.length===0)return{totalInput:0,totalOutput:0,totalTokens:0,avgInput:0,avgOutput:0,avgTotal:0,peakInput:0,peakOutput:0,peakTotal:0,turnCount:0};let t=e.reduce((e,t)=>e+t.input_tokens,0),n=e.reduce((e,t)=>e+t.output_tokens,0),r=t+n,i=Math.max(...e.map(e=>e.input_tokens)),a=Math.max(...e.map(e=>e.output_tokens)),o=Math.max(...e.map(e=>e.total_tokens));return{totalInput:t,totalOutput:n,totalTokens:r,avgInput:Math.round(t/e.length),avgOutput:Math.round(n/e.length),avgTotal:Math.round(r/e.length),peakInput:i,peakOutput:a,peakTotal:o,turnCount:e.length}}function om(e){return e.reduce((e,t)=>({system:e.system+t.composition.system,user:e.user+t.composition.user,assistant:e.assistant+t.composition.assistant,toolCalls:e.toolCalls+t.composition.toolCalls,toolResults:e.toolResults+t.composition.toolResults,total:e.total+t.composition.total}),{system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0})}function sm(e){return e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}var cm={input:`bg-blue-500 dark:bg-blue-600`,output:`bg-emerald-500 dark:bg-emerald-600`,system:`bg-purple-500 dark:bg-purple-600`,user:`bg-blue-500 dark:bg-blue-600`,assistant:`bg-emerald-500 dark:bg-emerald-600`,toolCalls:`bg-amber-500 dark:bg-amber-600`,toolResults:`bg-orange-500 dark:bg-orange-600`};function lm({segments:e,maxValue:t,height:n=20,renderLabel:r}){let i=e.reduce((e,t)=>e+t.value,0);if(i===0)return(0,T.jsx)(`div`,{className:`flex items-center gap-2 w-full`,children:(0,T.jsx)(`div`,{className:`rounded bg-muted/30 flex-1`,style:{height:`${n}px`}})});let a=t>0?i/t*100:100,o=e.filter(e=>e.value>0).map(e=>({...e,percent:Math.round(e.value/i*100)}));return(0,T.jsxs)(`div`,{className:`flex items-center gap-2 w-full`,children:[(0,T.jsx)(`div`,{className:`relative rounded overflow-hidden bg-muted/30 flex-1`,style:{height:`${n}px`},children:(0,T.jsx)(Jp,{delayDuration:150,children:(0,T.jsx)(`div`,{className:`h-full flex transition-all duration-300`,style:{width:`${a}%`},children:o.map(e=>(0,T.jsxs)(Yp,{children:[(0,T.jsx)(Xp,{asChild:!0,children:(0,T.jsx)(`div`,{className:`h-full ${e.color} transition-all duration-150 hover:brightness-110 hover:scale-y-[1.15] origin-bottom cursor-default`,style:{width:`${e.value/i*100}%`}})}),(0,T.jsx)(Zp,{side:`top`,className:`text-xs`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2 h-2 rounded-sm ${e.color} flex-shrink-0`}),(0,T.jsx)(`span`,{className:`font-medium`,children:e.label}),(0,T.jsxs)(`span`,{className:`opacity-80`,children:[sm(e.value),` (`,e.percent,`%)`]})]})})]},e.key))})})}),r?.(i,e)]})}function um(e,t){return[{key:`input`,value:e,color:cm.input,label:`Input`},{key:`output`,value:t,color:cm.output,label:`Output`}]}function dm(e){return[{key:`system`,value:e.system,color:cm.system,label:`System`},{key:`user`,value:e.user,color:cm.user,label:`User`},{key:`assistant`,value:e.assistant,color:cm.assistant,label:`Assistant`},{key:`toolCalls`,value:e.toolCalls,color:cm.toolCalls,label:`Tool Calls`},{key:`toolResults`,value:e.toolResults,color:cm.toolResults,label:`Tool Results`}]}function fm({composition:e,className:t=``}){let{system:n,user:r,assistant:i,toolCalls:a,toolResults:o,total:s}=e;if(s===0)return(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground ${t}`,children:`No composition data available`});let c=[{label:`System`,value:n,color:cm.system},{label:`User`,value:r,color:cm.user},{label:`Assistant`,value:i,color:cm.assistant},{label:`Tool Calls`,value:a,color:cm.toolCalls},{label:`Tool Results`,value:o,color:cm.toolResults}].filter(e=>e.value>0);return(0,T.jsx)(`div`,{className:`space-y-1.5 ${t}`,children:c.map(e=>{let t=Math.round(e.value/s*100);return(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs`,children:[(0,T.jsx)(`div`,{className:`w-2 h-2 rounded-sm ${e.color}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground w-20`,children:e.label}),(0,T.jsx)(`div`,{className:`flex-1 h-3 bg-muted/30 rounded overflow-hidden`,children:(0,T.jsx)(`div`,{className:`h-full ${e.color} transition-all duration-300`,style:{width:`${t}%`}})}),(0,T.jsxs)(`span`,{className:`font-mono w-10 text-right text-muted-foreground`,children:[t,`%`]})]},e.label)})})}function pm({turn:e,index:t,maxValue:n,maxCompositionValue:r,cumulativeInput:i,cumulativeOutput:a,cumulativeComposition:o,showCumulative:s,viewMode:c}){let[l,u]=(0,y.useState)(!1),d=s?i:e.input_tokens,f=s?a:e.output_tokens,p=s?o:e.composition,m=new Date(e.timestamp*1e3).toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`,second:`2-digit`});return(0,T.jsxs)(`div`,{className:`border-b border-muted/50 last:border-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-3 py-2 px-2 hover:bg-muted/30 cursor-pointer transition-colors`,onClick:()=>u(!l),children:[(0,T.jsx)(`div`,{className:`w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium flex-shrink-0`,children:t+1}),(0,T.jsx)(`div`,{className:`flex-1 min-w-0`,children:c===`tokens`?(0,T.jsx)(lm,{segments:um(d,f),maxValue:n,height:20,renderLabel:(e,t)=>(0,T.jsxs)(`div`,{className:`flex items-center gap-1 text-xs font-mono text-muted-foreground min-w-[80px] justify-end`,children:[(0,T.jsxs)(`span`,{className:`text-blue-600 dark:text-blue-400`,children:[`↑`,sm(t[0]?.value||0)]}),(0,T.jsx)(`span`,{children:`/`}),(0,T.jsxs)(`span`,{className:`text-emerald-600 dark:text-emerald-400`,children:[`↓`,sm(t[1]?.value||0)]})]})}):(0,T.jsx)(lm,{segments:dm(p),maxValue:r,height:20,renderLabel:e=>(0,T.jsxs)(`div`,{className:`text-xs font-mono text-muted-foreground min-w-[50px] text-right`,children:[sm(Math.round(e/4)),`~`]})})}),(0,T.jsx)(`div`,{className:`text-muted-foreground flex-shrink-0`,children:l?(0,T.jsx)(X,{className:`h-4 w-4`}):(0,T.jsx)(Z,{className:`h-4 w-4`})})]}),l&&(0,T.jsx)(`div`,{className:`pb-3`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-3 px-2`,children:[(0,T.jsx)(`div`,{className:`w-6 flex justify-center flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`w-px h-full bg-muted`})}),(0,T.jsx)(`div`,{className:`flex-1 min-w-0`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,T.jsx)(`div`,{className:`text-muted-foreground text-xs mt-1`,children:`└─`}),(0,T.jsxs)(`div`,{className:`flex-1 space-y-3`,children:[(0,T.jsxs)(`div`,{className:`grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground`,children:[(0,T.jsxs)(`div`,{children:[`Time: `,(0,T.jsx)(`span`,{className:`font-mono text-foreground`,children:m})]}),(0,T.jsxs)(`div`,{children:[`Duration: `,(0,T.jsxs)(`span`,{className:`font-mono text-foreground`,children:[e.duration_ms.toFixed(0),`ms`]})]}),e.model&&(0,T.jsxs)(`div`,{children:[`Model: `,(0,T.jsx)(`span`,{className:`font-mono text-foreground`,children:e.model})]}),e.entity_id&&(0,T.jsxs)(`div`,{children:[`Entity: `,(0,T.jsx)(`span`,{className:`font-mono text-foreground`,children:e.entity_id})]})]}),c===`tokens`&&(0,T.jsxs)(`div`,{className:`flex gap-4 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-blue-600 dark:text-blue-400`,children:`Input:`}),` `,(0,T.jsx)(`span`,{className:`font-mono`,children:e.input_tokens.toLocaleString()})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-emerald-600 dark:text-emerald-400`,children:`Output:`}),` `,(0,T.jsx)(`span`,{className:`font-mono`,children:e.output_tokens.toLocaleString()})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Total:`}),` `,(0,T.jsx)(`span`,{className:`font-mono`,children:e.total_tokens.toLocaleString()})]})]}),c===`composition`&&e.composition.total>0&&(0,T.jsxs)(`div`,{children:[(0,T.jsxs)(`div`,{className:`text-xs text-muted-foreground mb-2 flex items-center gap-1`,children:[(0,T.jsx)(Jl,{className:`h-3 w-3`}),`Context Composition (estimated from ~`,sm(Math.round(e.composition.total/4)),` tokens)`]}),(0,T.jsx)(fm,{composition:e.composition})]})]})]})})]})})]})}function mm({label:e,value:t,icon:n,color:r=`default`}){let i={default:`text-muted-foreground`,blue:`text-blue-600 dark:text-blue-400`,green:`text-emerald-600 dark:text-emerald-400`}[r];return(0,T.jsxs)(`div`,{className:`flex items-center gap-2 p-2 bg-muted/30 rounded`,children:[(0,T.jsx)(n,{className:`h-4 w-4 ${i}`}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground truncate`,children:e}),(0,T.jsx)(`div`,{className:`font-mono text-sm font-medium`,children:t})]})]})}function hm({events:e}){let t=Q(e=>e.contextInspectorViewMode),n=Q(e=>e.setContextInspectorViewMode),r=Q(e=>e.contextInspectorCumulative),i=Q(e=>e.setContextInspectorCumulative),a=(0,y.useMemo)(()=>im(e),[e]),o=(0,y.useMemo)(()=>am(a),[a]),s=(0,y.useMemo)(()=>om(a),[a]),c=(0,y.useMemo)(()=>a.length===0?0:r?o.totalTokens:0,[a,r,o.totalTokens]),l=(0,y.useMemo)(()=>a.length===0?0:r?s.total:0,[a,r,s.total]),u=(0,y.useMemo)(()=>{let e=0,t=0,n={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};return a.map(r=>(e+=r.input_tokens,t+=r.output_tokens,n={system:n.system+r.composition.system,user:n.user+r.composition.user,assistant:n.assistant+r.composition.assistant,toolCalls:n.toolCalls+r.composition.toolCalls,toolResults:n.toolResults+r.composition.toolResults,total:n.total+r.composition.total},{input:e,output:t,composition:{...n}}))},[a]);return a.length===0?(0,T.jsxs)(`div`,{className:`flex flex-col items-center text-center p-6 pt-9`,children:[(0,T.jsx)(Cl,{className:`h-8 w-8 text-muted-foreground mb-3`}),(0,T.jsx)(`div`,{className:`text-sm font-medium mb-1`,children:`No Data`}),(0,T.jsxs)(`div`,{className:`text-xs text-muted-foreground max-w-[200px]`,children:[`Run`,` `,(0,T.jsx)(`span`,{className:`font-mono bg-accent/10 px-1 rounded`,children:`devui --instrumentation`}),` `,`and start a conversation.`]})]}):(0,T.jsxs)(`div`,{className:`h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`p-3 border-b flex-shrink-0 space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Cl,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`Context Inspector`}),(0,T.jsxs)(ct,{variant:`outline`,className:`text-xs`,children:[a.length,` turn`,a.length===1?``:`s`]})]}),(0,T.jsxs)(`label`,{className:`flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer`,children:[(0,T.jsx)(ip,{checked:r,onCheckedChange:e=>i(e===!0),className:`h-3.5 w-3.5`}),(0,T.jsx)(`span`,{children:`Cumulative`})]})]}),(0,T.jsxs)(`div`,{className:`flex items-center bg-muted rounded-md p-1`,children:[(0,T.jsx)(`button`,{onClick:()=>n(`tokens`),className:`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${t===`tokens`?`bg-background shadow-sm font-medium`:`text-muted-foreground hover:text-foreground`}`,children:`Tokens`}),(0,T.jsx)(`button`,{onClick:()=>n(`composition`),className:`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${t===`composition`?`bg-background shadow-sm font-medium`:`text-muted-foreground hover:text-foreground`}`,children:`Composition`})]}),(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:t===`tokens`?`Token usage per turn`:`Context breakdown by message type (chars)`})]}),(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsxs)(`div`,{className:`p-3 space-y-4`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-4 text-xs px-1 flex-wrap`,children:[t===`tokens`?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-3 h-3 rounded ${cm.input}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Input (↑)`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-3 h-3 rounded ${cm.output}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Output (↓)`})]})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.system}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`System`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.user}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`User`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.assistant}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Assistant`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.toolCalls}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Tools`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.toolResults}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Results`})]})]}),(0,T.jsx)(`div`,{className:`flex-1`}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1 text-muted-foreground`,children:[(0,T.jsx)(Jl,{className:`h-3 w-3`}),(0,T.jsx)(`span`,{children:`Click for details`})]})]}),(0,T.jsx)(`div`,{className:`border rounded-lg overflow-hidden`,children:a.map((e,n)=>(0,T.jsx)(pm,{turn:e,index:n,maxValue:c,maxCompositionValue:l,cumulativeInput:u[n]?.input||0,cumulativeOutput:u[n]?.output||0,cumulativeComposition:u[n]?.composition||e.composition,showCumulative:r,viewMode:t},e.response_id))}),(0,T.jsxs)(`div`,{className:`border rounded-lg overflow-hidden`,children:[(0,T.jsx)(`div`,{className:`p-3 bg-muted/30 border-b`,children:(0,T.jsx)(`span`,{className:`text-xs font-medium`,children:`Session Summary`})}),(0,T.jsxs)(`div`,{className:`p-3 space-y-3`,children:[(0,T.jsxs)(`div`,{className:`grid grid-cols-3 gap-2`,children:[(0,T.jsx)(mm,{label:`Total Tokens`,value:sm(o.totalTokens),icon:Xl}),(0,T.jsx)(mm,{label:`Input`,value:sm(o.totalInput),icon:Cl,color:`blue`}),(0,T.jsx)(mm,{label:`Output`,value:sm(o.totalOutput),icon:Cl,color:`green`})]}),a.length>1&&(0,T.jsxs)(`div`,{className:`grid grid-cols-2 gap-x-4 gap-y-1 text-xs pt-2 border-t border-muted/50`,children:[(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Avg per turn:`}),(0,T.jsx)(`span`,{className:`font-mono`,children:sm(o.avgTotal)})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Peak turn:`}),(0,T.jsx)(`span`,{className:`font-mono`,children:sm(o.peakTotal)})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Avg input:`}),(0,T.jsx)(`span`,{className:`font-mono text-blue-600 dark:text-blue-400`,children:sm(o.avgInput)})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Avg output:`}),(0,T.jsx)(`span`,{className:`font-mono text-emerald-600 dark:text-emerald-400`,children:sm(o.avgOutput)})]})]}),s.total>0&&(0,T.jsx)(`div`,{className:`pt-3 border-t border-muted/50`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,T.jsx)(`div`,{className:`text-muted-foreground text-xs mt-0.5`,children:`└─`}),(0,T.jsxs)(`div`,{className:`flex-1`,children:[(0,T.jsxs)(`div`,{className:`text-xs text-muted-foreground mb-2 flex items-center gap-1`,children:[(0,T.jsx)(Jl,{className:`h-3 w-3`}),`Total Composition (all turns)`]}),(0,T.jsx)(fm,{composition:s})]})]})})]})]})]})})]})}Td();function gm(){return(0,T.jsx)(`div`,{className:`flex items-center gap-2 py-3 px-2`,children:(0,T.jsx)(`div`,{className:`flex-1 border-t border-border/50`})})}function _m(e){let t=[],n=!1;for(let r=0;r100&&(i.includes(` + `);let t=e.state[s];if(t==null)return;JSON.stringify(i.getState())!==JSON.stringify(t)&&p(t);return}i.dispatchFromDevtools&&typeof i.dispatch==`function`&&i.dispatch(e)});case`DISPATCH`:switch(e.payload.type){case`RESET`:return p(m),s===void 0?u?.init(i.getState()):u?.init(dd(c.name));case`COMMIT`:if(s===void 0){u?.init(i.getState());return}return u?.init(dd(c.name));case`ROLLBACK`:return _d(e.state,e=>{if(s===void 0){p(e),u?.init(i.getState());return}p(e[s]),u?.init(dd(c.name))});case`JUMP_TO_STATE`:case`JUMP_TO_ACTION`:return _d(e.state,e=>{if(s===void 0){p(e);return}JSON.stringify(i.getState())!==JSON.stringify(e[s])&&p(e[s])});case`IMPORT_STATE`:{let{nextLiftedState:t}=e.payload,n=t.computedStates.slice(-1)[0]?.state;if(!n)return;p(s===void 0?n:n[s]),u?.send(null,t);return}case`PAUSE_RECORDING`:return f=!f}return}}),m},gd=hd,_d=(e,t)=>{let n;try{n=JSON.parse(e)}catch(e){console.error(`[zustand devtools middleware] Could not parse the received json`,e)}n!==void 0&&t(n)},vd=e=>t=>{try{let n=e(t);return n instanceof Promise?n:{then(e){return vd(e)(n)},catch(e){return this}}}catch(e){return{then(e){return this},catch(t){return vd(t)(e)}}}},yd=(e,t)=>(n,r,i)=>{let a={storage:ld(()=>localStorage),partialize:e=>e,version:0,merge:(e,t)=>({...t,...e}),...t},o=!1,s=new Set,c=new Set,l=a.storage;if(!l)return e((...e)=>{console.warn(`[zustand persist middleware] Unable to update item '${a.name}', the given storage is currently unavailable.`),n(...e)},r,i);let u=()=>{let e=a.partialize({...r()});return l.setItem(a.name,{state:e,version:a.version})},d=i.setState;i.setState=(e,t)=>(d(e,t),u());let f=e((...e)=>(n(...e),u()),r,i);i.getInitialState=()=>f;let p,m=()=>{if(!l)return;o=!1,s.forEach(e=>e(r()??f));let e=a.onRehydrateStorage?.call(a,r()??f)||void 0;return vd(l.getItem.bind(l))(a.name).then(e=>{if(e)if(typeof e.version==`number`&&e.version!==a.version){if(a.migrate){let t=a.migrate(e.state,e.version);return t instanceof Promise?t.then(e=>[!0,e]):[!0,t]}console.error(`State loaded from storage couldn't be migrated since no migrate function was provided`)}else return[!1,e.state];return[!1,void 0]}).then(e=>{let[t,i]=e;if(p=a.merge(i,r()??f),n(p,!0),t)return u()}).then(()=>{e?.(p,void 0),p=r(),o=!0,c.forEach(e=>e(p))}).catch(t=>{e?.(void 0,t)})};return i.persist={setOptions:e=>{a={...a,...e},e.storage&&(l=e.storage)},clearStorage:()=>{l?.removeItem(a.name)},getOptions:()=>a,rehydrate:()=>m(),hasHydrated:()=>o,onHydrate:e=>(s.add(e),()=>{s.delete(e)}),onFinishHydration:e=>(c.add(e),()=>{c.delete(e)})},a.skipHydration||m(),p||f},bd=yd}));function Sd(e){if(e.type!==`response.output_text.delta`||!(`delta`in e)||typeof e.delta!=`string`||e.delta.length<=wd)return e;let t=e.delta.length-wd;return{...e,delta:`${e.delta.slice(0,wd)}\n...[${t} chars omitted from debug view]`}}var Cd,wd,Q,Td=o((()=>{cd(),xd(),Cd=1e3,wd=2048,Q=od()(gd(bd(e=>({agents:[],workflows:[],entities:[],selectedAgent:void 0,isLoadingEntities:!0,entityError:null,currentConversation:void 0,availableConversations:[],chatItems:[],isStreaming:!1,isSubmitting:!1,loadingConversations:!1,inputValue:``,attachments:[],conversationUsage:{total_tokens:0,message_count:0},pendingApprovals:[],currentSession:void 0,availableSessions:[],sessionCheckpoints:[],loadingSessions:!1,loadingCheckpoints:!1,showDebugPanel:!0,debugPanelMinimized:!1,debugPanelWidth:320,debugEvents:[],isResizing:!1,showToolCalls:!0,streamingEnabled:!0,debugPanelTab:`events`,debugTraceSubTab:`spans`,contextInspectorViewMode:`tokens`,contextInspectorCumulative:!1,showAboutModal:!1,showGallery:!1,showDeployModal:!1,showEntityNotFoundToast:!1,toasts:[],oaiMode:{enabled:!1,model:`gpt-4o-mini`},uiMode:`developer`,runtime:`python`,serverCapabilities:{instrumentation:!1,trace_retrieval:!1,openai_proxy:!1,deployment:!1},authRequired:!1,serverVersion:null,isDeploying:!1,deploymentLogs:[],lastDeployment:null,azureDeploymentEnabled:!1,setAgents:t=>e({agents:t}),setWorkflows:t=>e({workflows:t}),setEntities:t=>e({entities:t}),setSelectedAgent:t=>e({selectedAgent:t}),addAgent:t=>e(e=>({agents:[...e.agents,t]})),addWorkflow:t=>e(e=>({workflows:[...e.workflows,t]})),updateAgent:t=>e(e=>({agents:e.agents.map(e=>e.id===t.id?t:e),selectedAgent:e.selectedAgent?.id===t.id&&e.selectedAgent.type===`agent`?t:e.selectedAgent})),updateWorkflow:t=>e(e=>({workflows:e.workflows.map(e=>e.id===t.id?t:e),selectedAgent:e.selectedAgent?.id===t.id&&e.selectedAgent.type===`workflow`?t:e.selectedAgent})),removeEntity:t=>e(e=>({agents:e.agents.filter(e=>e.id!==t),workflows:e.workflows.filter(e=>e.id!==t),selectedAgent:e.selectedAgent?.id===t?void 0:e.selectedAgent})),setEntityError:t=>e({entityError:t}),setIsLoadingEntities:t=>e({isLoadingEntities:t}),setCurrentConversation:t=>e({currentConversation:t}),setAvailableConversations:t=>e({availableConversations:t}),setChatItems:t=>e({chatItems:t}),setIsStreaming:t=>e({isStreaming:t}),setIsSubmitting:t=>e({isSubmitting:t}),setLoadingConversations:t=>e({loadingConversations:t}),setInputValue:t=>e({inputValue:t}),setAttachments:t=>e({attachments:t}),updateConversationUsage:t=>e(e=>({conversationUsage:{total_tokens:e.conversationUsage.total_tokens+t,message_count:e.conversationUsage.message_count+1}})),setPendingApprovals:t=>e({pendingApprovals:t}),setCurrentSession:t=>e({currentSession:t}),setAvailableSessions:t=>e({availableSessions:t}),setSessionCheckpoints:t=>e({sessionCheckpoints:t}),setLoadingSessions:t=>e({loadingSessions:t}),setLoadingCheckpoints:t=>e({loadingCheckpoints:t}),addSession:t=>e(e=>({availableSessions:[t,...e.availableSessions]})),removeSession:t=>e(e=>({availableSessions:e.availableSessions.filter(e=>e.conversation_id!==t),currentSession:e.currentSession?.conversation_id===t?void 0:e.currentSession,sessionCheckpoints:e.currentSession?.conversation_id===t?[]:e.sessionCheckpoints})),setShowDebugPanel:t=>e({showDebugPanel:t}),setDebugPanelMinimized:t=>e({debugPanelMinimized:t}),setDebugPanelWidth:t=>e({debugPanelWidth:t}),setShowToolCalls:t=>e({showToolCalls:t}),setStreamingEnabled:t=>e({streamingEnabled:t}),addDebugEvent:t=>e(e=>{let n=Sd(t),r=Math.floor(Date.now()/1e3),i=(e.debugEvents.length>0?e.debugEvents[e.debugEvents.length-1]:null)?._uiTimestamp??0,a=Math.max(r,i+1);return{debugEvents:[...e.debugEvents.length>=Cd?e.debugEvents.slice(-999):e.debugEvents,{...n,_uiTimestamp:`created_at`in n&&n.created_at?n.created_at:a}]}}),clearDebugEvents:()=>e({debugEvents:[]}),setIsResizing:t=>e({isResizing:t}),setDebugPanelTab:t=>e({debugPanelTab:t}),setDebugTraceSubTab:t=>e({debugTraceSubTab:t}),setContextInspectorViewMode:t=>e({contextInspectorViewMode:t}),setContextInspectorCumulative:t=>e({contextInspectorCumulative:t}),setShowAboutModal:t=>e({showAboutModal:t}),setShowGallery:t=>e({showGallery:t}),setShowDeployModal:t=>e({showDeployModal:t}),setShowEntityNotFoundToast:t=>e({showEntityNotFoundToast:t}),addToast:t=>e(e=>({toasts:[...e.toasts,{id:`toast-${Date.now()}-${Math.random().toString(36).substr(2,9)}`,type:t.type||`info`,duration:t.duration||4e3,...t}]})),removeToast:t=>e(e=>({toasts:e.toasts.filter(e=>e.id!==t)})),setOAIMode:t=>e(e=>t.enabled&&!e.oaiMode.enabled||!t.enabled&&e.oaiMode.enabled?(Object.keys(localStorage).forEach(e=>{e.startsWith(`devui_convs_`)&&localStorage.removeItem(e)}),{oaiMode:t,currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:``,attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],debugEvents:[]}):{oaiMode:t}),toggleOAIMode:()=>e(e=>{let t=!e.oaiMode.enabled;return{oaiMode:{...e.oaiMode,enabled:t},currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:``,attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],debugEvents:[]}}),setServerMeta:t=>e({uiMode:t.uiMode,runtime:t.runtime,serverCapabilities:t.capabilities,authRequired:t.authRequired,serverVersion:t.version||null}),startDeployment:()=>e({isDeploying:!0,deploymentLogs:[],lastDeployment:null}),addDeploymentLog:t=>e(e=>({deploymentLogs:[...e.deploymentLogs,t]})),setDeploymentResult:t=>e({isDeploying:!1,lastDeployment:t}),stopDeployment:()=>e({isDeploying:!1}),clearDeploymentState:()=>e({isDeploying:!1,deploymentLogs:[],lastDeployment:null}),setAzureDeploymentEnabled:t=>e({azureDeploymentEnabled:t}),selectEntity:t=>{e({selectedAgent:t,currentConversation:void 0,availableConversations:[],chatItems:[],inputValue:``,attachments:[],conversationUsage:{total_tokens:0,message_count:0},isStreaming:!1,isSubmitting:!1,pendingApprovals:[],currentSession:void 0,availableSessions:[],sessionCheckpoints:[],debugEvents:[]});let n=new URL(window.location.href);n.searchParams.set(`entity_id`,t.id),window.history.pushState({},``,n)}}),{name:`devui-storage`,partialize:e=>({showDebugPanel:e.showDebugPanel,debugPanelMinimized:e.debugPanelMinimized,debugPanelWidth:e.debugPanelWidth,showToolCalls:e.showToolCalls,streamingEnabled:e.streamingEnabled,oaiMode:e.oaiMode,azureDeploymentEnabled:e.azureDeploymentEnabled,debugPanelTab:e.debugPanelTab,debugTraceSubTab:e.debugTraceSubTab,contextInspectorViewMode:e.contextInspectorViewMode,contextInspectorCumulative:e.contextInspectorCumulative})}),{name:`DevUI Store`}))})),Ed=c({useDevUIStore:()=>Q}),Dd=o((()=>{Td()}));Dd();function Od({agents:e,workflows:t,entities:n,selectedItem:r,onSelect:i,onBrowseGallery:a,isLoading:o=!1,onSettingsClick:s}){let{oaiMode:c,serverVersion:l}=Q();return(0,T.jsxs)(`header`,{className:`flex h-14 items-center gap-4 border-b px-4`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 font-semibold`,children:[(0,T.jsxs)(`svg`,{width:`24`,height:`24`,viewBox:`0 0 805 805`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`,className:`flex-shrink-0`,children:[(0,T.jsx)(`path`,{d:`M402.488 119.713C439.197 119.713 468.955 149.472 468.955 186.18C468.955 192.086 471.708 197.849 476.915 200.635L546.702 237.977C555.862 242.879 566.95 240.96 576.092 236.023C585.476 230.955 596.218 228.078 607.632 228.078C644.341 228.078 674.098 257.836 674.099 294.545C674.099 316.95 663.013 336.765 646.028 348.806C637.861 354.595 631.412 363.24 631.412 373.251V430.818C631.412 440.83 637.861 449.475 646.028 455.264C663.013 467.305 674.099 487.121 674.099 509.526C674.099 546.235 644.341 575.994 607.632 575.994C598.598 575.994 589.985 574.191 582.133 570.926C573.644 567.397 563.91 566.393 555.804 570.731L469.581 616.867C469.193 617.074 468.955 617.479 468.955 617.919C468.955 654.628 439.197 684.386 402.488 684.386C365.779 684.386 336.021 654.628 336.021 617.919C336.021 616.802 335.423 615.765 334.439 615.238L249.895 570C241.61 565.567 231.646 566.713 223.034 570.472C214.898 574.024 205.914 575.994 196.47 575.994C159.761 575.994 130.002 546.235 130.002 509.526C130.002 486.66 141.549 466.49 159.13 454.531C167.604 448.766 174.349 439.975 174.349 429.726V372.538C174.349 362.289 167.604 353.498 159.13 347.734C141.549 335.774 130.002 315.604 130.002 292.738C130.002 256.029 159.761 226.271 196.47 226.271C208.223 226.271 219.263 229.322 228.843 234.674C238.065 239.827 249.351 241.894 258.666 236.91L328.655 199.459C333.448 196.895 336.021 191.616 336.021 186.18C336.021 149.471 365.779 119.713 402.488 119.713ZM475.716 394.444C471.337 396.787 468.955 401.586 468.955 406.552C468.955 429.68 457.142 450.048 439.221 461.954C430.571 467.7 423.653 476.574 423.653 486.959V537.511C423.653 547.896 430.746 556.851 439.379 562.622C449 569.053 461.434 572.052 471.637 566.592L527.264 536.826C536.887 531.677 541.164 520.44 541.164 509.526C541.164 485.968 553.42 465.272 571.904 453.468C580.846 447.757 588.054 438.749 588.054 428.139V371.427C588.054 363.494 582.671 356.676 575.716 352.862C569.342 349.366 561.663 348.454 555.253 351.884L475.716 394.444ZM247.992 349.841C241.997 346.633 234.806 347.465 228.873 350.785C222.524 354.337 217.706 360.639 217.706 367.915V429.162C217.706 439.537 224.611 448.404 233.248 454.152C251.144 466.062 262.937 486.417 262.937 509.526C262.937 519.654 267.026 529.991 275.955 534.769L334.852 566.284C344.582 571.49 356.362 568.81 365.528 562.667C373.735 557.166 380.296 548.643 380.296 538.764V486.305C380.296 476.067 373.564 467.282 365.103 461.516C347.548 449.552 336.021 429.398 336.021 406.552C336.021 400.967 333.389 395.536 328.465 392.902L247.992 349.841ZM270.019 280.008C265.421 282.469 262.936 287.522 262.937 292.738C262.937 293.308 262.929 293.876 262.915 294.443C262.615 306.354 266.961 318.871 277.466 324.492L334.017 354.751C344.13 360.163 356.442 357.269 366.027 350.969C376.495 344.088 389.024 340.085 402.488 340.085C416.203 340.085 428.947 344.239 439.532 351.357C449.163 357.834 461.63 360.861 471.864 355.385L526.625 326.083C537.106 320.474 541.458 307.999 541.182 296.115C541.17 295.593 541.164 295.069 541.164 294.545C541.164 288.551 538.376 282.696 533.091 279.868L463.562 242.664C454.384 237.753 443.274 239.688 434.123 244.65C424.716 249.75 413.941 252.647 402.488 252.647C390.83 252.647 379.873 249.646 370.348 244.373C361.148 239.281 349.917 237.256 340.646 242.217L270.019 280.008Z`,fill:`url(#paint0_linear_510_1294)`}),(0,T.jsx)(`defs`,{children:(0,T.jsxs)(`linearGradient`,{id:`paint0_linear_510_1294`,x1:`255.628`,y1:`-34.3245`,x2:`618.483`,y2:`632.032`,gradientUnits:`userSpaceOnUse`,children:[(0,T.jsx)(`stop`,{stopColor:`#D59FFF`}),(0,T.jsx)(`stop`,{offset:`1`,stopColor:`#8562C5`})]})})]}),`Dev UI`,l&&(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground ml-1`,children:[`v`,l]}),c.enabled&&(0,T.jsxs)(ct,{variant:`secondary`,className:`gap-1 ml-2`,children:[(0,T.jsx)(Ou,{className:`h-3 w-3`}),`OpenAI: `,c.model]})]}),!c.enabled&&(0,T.jsx)(Lu,{agents:e,workflows:t,entities:n,selectedItem:r,onSelect:i,onBrowseGallery:a,isLoading:o}),(0,T.jsx)(`div`,{className:`flex-1`}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 ml-auto`,children:[(0,T.jsx)(Qu,{}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:e=>{e.stopPropagation(),s?.()},children:(0,T.jsx)(_u,{className:`h-4 w-4`})})]})]})}function kd(e,[t,n]){return Math.min(n,Math.max(t,e))}function Ad(e,t){return y.useReducer((e,n)=>t[e][n]??e,e)}var jd=`ScrollArea`,[Md,Nd]=lt(jd),[Pd,Fd]=Md(jd),Id=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,type:r=`hover`,dir:i,scrollHideDelay:a=600,...o}=e,[s,c]=y.useState(null),[l,u]=y.useState(null),[d,f]=y.useState(null),[p,m]=y.useState(null),[h,g]=y.useState(null),[_,v]=y.useState(0),[b,x]=y.useState(0),[S,w]=y.useState(!1),[E,D]=y.useState(!1),O=C(t,e=>c(e)),k=bt(i);return(0,T.jsx)(Pd,{scope:n,type:r,dir:k,scrollHideDelay:a,scrollArea:s,viewport:l,onViewportChange:u,content:d,onContentChange:f,scrollbarX:p,onScrollbarXChange:m,scrollbarXEnabled:S,onScrollbarXEnabledChange:w,scrollbarY:h,onScrollbarYChange:g,scrollbarYEnabled:E,onScrollbarYEnabledChange:D,onCornerWidthChange:v,onCornerHeightChange:x,children:(0,T.jsx)(J.div,{dir:k,...o,ref:O,style:{position:`relative`,"--radix-scroll-area-corner-width":_+`px`,"--radix-scroll-area-corner-height":b+`px`,...e.style}})})});Id.displayName=jd;var Ld=`ScrollAreaViewport`,Rd=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,children:r,nonce:i,...a}=e,o=Fd(Ld,n),s=C(t,y.useRef(null),o.onViewportChange);return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`style`,{dangerouslySetInnerHTML:{__html:`[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none}`},nonce:i}),(0,T.jsx)(J.div,{"data-radix-scroll-area-viewport":``,...a,ref:s,style:{overflowX:o.scrollbarXEnabled?`scroll`:`hidden`,overflowY:o.scrollbarYEnabled?`scroll`:`hidden`,...e.style},children:(0,T.jsx)(`div`,{ref:o.onContentChange,style:{minWidth:`100%`,display:`table`},children:r})})]})});Rd.displayName=Ld;var zd=`ScrollAreaScrollbar`,Bd=y.forwardRef((e,t)=>{let{forceMount:n,...r}=e,i=Fd(zd,e.__scopeScrollArea),{onScrollbarXEnabledChange:a,onScrollbarYEnabledChange:o}=i,s=e.orientation===`horizontal`;return y.useEffect(()=>(s?a(!0):o(!0),()=>{s?a(!1):o(!1)}),[s,a,o]),i.type===`hover`?(0,T.jsx)(Vd,{...r,ref:t,forceMount:n}):i.type===`scroll`?(0,T.jsx)(Hd,{...r,ref:t,forceMount:n}):i.type===`auto`?(0,T.jsx)(Ud,{...r,ref:t,forceMount:n}):i.type===`always`?(0,T.jsx)(Wd,{...r,ref:t}):null});Bd.displayName=zd;var Vd=y.forwardRef((e,t)=>{let{forceMount:n,...r}=e,i=Fd(zd,e.__scopeScrollArea),[a,o]=y.useState(!1);return y.useEffect(()=>{let e=i.scrollArea,t=0;if(e){let n=()=>{window.clearTimeout(t),o(!0)},r=()=>{t=window.setTimeout(()=>o(!1),i.scrollHideDelay)};return e.addEventListener(`pointerenter`,n),e.addEventListener(`pointerleave`,r),()=>{window.clearTimeout(t),e.removeEventListener(`pointerenter`,n),e.removeEventListener(`pointerleave`,r)}}},[i.scrollArea,i.scrollHideDelay]),(0,T.jsx)(Zi,{present:n||a,children:(0,T.jsx)(Ud,{"data-state":a?`visible`:`hidden`,...r,ref:t})})}),Hd=y.forwardRef((e,t)=>{let{forceMount:n,...r}=e,i=Fd(zd,e.__scopeScrollArea),a=e.orientation===`horizontal`,o=df(()=>c(`SCROLL_END`),100),[s,c]=Ad(`hidden`,{hidden:{SCROLL:`scrolling`},scrolling:{SCROLL_END:`idle`,POINTER_ENTER:`interacting`},interacting:{SCROLL:`interacting`,POINTER_LEAVE:`idle`},idle:{HIDE:`hidden`,SCROLL:`scrolling`,POINTER_ENTER:`interacting`}});return y.useEffect(()=>{if(s===`idle`){let e=window.setTimeout(()=>c(`HIDE`),i.scrollHideDelay);return()=>window.clearTimeout(e)}},[s,i.scrollHideDelay,c]),y.useEffect(()=>{let e=i.viewport,t=a?`scrollLeft`:`scrollTop`;if(e){let n=e[t],r=()=>{let r=e[t];n!==r&&(c(`SCROLL`),o()),n=r};return e.addEventListener(`scroll`,r),()=>e.removeEventListener(`scroll`,r)}},[i.viewport,a,c,o]),(0,T.jsx)(Zi,{present:n||s!==`hidden`,children:(0,T.jsx)(Wd,{"data-state":s===`hidden`?`hidden`:`visible`,...r,ref:t,onPointerEnter:q(e.onPointerEnter,()=>c(`POINTER_ENTER`)),onPointerLeave:q(e.onPointerLeave,()=>c(`POINTER_LEAVE`))})})}),Ud=y.forwardRef((e,t)=>{let n=Fd(zd,e.__scopeScrollArea),{forceMount:r,...i}=e,[a,o]=y.useState(!1),s=e.orientation===`horizontal`,c=df(()=>{if(n.viewport){let e=n.viewport.offsetWidth{let{orientation:n=`vertical`,...r}=e,i=Fd(zd,e.__scopeScrollArea),a=y.useRef(null),o=y.useRef(0),[s,c]=y.useState({content:0,viewport:0,scrollbar:{size:0,paddingStart:0,paddingEnd:0}}),l=rf(s.viewport,s.content),u={...r,sizes:s,onSizesChange:c,hasThumb:l>0&&l<1,onThumbChange:e=>a.current=e,onThumbPointerUp:()=>o.current=0,onThumbPointerDown:e=>o.current=e};function d(e,t){return of(e,o.current,s,t)}return n===`horizontal`?(0,T.jsx)(Gd,{...u,ref:t,onThumbPositionChange:()=>{if(i.viewport&&a.current){let e=i.viewport.scrollLeft,t=sf(e,s,i.dir);a.current.style.transform=`translate3d(${t}px, 0, 0)`}},onWheelScroll:e=>{i.viewport&&(i.viewport.scrollLeft=e)},onDragScroll:e=>{i.viewport&&(i.viewport.scrollLeft=d(e,i.dir))}}):n===`vertical`?(0,T.jsx)(Kd,{...u,ref:t,onThumbPositionChange:()=>{if(i.viewport&&a.current){let e=i.viewport.scrollTop,t=sf(e,s);a.current.style.transform=`translate3d(0, ${t}px, 0)`}},onWheelScroll:e=>{i.viewport&&(i.viewport.scrollTop=e)},onDragScroll:e=>{i.viewport&&(i.viewport.scrollTop=d(e))}}):null}),Gd=y.forwardRef((e,t)=>{let{sizes:n,onSizesChange:r,...i}=e,a=Fd(zd,e.__scopeScrollArea),[o,s]=y.useState(),c=y.useRef(null),l=C(t,c,a.onScrollbarXChange);return y.useEffect(()=>{c.current&&s(getComputedStyle(c.current))},[c]),(0,T.jsx)(Yd,{"data-orientation":`horizontal`,...i,ref:l,sizes:n,style:{bottom:0,left:a.dir===`rtl`?`var(--radix-scroll-area-corner-width)`:0,right:a.dir===`ltr`?`var(--radix-scroll-area-corner-width)`:0,"--radix-scroll-area-thumb-width":af(n)+`px`,...e.style},onThumbPointerDown:t=>e.onThumbPointerDown(t.x),onDragScroll:t=>e.onDragScroll(t.x),onWheelScroll:(t,n)=>{if(a.viewport){let r=a.viewport.scrollLeft+t.deltaX;e.onWheelScroll(r),lf(r,n)&&t.preventDefault()}},onResize:()=>{c.current&&a.viewport&&o&&r({content:a.viewport.scrollWidth,viewport:a.viewport.offsetWidth,scrollbar:{size:c.current.clientWidth,paddingStart:nf(o.paddingLeft),paddingEnd:nf(o.paddingRight)}})}})}),Kd=y.forwardRef((e,t)=>{let{sizes:n,onSizesChange:r,...i}=e,a=Fd(zd,e.__scopeScrollArea),[o,s]=y.useState(),c=y.useRef(null),l=C(t,c,a.onScrollbarYChange);return y.useEffect(()=>{c.current&&s(getComputedStyle(c.current))},[c]),(0,T.jsx)(Yd,{"data-orientation":`vertical`,...i,ref:l,sizes:n,style:{top:0,right:a.dir===`ltr`?0:void 0,left:a.dir===`rtl`?0:void 0,bottom:`var(--radix-scroll-area-corner-height)`,"--radix-scroll-area-thumb-height":af(n)+`px`,...e.style},onThumbPointerDown:t=>e.onThumbPointerDown(t.y),onDragScroll:t=>e.onDragScroll(t.y),onWheelScroll:(t,n)=>{if(a.viewport){let r=a.viewport.scrollTop+t.deltaY;e.onWheelScroll(r),lf(r,n)&&t.preventDefault()}},onResize:()=>{c.current&&a.viewport&&o&&r({content:a.viewport.scrollHeight,viewport:a.viewport.offsetHeight,scrollbar:{size:c.current.clientHeight,paddingStart:nf(o.paddingTop),paddingEnd:nf(o.paddingBottom)}})}})}),[qd,Jd]=Md(zd),Yd=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,sizes:r,hasThumb:i,onThumbChange:a,onThumbPointerUp:o,onThumbPointerDown:s,onThumbPositionChange:c,onDragScroll:l,onWheelScroll:u,onResize:d,...f}=e,p=Fd(zd,n),[m,h]=y.useState(null),g=C(t,e=>h(e)),_=y.useRef(null),v=y.useRef(``),b=p.viewport,x=r.content-r.viewport,S=xt(u),w=xt(c),E=df(d,10);function D(e){_.current&&l({x:e.clientX-_.current.left,y:e.clientY-_.current.top})}return y.useEffect(()=>{let e=e=>{let t=e.target;m?.contains(t)&&S(e,x)};return document.addEventListener(`wheel`,e,{passive:!1}),()=>document.removeEventListener(`wheel`,e,{passive:!1})},[b,m,x,S]),y.useEffect(w,[r,w]),ff(m,E),ff(p.content,E),(0,T.jsx)(qd,{scope:n,scrollbar:m,hasThumb:i,onThumbChange:xt(a),onThumbPointerUp:xt(o),onThumbPositionChange:w,onThumbPointerDown:xt(s),children:(0,T.jsx)(J.div,{...f,ref:g,style:{position:`absolute`,...f.style},onPointerDown:q(e.onPointerDown,e=>{e.button===0&&(e.target.setPointerCapture(e.pointerId),_.current=m.getBoundingClientRect(),v.current=document.body.style.webkitUserSelect,document.body.style.webkitUserSelect=`none`,p.viewport&&(p.viewport.style.scrollBehavior=`auto`),D(e))}),onPointerMove:q(e.onPointerMove,D),onPointerUp:q(e.onPointerUp,e=>{let t=e.target;t.hasPointerCapture(e.pointerId)&&t.releasePointerCapture(e.pointerId),document.body.style.webkitUserSelect=v.current,p.viewport&&(p.viewport.style.scrollBehavior=``),_.current=null})})})}),Xd=`ScrollAreaThumb`,Zd=y.forwardRef((e,t)=>{let{forceMount:n,...r}=e,i=Jd(Xd,e.__scopeScrollArea);return(0,T.jsx)(Zi,{present:n||i.hasThumb,children:(0,T.jsx)(Qd,{ref:t,...r})})}),Qd=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,style:r,...i}=e,a=Fd(Xd,n),o=Jd(Xd,n),{onThumbPositionChange:s}=o,c=C(t,e=>o.onThumbChange(e)),l=y.useRef(void 0),u=df(()=>{l.current&&=(l.current(),void 0)},100);return y.useEffect(()=>{let e=a.viewport;if(e){let t=()=>{u(),l.current||(l.current=uf(e,s),s())};return s(),e.addEventListener(`scroll`,t),()=>e.removeEventListener(`scroll`,t)}},[a.viewport,u,s]),(0,T.jsx)(J.div,{"data-state":o.hasThumb?`visible`:`hidden`,...i,ref:c,style:{width:`var(--radix-scroll-area-thumb-width)`,height:`var(--radix-scroll-area-thumb-height)`,...r},onPointerDownCapture:q(e.onPointerDownCapture,e=>{let t=e.target.getBoundingClientRect(),n=e.clientX-t.left,r=e.clientY-t.top;o.onThumbPointerDown({x:n,y:r})}),onPointerUp:q(e.onPointerUp,o.onThumbPointerUp)})});Zd.displayName=Xd;var $d=`ScrollAreaCorner`,ef=y.forwardRef((e,t)=>{let n=Fd($d,e.__scopeScrollArea),r=!!(n.scrollbarX&&n.scrollbarY);return n.type!==`scroll`&&r?(0,T.jsx)(tf,{...e,ref:t}):null});ef.displayName=$d;var tf=y.forwardRef((e,t)=>{let{__scopeScrollArea:n,...r}=e,i=Fd($d,n),[a,o]=y.useState(0),[s,c]=y.useState(0),l=!!(a&&s);return ff(i.scrollbarX,()=>{let e=i.scrollbarX?.offsetHeight||0;i.onCornerHeightChange(e),c(e)}),ff(i.scrollbarY,()=>{let e=i.scrollbarY?.offsetWidth||0;i.onCornerWidthChange(e),o(e)}),l?(0,T.jsx)(J.div,{...r,ref:t,style:{width:a,height:s,position:`absolute`,right:i.dir===`ltr`?0:void 0,left:i.dir===`rtl`?0:void 0,bottom:0,...e.style}}):null});function nf(e){return e?parseInt(e,10):0}function rf(e,t){let n=e/t;return isNaN(n)?0:n}function af(e){let t=rf(e.viewport,e.content),n=e.scrollbar.paddingStart+e.scrollbar.paddingEnd,r=(e.scrollbar.size-n)*t;return Math.max(r,18)}function of(e,t,n,r=`ltr`){let i=af(n),a=i/2,o=t||a,s=i-o,c=n.scrollbar.paddingStart+o,l=n.scrollbar.size-n.scrollbar.paddingEnd-s,u=n.content-n.viewport,d=r===`ltr`?[0,u]:[u*-1,0];return cf([c,l],d)(e)}function sf(e,t,n=`ltr`){let r=af(t),i=t.scrollbar.paddingStart+t.scrollbar.paddingEnd,a=t.scrollbar.size-i,o=t.content-t.viewport,s=a-r,c=kd(e,n===`ltr`?[0,o]:[o*-1,0]);return cf([0,o],[0,s])(c)}function cf(e,t){return n=>{if(e[0]===e[1]||t[0]===t[1])return t[0];let r=(t[1]-t[0])/(e[1]-e[0]);return t[0]+r*(n-e[0])}}function lf(e,t){return e>0&&e{})=>{let n={left:e.scrollLeft,top:e.scrollTop},r=0;return(function i(){let a={left:e.scrollLeft,top:e.scrollTop},o=n.left!==a.left,s=n.top!==a.top;(o||s)&&t(),n=a,r=window.requestAnimationFrame(i)})(),()=>window.cancelAnimationFrame(r)};function df(e,t){let n=xt(e),r=y.useRef(0);return y.useEffect(()=>()=>window.clearTimeout(r.current),[]),y.useCallback(()=>{window.clearTimeout(r.current),r.current=window.setTimeout(n,t)},[n,t])}function ff(e,t){let n=xt(t);dt(()=>{let t=0;if(e){let r=new ResizeObserver(()=>{cancelAnimationFrame(t),t=window.requestAnimationFrame(n)});return r.observe(e),()=>{window.cancelAnimationFrame(t),r.unobserve(e)}}},[e,n])}var pf=Id,mf=Rd,hf=ef,gf=y.forwardRef(({className:e,children:t,...n},r)=>(0,T.jsxs)(pf,{ref:r,className:at(`relative overflow-hidden`,e),...n,children:[(0,T.jsx)(mf,{className:`h-full w-full rounded-[inherit]`,children:t}),(0,T.jsx)(_f,{}),(0,T.jsx)(hf,{})]}));gf.displayName=pf.displayName;var _f=y.forwardRef(({className:e,orientation:t=`vertical`,...n},r)=>(0,T.jsx)(Bd,{ref:r,orientation:t,className:at(`flex touch-none select-none transition-colors`,t===`vertical`&&`h-full w-2.5 border-l border-l-transparent p-[1px]`,t===`horizontal`&&`h-2.5 flex-col border-t border-t-transparent p-[1px]`,e),...n,children:(0,T.jsx)(Zd,{className:`relative flex-1 rounded-full bg-border`})}));_f.displayName=Bd.displayName;var vf=`Tabs`,[yf,bf]=lt(vf,[ca]),xf=ca(),[Sf,Cf]=yf(vf),wf=y.forwardRef((e,t)=>{let{__scopeTabs:n,value:r,onValueChange:i,defaultValue:a,orientation:o=`horizontal`,dir:s,activationMode:c=`automatic`,...l}=e,u=bt(s),[d,f]=pt({prop:r,onChange:i,defaultProp:a??``,caller:vf});return(0,T.jsx)(Sf,{scope:n,baseId:rn(),value:d,onValueChange:f,orientation:o,dir:u,activationMode:c,children:(0,T.jsx)(J.div,{dir:u,"data-orientation":o,...l,ref:t})})});wf.displayName=vf;var Tf=`TabsList`,Ef=y.forwardRef((e,t)=>{let{__scopeTabs:n,loop:r=!0,...i}=e,a=Cf(Tf,n),o=xf(n);return(0,T.jsx)(ba,{asChild:!0,...o,orientation:a.orientation,dir:a.dir,loop:r,children:(0,T.jsx)(J.div,{role:`tablist`,"aria-orientation":a.orientation,...i,ref:t})})});Ef.displayName=Tf;var Df=`TabsTrigger`,Of=y.forwardRef((e,t)=>{let{__scopeTabs:n,value:r,disabled:i=!1,...a}=e,o=Cf(Df,n),s=xf(n),c=jf(o.baseId,r),l=Mf(o.baseId,r),u=r===o.value;return(0,T.jsx)(xa,{asChild:!0,...s,focusable:!i,active:u,children:(0,T.jsx)(J.button,{type:`button`,role:`tab`,"aria-selected":u,"aria-controls":l,"data-state":u?`active`:`inactive`,"data-disabled":i?``:void 0,disabled:i,id:c,...a,ref:t,onMouseDown:q(e.onMouseDown,e=>{!i&&e.button===0&&e.ctrlKey===!1?o.onValueChange(r):e.preventDefault()}),onKeyDown:q(e.onKeyDown,e=>{[` `,`Enter`].includes(e.key)&&o.onValueChange(r)}),onFocus:q(e.onFocus,()=>{let e=o.activationMode!==`manual`;!u&&!i&&e&&o.onValueChange(r)})})})});Of.displayName=Df;var kf=`TabsContent`,Af=y.forwardRef((e,t)=>{let{__scopeTabs:n,value:r,forceMount:i,children:a,...o}=e,s=Cf(kf,n),c=jf(s.baseId,r),l=Mf(s.baseId,r),u=r===s.value,d=y.useRef(u);return y.useEffect(()=>{let e=requestAnimationFrame(()=>d.current=!1);return()=>cancelAnimationFrame(e)},[]),(0,T.jsx)(Zi,{present:i||u,children:({present:n})=>(0,T.jsx)(J.div,{"data-state":u?`active`:`inactive`,"data-orientation":s.orientation,role:`tabpanel`,"aria-labelledby":c,hidden:!n,id:l,tabIndex:0,...o,ref:t,style:{...e.style,animationDuration:d.current?`0s`:void 0},children:n&&a})})});Af.displayName=kf;function jf(e,t){return`${e}-trigger-${t}`}function Mf(e,t){return`${e}-content-${t}`}var Nf=wf,Pf=Ef,Ff=Of,If=Af,Lf=Nf,Rf=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(Pf,{ref:n,className:at(`inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground`,e),...t}));Rf.displayName=Pf.displayName;var zf=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(Ff,{ref:n,className:at(`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow`,e),...t}));zf.displayName=Ff.displayName;var Bf=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(If,{ref:n,className:at(`mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`,e),...t}));Bf.displayName=If.displayName;function Vf(e){let t=y.useRef({value:e,previous:e});return y.useMemo(()=>(t.current.value!==e&&(t.current.previous=t.current.value,t.current.value=e),t.current.previous),[e])}var Hf=`Checkbox`,[Uf,Wf]=lt(Hf),[Gf,Kf]=Uf(Hf);function qf(e){let{__scopeCheckbox:t,checked:n,children:r,defaultChecked:i,disabled:a,form:o,name:s,onCheckedChange:c,required:l,value:u=`on`,internal_do_not_use_render:d}=e,[f,p]=pt({prop:n,defaultProp:i??!1,onChange:c,caller:Hf}),[m,h]=y.useState(null),[g,_]=y.useState(null),v=y.useRef(!1),b=m?!!o||!!m.closest(`form`):!0,x={checked:f,disabled:a,setChecked:p,control:m,setControl:h,name:s,form:o,value:u,hasConsumerStoppedPropagationRef:v,required:l,defaultChecked:np(i)?!1:i,isFormControl:b,bubbleInput:g,setBubbleInput:_};return(0,T.jsx)(Gf,{scope:t,...x,children:tp(d)?d(x):r})}var Jf=`CheckboxTrigger`,Yf=y.forwardRef(({__scopeCheckbox:e,onKeyDown:t,onClick:n,...r},i)=>{let{control:a,value:o,disabled:s,checked:c,required:l,setControl:u,setChecked:d,hasConsumerStoppedPropagationRef:f,isFormControl:p,bubbleInput:m}=Kf(Jf,e),h=C(i,u),g=y.useRef(c);return y.useEffect(()=>{let e=a?.form;if(e){let t=()=>d(g.current);return e.addEventListener(`reset`,t),()=>e.removeEventListener(`reset`,t)}},[a,d]),(0,T.jsx)(J.button,{type:`button`,role:`checkbox`,"aria-checked":np(c)?`mixed`:c,"aria-required":l,"data-state":rp(c),"data-disabled":s?``:void 0,disabled:s,value:o,...r,ref:h,onKeyDown:q(t,e=>{e.key===`Enter`&&e.preventDefault()}),onClick:q(n,e=>{d(e=>np(e)?!0:!e),m&&p&&(f.current=e.isPropagationStopped(),f.current||e.stopPropagation())})})});Yf.displayName=Jf;var Xf=y.forwardRef((e,t)=>{let{__scopeCheckbox:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c,onCheckedChange:l,form:u,...d}=e;return(0,T.jsx)(qf,{__scopeCheckbox:n,checked:i,defaultChecked:a,disabled:s,required:o,onCheckedChange:l,name:r,form:u,value:c,internal_do_not_use_render:({isFormControl:e})=>(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Yf,{...d,ref:t,__scopeCheckbox:n}),e&&(0,T.jsx)(ep,{__scopeCheckbox:n})]})})});Xf.displayName=Hf;var Zf=`CheckboxIndicator`,Qf=y.forwardRef((e,t)=>{let{__scopeCheckbox:n,forceMount:r,...i}=e,a=Kf(Zf,n);return(0,T.jsx)(Zi,{present:r||np(a.checked)||a.checked===!0,children:(0,T.jsx)(J.span,{"data-state":rp(a.checked),"data-disabled":a.disabled?``:void 0,...i,ref:t,style:{pointerEvents:`none`,...e.style}})})});Qf.displayName=Zf;var $f=`CheckboxBubbleInput`,ep=y.forwardRef(({__scopeCheckbox:e,...t},n)=>{let{control:r,hasConsumerStoppedPropagationRef:i,checked:a,defaultChecked:o,required:s,disabled:c,name:l,value:u,form:d,bubbleInput:f,setBubbleInput:p}=Kf($f,e),m=C(n,p),h=Vf(a),g=Ti(r);y.useEffect(()=>{let e=f;if(!e)return;let t=window.HTMLInputElement.prototype,n=Object.getOwnPropertyDescriptor(t,`checked`).set,r=!i.current;if(h!==a&&n){let t=new Event(`click`,{bubbles:r});e.indeterminate=np(a),n.call(e,np(a)?!1:a),e.dispatchEvent(t)}},[f,h,a,i]);let _=y.useRef(np(a)?!1:a);return(0,T.jsx)(J.input,{type:`checkbox`,"aria-hidden":!0,defaultChecked:o??_.current,required:s,disabled:c,name:l,value:u,form:d,...t,tabIndex:-1,ref:m,style:{...t.style,...g,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0,transform:`translateX(-100%)`}})});ep.displayName=$f;function tp(e){return typeof e==`function`}function np(e){return e===`indeterminate`}function rp(e){return np(e)?`indeterminate`:e?`checked`:`unchecked`}function ip({className:e,...t}){return(0,T.jsx)(Xf,{"data-slot":`checkbox`,className:at(`peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50`,e),...t,children:(0,T.jsx)(Qf,{"data-slot":`checkbox-indicator`,className:`flex items-center justify-center text-current transition-none`,children:(0,T.jsx)(Tl,{className:`size-3.5`})})})}var ap=Object.freeze({position:`absolute`,border:0,width:1,height:1,padding:0,margin:-1,overflow:`hidden`,clip:`rect(0, 0, 0, 0)`,whiteSpace:`nowrap`,wordWrap:`normal`}),op=`VisuallyHidden`,sp=y.forwardRef((e,t)=>(0,T.jsx)(J.span,{...e,ref:t,style:{...ap,...e.style}}));sp.displayName=op;var cp=sp,[lp,up]=lt(`Tooltip`,[Oi]),dp=Oi(),fp=`TooltipProvider`,pp=700,mp=`tooltip.open`,[hp,gp]=lp(fp),_p=e=>{let{__scopeTooltip:t,delayDuration:n=pp,skipDelayDuration:r=300,disableHoverableContent:i=!1,children:a}=e,o=y.useRef(!0),s=y.useRef(!1),c=y.useRef(0);return y.useEffect(()=>{let e=c.current;return()=>window.clearTimeout(e)},[]),(0,T.jsx)(hp,{scope:t,isOpenDelayedRef:o,delayDuration:n,onOpen:y.useCallback(()=>{window.clearTimeout(c.current),o.current=!1},[]),onClose:y.useCallback(()=>{window.clearTimeout(c.current),c.current=window.setTimeout(()=>o.current=!0,r)},[r]),isPointerInTransitRef:s,onPointerInTransitChange:y.useCallback(e=>{s.current=e},[]),disableHoverableContent:i,children:a})};_p.displayName=fp;var vp=`Tooltip`,[yp,bp]=lp(vp),xp=e=>{let{__scopeTooltip:t,children:n,open:r,defaultOpen:i,onOpenChange:a,disableHoverableContent:o,delayDuration:s}=e,c=gp(vp,e.__scopeTooltip),l=dp(t),[u,d]=y.useState(null),f=rn(),p=y.useRef(0),m=o??c.disableHoverableContent,h=s??c.delayDuration,g=y.useRef(!1),[_,v]=pt({prop:r,defaultProp:i??!1,onChange:e=>{e?(c.onOpen(),document.dispatchEvent(new CustomEvent(mp))):c.onClose(),a?.(e)},caller:vp}),b=y.useMemo(()=>_?g.current?`delayed-open`:`instant-open`:`closed`,[_]),x=y.useCallback(()=>{window.clearTimeout(p.current),p.current=0,g.current=!1,v(!0)},[v]),S=y.useCallback(()=>{window.clearTimeout(p.current),p.current=0,v(!1)},[v]),C=y.useCallback(()=>{window.clearTimeout(p.current),p.current=window.setTimeout(()=>{g.current=!0,v(!0),p.current=0},h)},[h,v]);return y.useEffect(()=>()=>{p.current&&=(window.clearTimeout(p.current),0)},[]),(0,T.jsx)(Wi,{...l,children:(0,T.jsx)(yp,{scope:t,contentId:f,open:_,stateAttribute:b,trigger:u,onTriggerChange:d,onTriggerEnter:y.useCallback(()=>{c.isOpenDelayedRef.current?C():x()},[c.isOpenDelayedRef,C,x]),onTriggerLeave:y.useCallback(()=>{m?S():(window.clearTimeout(p.current),p.current=0)},[S,m]),onOpen:x,onClose:S,disableHoverableContent:m,children:n})})};xp.displayName=vp;var Sp=`TooltipTrigger`,Cp=y.forwardRef((e,t)=>{let{__scopeTooltip:n,...r}=e,i=bp(Sp,n),a=gp(Sp,n),o=dp(n),s=C(t,y.useRef(null),i.onTriggerChange),c=y.useRef(!1),l=y.useRef(!1),u=y.useCallback(()=>c.current=!1,[]);return y.useEffect(()=>()=>document.removeEventListener(`pointerup`,u),[u]),(0,T.jsx)(Gi,{asChild:!0,...o,children:(0,T.jsx)(J.button,{"aria-describedby":i.open?i.contentId:void 0,"data-state":i.stateAttribute,...r,ref:s,onPointerMove:q(e.onPointerMove,e=>{e.pointerType!==`touch`&&!l.current&&!a.isPointerInTransitRef.current&&(i.onTriggerEnter(),l.current=!0)}),onPointerLeave:q(e.onPointerLeave,()=>{i.onTriggerLeave(),l.current=!1}),onPointerDown:q(e.onPointerDown,()=>{i.open&&i.onClose(),c.current=!0,document.addEventListener(`pointerup`,u,{once:!0})}),onFocus:q(e.onFocus,()=>{c.current||i.onOpen()}),onBlur:q(e.onBlur,i.onClose),onClick:q(e.onClick,i.onClose)})})});Cp.displayName=Sp;var wp=`TooltipPortal`,[Tp,Ep]=lp(wp,{forceMount:void 0}),Dp=e=>{let{__scopeTooltip:t,forceMount:n,children:r,container:i}=e,a=bp(wp,t);return(0,T.jsx)(Tp,{scope:t,forceMount:n,children:(0,T.jsx)(Zi,{present:n||a.open,children:(0,T.jsx)(Yi,{asChild:!0,container:i,children:r})})})};Dp.displayName=wp;var Op=`TooltipContent`,kp=y.forwardRef((e,t)=>{let n=Ep(Op,e.__scopeTooltip),{forceMount:r=n.forceMount,side:i=`top`,...a}=e,o=bp(Op,e.__scopeTooltip);return(0,T.jsx)(Zi,{present:r||o.open,children:o.disableHoverableContent?(0,T.jsx)(Pp,{side:i,...a,ref:t}):(0,T.jsx)(Ap,{side:i,...a,ref:t})})}),Ap=y.forwardRef((e,t)=>{let n=bp(Op,e.__scopeTooltip),r=gp(Op,e.__scopeTooltip),i=y.useRef(null),a=C(t,i),[o,s]=y.useState(null),{trigger:c,onClose:l}=n,u=i.current,{onPointerInTransitChange:d}=r,f=y.useCallback(()=>{s(null),d(!1)},[d]),p=y.useCallback((e,t)=>{let n=e.currentTarget,r={x:e.clientX,y:e.clientY},i=Rp(r,Lp(r,n.getBoundingClientRect())),a=zp(t.getBoundingClientRect());s(Vp([...i,...a])),d(!0)},[d]);return y.useEffect(()=>()=>f(),[f]),y.useEffect(()=>{if(c&&u){let e=e=>p(e,u),t=e=>p(e,c);return c.addEventListener(`pointerleave`,e),u.addEventListener(`pointerleave`,t),()=>{c.removeEventListener(`pointerleave`,e),u.removeEventListener(`pointerleave`,t)}}},[c,u,p,f]),y.useEffect(()=>{if(o){let e=e=>{let t=e.target,n={x:e.clientX,y:e.clientY},r=c?.contains(t)||u?.contains(t),i=!Bp(n,o);r?f():i&&(f(),l())};return document.addEventListener(`pointermove`,e),()=>document.removeEventListener(`pointermove`,e)}},[c,u,o,l,f]),(0,T.jsx)(Pp,{...e,ref:a})}),[jp,Mp]=lp(vp,{isInside:!1}),Np=A(`TooltipContent`),Pp=y.forwardRef((e,t)=>{let{__scopeTooltip:n,children:r,"aria-label":i,onEscapeKeyDown:a,onPointerDownOutside:o,...s}=e,c=bp(Op,n),l=dp(n),{onClose:u}=c;return y.useEffect(()=>(document.addEventListener(mp,u),()=>document.removeEventListener(mp,u)),[u]),y.useEffect(()=>{if(c.trigger){let e=e=>{e.target?.contains(c.trigger)&&u()};return window.addEventListener(`scroll`,e,{capture:!0}),()=>window.removeEventListener(`scroll`,e,{capture:!0})}},[c.trigger,u]),(0,T.jsx)(kt,{asChild:!0,disableOutsidePointerEvents:!1,onEscapeKeyDown:a,onPointerDownOutside:o,onFocusOutside:e=>e.preventDefault(),onDismiss:u,children:(0,T.jsxs)(Ki,{"data-state":c.stateAttribute,...l,...s,ref:t,style:{...s.style,"--radix-tooltip-content-transform-origin":`var(--radix-popper-transform-origin)`,"--radix-tooltip-content-available-width":`var(--radix-popper-available-width)`,"--radix-tooltip-content-available-height":`var(--radix-popper-available-height)`,"--radix-tooltip-trigger-width":`var(--radix-popper-anchor-width)`,"--radix-tooltip-trigger-height":`var(--radix-popper-anchor-height)`},children:[(0,T.jsx)(Np,{children:r}),(0,T.jsx)(jp,{scope:n,isInside:!0,children:(0,T.jsx)(cp,{id:c.contentId,role:`tooltip`,children:i||r})})]})})});kp.displayName=Op;var Fp=`TooltipArrow`,Ip=y.forwardRef((e,t)=>{let{__scopeTooltip:n,...r}=e,i=dp(n);return Mp(Fp,n).isInside?null:(0,T.jsx)(qi,{...i,...r,ref:t})});Ip.displayName=Fp;function Lp(e,t){let n=Math.abs(t.top-e.y),r=Math.abs(t.bottom-e.y),i=Math.abs(t.right-e.x),a=Math.abs(t.left-e.x);switch(Math.min(n,r,i,a)){case a:return`left`;case i:return`right`;case n:return`top`;case r:return`bottom`;default:throw Error(`unreachable`)}}function Rp(e,t,n=5){let r=[];switch(t){case`top`:r.push({x:e.x-n,y:e.y+n},{x:e.x+n,y:e.y+n});break;case`bottom`:r.push({x:e.x-n,y:e.y-n},{x:e.x+n,y:e.y-n});break;case`left`:r.push({x:e.x+n,y:e.y-n},{x:e.x+n,y:e.y+n});break;case`right`:r.push({x:e.x-n,y:e.y-n},{x:e.x-n,y:e.y+n});break}return r}function zp(e){let{top:t,right:n,bottom:r,left:i}=e;return[{x:i,y:t},{x:n,y:t},{x:n,y:r},{x:i,y:r}]}function Bp(e,t){let{x:n,y:r}=e,i=!1;for(let e=0,a=t.length-1;er!=d>r&&n<(u-c)*(r-l)/(d-l)+c&&(i=!i)}return i}function Vp(e){let t=e.slice();return t.sort((e,t)=>e.xt.x?1:e.yt.y)),Hp(t)}function Hp(e){if(e.length<=1)return e.slice();let t=[];for(let n=0;n=2;){let e=t[t.length-1],n=t[t.length-2];if((e.x-n.x)*(r.y-n.y)>=(e.y-n.y)*(r.x-n.x))t.pop();else break}t.push(r)}t.pop();let n=[];for(let t=e.length-1;t>=0;t--){let r=e[t];for(;n.length>=2;){let e=n[n.length-1],t=n[n.length-2];if((e.x-t.x)*(r.y-t.y)>=(e.y-t.y)*(r.x-t.x))n.pop();else break}n.push(r)}return n.pop(),t.length===1&&n.length===1&&t[0].x===n[0].x&&t[0].y===n[0].y?t:t.concat(n)}var Up=_p,Wp=xp,Gp=Cp,Kp=Dp,qp=kp,Jp=Up,Yp=Wp,Xp=Gp,Zp=y.forwardRef(({className:e,sideOffset:t=4,...n},r)=>(0,T.jsx)(Kp,{children:(0,T.jsx)(qp,{ref:r,sideOffset:t,className:at(`z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`,e),...n})}));Zp.displayName=qp.displayName;var Qp={MODEL:`gen_ai.request.model`,MAX_TOKENS:`gen_ai.request.max_tokens`,TEMPERATURE:`gen_ai.request.temperature`,TOP_P:`gen_ai.request.top_p`,SEED:`gen_ai.request.seed`,FREQUENCY_PENALTY:`gen_ai.request.frequency_penalty`,PRESENCE_PENALTY:`gen_ai.request.presence_penalty`,STOP_SEQUENCES:`gen_ai.request.stop_sequences`,FINISH_REASONS:`gen_ai.response.finish_reasons`,RESPONSE_ID:`gen_ai.response.id`,INPUT_TOKENS:`gen_ai.usage.input_tokens`,OUTPUT_TOKENS:`gen_ai.usage.output_tokens`,INPUT_MESSAGES:`gen_ai.input.messages`,OUTPUT_MESSAGES:`gen_ai.output.messages`,SYSTEM_INSTRUCTIONS:`gen_ai.system_instructions`,OUTPUT_TYPE:`gen_ai.output.type`,TOOL_CALL_ID:`gen_ai.tool.call.id`,TOOL_NAME:`gen_ai.tool.name`,TOOL_TYPE:`gen_ai.tool.type`,TOOL_DEFINITIONS:`gen_ai.tool.definitions`,TOOL_ARGUMENTS:`gen_ai.tool.call.arguments`,TOOL_RESULT:`gen_ai.tool.call.result`,AGENT_ID:`gen_ai.agent.id`,AGENT_NAME:`gen_ai.agent.name`,AGENT_DESCRIPTION:`gen_ai.agent.description`,CONVERSATION_ID:`gen_ai.conversation.id`,WORKFLOW_ID:`workflow.id`,WORKFLOW_NAME:`workflow.name`,EXECUTOR_ID:`executor.id`,EXECUTOR_TYPE:`executor.type`};function $p(e){return e.type===`text`}function em(e){return e.type===`tool_call`||e.type===`function_call`}function tm(e){return e.type===`tool_result`||e.type===`function_result`||e.type===`tool_call_response`}function nm(e){if(!e)return[];try{return JSON.parse(e)}catch{return[]}}Td();function rm(e){let t={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};try{let n;if(typeof e==`string`)n=nm(e);else if(Array.isArray(e))n=e;else return t;for(let e of n){if(!e||typeof e!=`object`)continue;let n=e.role,r=e.parts,i=0;if(Array.isArray(r)){for(let e of r)if(!(!e||typeof e!=`object`)){if($p(e)){let t=e.content||e.text||``;i+=t.length}else if(em(e)){let n=e.name||``,r=e.arguments||``;t.toolCalls+=n.length+r.length}else if(tm(e)){let n=e.result||e.response||``;t.toolResults+=n.length}}}n===`system`?t.system+=i:n===`user`?t.user+=i:n===`assistant`?t.assistant+=i:n===`tool`&&(t.toolResults+=i)}t.total=t.system+t.user+t.assistant+t.toolCalls+t.toolResults}catch{}return t}function im(e){let t=e.filter(e=>e.type===`response.trace.completed`),n=new Map;for(let e of t){if(!(`data`in e))continue;let t=e.data,r=t.response_id||`unknown`;n.has(r)||n.set(r,[]),n.get(r).push(t)}let r=[];for(let[e,t]of n){let n=0,i=0,a,o=Date.now()/1e3,s,c=0,l={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};for(let e of t){let t=e.attributes||{},r=t[Qp.INPUT_TOKENS],u=t[Qp.OUTPUT_TOKENS];r!==void 0&&(n+=Number(r)),u!==void 0&&(i+=Number(u)),t[Qp.MODEL]&&(a=String(t[Qp.MODEL])),e.start_time&&e.start_time0||i>0)&&r.push({response_id:e,timestamp:o,input_tokens:n,output_tokens:i,total_tokens:n+i,model:a,entity_id:s,duration_ms:c,composition:l})}return r.sort((e,t)=>e.timestamp-t.timestamp),r}function am(e){if(e.length===0)return{totalInput:0,totalOutput:0,totalTokens:0,avgInput:0,avgOutput:0,avgTotal:0,peakInput:0,peakOutput:0,peakTotal:0,turnCount:0};let t=e.reduce((e,t)=>e+t.input_tokens,0),n=e.reduce((e,t)=>e+t.output_tokens,0),r=t+n,i=Math.max(...e.map(e=>e.input_tokens)),a=Math.max(...e.map(e=>e.output_tokens)),o=Math.max(...e.map(e=>e.total_tokens));return{totalInput:t,totalOutput:n,totalTokens:r,avgInput:Math.round(t/e.length),avgOutput:Math.round(n/e.length),avgTotal:Math.round(r/e.length),peakInput:i,peakOutput:a,peakTotal:o,turnCount:e.length}}function om(e){return e.reduce((e,t)=>({system:e.system+t.composition.system,user:e.user+t.composition.user,assistant:e.assistant+t.composition.assistant,toolCalls:e.toolCalls+t.composition.toolCalls,toolResults:e.toolResults+t.composition.toolResults,total:e.total+t.composition.total}),{system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0})}function sm(e){return e>=1e3?`${(e/1e3).toFixed(1)}k`:String(e)}var cm={input:`bg-blue-500 dark:bg-blue-600`,output:`bg-emerald-500 dark:bg-emerald-600`,system:`bg-purple-500 dark:bg-purple-600`,user:`bg-blue-500 dark:bg-blue-600`,assistant:`bg-emerald-500 dark:bg-emerald-600`,toolCalls:`bg-amber-500 dark:bg-amber-600`,toolResults:`bg-orange-500 dark:bg-orange-600`};function lm({segments:e,maxValue:t,height:n=20,renderLabel:r}){let i=e.reduce((e,t)=>e+t.value,0);if(i===0)return(0,T.jsx)(`div`,{className:`flex items-center gap-2 w-full`,children:(0,T.jsx)(`div`,{className:`rounded bg-muted/30 flex-1`,style:{height:`${n}px`}})});let a=t>0?i/t*100:100,o=e.filter(e=>e.value>0).map(e=>({...e,percent:Math.round(e.value/i*100)}));return(0,T.jsxs)(`div`,{className:`flex items-center gap-2 w-full`,children:[(0,T.jsx)(`div`,{className:`relative rounded overflow-hidden bg-muted/30 flex-1`,style:{height:`${n}px`},children:(0,T.jsx)(Jp,{delayDuration:150,children:(0,T.jsx)(`div`,{className:`h-full flex transition-all duration-300`,style:{width:`${a}%`},children:o.map(e=>(0,T.jsxs)(Yp,{children:[(0,T.jsx)(Xp,{asChild:!0,children:(0,T.jsx)(`div`,{className:`h-full ${e.color} transition-all duration-150 hover:brightness-110 hover:scale-y-[1.15] origin-bottom cursor-default`,style:{width:`${e.value/i*100}%`}})}),(0,T.jsx)(Zp,{side:`top`,className:`text-xs`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2 h-2 rounded-sm ${e.color} flex-shrink-0`}),(0,T.jsx)(`span`,{className:`font-medium`,children:e.label}),(0,T.jsxs)(`span`,{className:`opacity-80`,children:[sm(e.value),` (`,e.percent,`%)`]})]})})]},e.key))})})}),r?.(i,e)]})}function um(e,t){return[{key:`input`,value:e,color:cm.input,label:`Input`},{key:`output`,value:t,color:cm.output,label:`Output`}]}function dm(e){return[{key:`system`,value:e.system,color:cm.system,label:`System`},{key:`user`,value:e.user,color:cm.user,label:`User`},{key:`assistant`,value:e.assistant,color:cm.assistant,label:`Assistant`},{key:`toolCalls`,value:e.toolCalls,color:cm.toolCalls,label:`Tool Calls`},{key:`toolResults`,value:e.toolResults,color:cm.toolResults,label:`Tool Results`}]}function fm({composition:e,className:t=``}){let{system:n,user:r,assistant:i,toolCalls:a,toolResults:o,total:s}=e;if(s===0)return(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground ${t}`,children:`No composition data available`});let c=[{label:`System`,value:n,color:cm.system},{label:`User`,value:r,color:cm.user},{label:`Assistant`,value:i,color:cm.assistant},{label:`Tool Calls`,value:a,color:cm.toolCalls},{label:`Tool Results`,value:o,color:cm.toolResults}].filter(e=>e.value>0);return(0,T.jsx)(`div`,{className:`space-y-1.5 ${t}`,children:c.map(e=>{let t=Math.round(e.value/s*100);return(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs`,children:[(0,T.jsx)(`div`,{className:`w-2 h-2 rounded-sm ${e.color}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground w-20`,children:e.label}),(0,T.jsx)(`div`,{className:`flex-1 h-3 bg-muted/30 rounded overflow-hidden`,children:(0,T.jsx)(`div`,{className:`h-full ${e.color} transition-all duration-300`,style:{width:`${t}%`}})}),(0,T.jsxs)(`span`,{className:`font-mono w-10 text-right text-muted-foreground`,children:[t,`%`]})]},e.label)})})}function pm({turn:e,index:t,maxValue:n,maxCompositionValue:r,cumulativeInput:i,cumulativeOutput:a,cumulativeComposition:o,showCumulative:s,viewMode:c}){let[l,u]=(0,y.useState)(!1),d=s?i:e.input_tokens,f=s?a:e.output_tokens,p=s?o:e.composition,m=new Date(e.timestamp*1e3).toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`,second:`2-digit`});return(0,T.jsxs)(`div`,{className:`border-b border-muted/50 last:border-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-3 py-2 px-2 hover:bg-muted/30 cursor-pointer transition-colors`,onClick:()=>u(!l),children:[(0,T.jsx)(`div`,{className:`w-6 h-6 rounded-full bg-muted flex items-center justify-center text-xs font-medium flex-shrink-0`,children:t+1}),(0,T.jsx)(`div`,{className:`flex-1 min-w-0`,children:c===`tokens`?(0,T.jsx)(lm,{segments:um(d,f),maxValue:n,height:20,renderLabel:(e,t)=>(0,T.jsxs)(`div`,{className:`flex items-center gap-1 text-xs font-mono text-muted-foreground min-w-[80px] justify-end`,children:[(0,T.jsxs)(`span`,{className:`text-blue-600 dark:text-blue-400`,children:[`↑`,sm(t[0]?.value||0)]}),(0,T.jsx)(`span`,{children:`/`}),(0,T.jsxs)(`span`,{className:`text-emerald-600 dark:text-emerald-400`,children:[`↓`,sm(t[1]?.value||0)]})]})}):(0,T.jsx)(lm,{segments:dm(p),maxValue:r,height:20,renderLabel:e=>(0,T.jsxs)(`div`,{className:`text-xs font-mono text-muted-foreground min-w-[50px] text-right`,children:[sm(Math.round(e/4)),`~`]})})}),(0,T.jsx)(`div`,{className:`text-muted-foreground flex-shrink-0`,children:l?(0,T.jsx)(X,{className:`h-4 w-4`}):(0,T.jsx)(Z,{className:`h-4 w-4`})})]}),l&&(0,T.jsx)(`div`,{className:`pb-3`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-3 px-2`,children:[(0,T.jsx)(`div`,{className:`w-6 flex justify-center flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`w-px h-full bg-muted`})}),(0,T.jsx)(`div`,{className:`flex-1 min-w-0`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,T.jsx)(`div`,{className:`text-muted-foreground text-xs mt-1`,children:`└─`}),(0,T.jsxs)(`div`,{className:`flex-1 space-y-3`,children:[(0,T.jsxs)(`div`,{className:`grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground`,children:[(0,T.jsxs)(`div`,{children:[`Time: `,(0,T.jsx)(`span`,{className:`font-mono text-foreground`,children:m})]}),(0,T.jsxs)(`div`,{children:[`Duration: `,(0,T.jsxs)(`span`,{className:`font-mono text-foreground`,children:[e.duration_ms.toFixed(0),`ms`]})]}),e.model&&(0,T.jsxs)(`div`,{children:[`Model: `,(0,T.jsx)(`span`,{className:`font-mono text-foreground`,children:e.model})]}),e.entity_id&&(0,T.jsxs)(`div`,{children:[`Entity: `,(0,T.jsx)(`span`,{className:`font-mono text-foreground`,children:e.entity_id})]})]}),c===`tokens`&&(0,T.jsxs)(`div`,{className:`flex gap-4 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-blue-600 dark:text-blue-400`,children:`Input:`}),` `,(0,T.jsx)(`span`,{className:`font-mono`,children:e.input_tokens.toLocaleString()})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-emerald-600 dark:text-emerald-400`,children:`Output:`}),` `,(0,T.jsx)(`span`,{className:`font-mono`,children:e.output_tokens.toLocaleString()})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Total:`}),` `,(0,T.jsx)(`span`,{className:`font-mono`,children:e.total_tokens.toLocaleString()})]})]}),c===`composition`&&e.composition.total>0&&(0,T.jsxs)(`div`,{children:[(0,T.jsxs)(`div`,{className:`text-xs text-muted-foreground mb-2 flex items-center gap-1`,children:[(0,T.jsx)(Jl,{className:`h-3 w-3`}),`Context Composition (estimated from ~`,sm(Math.round(e.composition.total/4)),` tokens)`]}),(0,T.jsx)(fm,{composition:e.composition})]})]})]})})]})})]})}function mm({label:e,value:t,icon:n,color:r=`default`}){let i={default:`text-muted-foreground`,blue:`text-blue-600 dark:text-blue-400`,green:`text-emerald-600 dark:text-emerald-400`}[r];return(0,T.jsxs)(`div`,{className:`flex items-center gap-2 p-2 bg-muted/30 rounded`,children:[(0,T.jsx)(n,{className:`h-4 w-4 ${i}`}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground truncate`,children:e}),(0,T.jsx)(`div`,{className:`font-mono text-sm font-medium`,children:t})]})]})}function hm({events:e}){let t=Q(e=>e.runtime),n=Q(e=>e.serverCapabilities.trace_retrieval),r=Q(e=>e.contextInspectorViewMode),i=Q(e=>e.setContextInspectorViewMode),a=Q(e=>e.contextInspectorCumulative),o=Q(e=>e.setContextInspectorCumulative),s=(0,y.useMemo)(()=>im(e),[e]),c=(0,y.useMemo)(()=>am(s),[s]),l=(0,y.useMemo)(()=>om(s),[s]),u=(0,y.useMemo)(()=>s.length===0?0:a?c.totalTokens:0,[s,a,c.totalTokens]),d=(0,y.useMemo)(()=>s.length===0?0:a?l.total:0,[s,a,l.total]),f=(0,y.useMemo)(()=>{let e=0,t=0,n={system:0,user:0,assistant:0,toolCalls:0,toolResults:0,total:0};return s.map(r=>(e+=r.input_tokens,t+=r.output_tokens,n={system:n.system+r.composition.system,user:n.user+r.composition.user,assistant:n.assistant+r.composition.assistant,toolCalls:n.toolCalls+r.composition.toolCalls,toolResults:n.toolResults+r.composition.toolResults,total:n.total+r.composition.total},{input:e,output:t,composition:{...n}}))},[s]);return s.length===0?(0,T.jsxs)(`div`,{className:`flex flex-col items-center text-center p-6 pt-9`,children:[(0,T.jsx)(Cl,{className:`h-8 w-8 text-muted-foreground mb-3`}),(0,T.jsx)(`div`,{className:`text-sm font-medium mb-1`,children:`No Data`}),(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground max-w-[200px]`,children:n?`Start a conversation to inspect OpenTelemetry context from the Aspire Dashboard.`:t===`dotnet`?`Trace retrieval is not available for this server.`:(0,T.jsxs)(T.Fragment,{children:[`Run`,` `,(0,T.jsx)(`span`,{className:`font-mono bg-accent/10 px-1 rounded`,children:`devui --instrumentation`}),` `,`and start a conversation.`]})})]}):(0,T.jsxs)(`div`,{className:`h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`p-3 border-b flex-shrink-0 space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Cl,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`Context Inspector`}),(0,T.jsxs)(ct,{variant:`outline`,className:`text-xs`,children:[s.length,` turn`,s.length===1?``:`s`]})]}),(0,T.jsxs)(`label`,{className:`flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer`,children:[(0,T.jsx)(ip,{checked:a,onCheckedChange:e=>o(e===!0),className:`h-3.5 w-3.5`}),(0,T.jsx)(`span`,{children:`Cumulative`})]})]}),(0,T.jsxs)(`div`,{className:`flex items-center bg-muted rounded-md p-1`,children:[(0,T.jsx)(`button`,{onClick:()=>i(`tokens`),className:`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${r===`tokens`?`bg-background shadow-sm font-medium`:`text-muted-foreground hover:text-foreground`}`,children:`Tokens`}),(0,T.jsx)(`button`,{onClick:()=>i(`composition`),className:`flex-1 px-3 py-1.5 text-xs rounded transition-colors ${r===`composition`?`bg-background shadow-sm font-medium`:`text-muted-foreground hover:text-foreground`}`,children:`Composition`})]}),(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:r===`tokens`?`Token usage per turn`:`Context breakdown by message type (chars)`})]}),(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsxs)(`div`,{className:`p-3 space-y-4`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-4 text-xs px-1 flex-wrap`,children:[r===`tokens`?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-3 h-3 rounded ${cm.input}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Input (↑)`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-3 h-3 rounded ${cm.output}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Output (↓)`})]})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.system}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`System`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.user}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`User`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.assistant}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Assistant`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.toolCalls}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Tools`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`div`,{className:`w-2.5 h-2.5 rounded-sm ${cm.toolResults}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Results`})]})]}),(0,T.jsx)(`div`,{className:`flex-1`}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1 text-muted-foreground`,children:[(0,T.jsx)(Jl,{className:`h-3 w-3`}),(0,T.jsx)(`span`,{children:`Click for details`})]})]}),(0,T.jsx)(`div`,{className:`border rounded-lg overflow-hidden`,children:s.map((e,t)=>(0,T.jsx)(pm,{turn:e,index:t,maxValue:u,maxCompositionValue:d,cumulativeInput:f[t]?.input||0,cumulativeOutput:f[t]?.output||0,cumulativeComposition:f[t]?.composition||e.composition,showCumulative:a,viewMode:r},e.response_id))}),(0,T.jsxs)(`div`,{className:`border rounded-lg overflow-hidden`,children:[(0,T.jsx)(`div`,{className:`p-3 bg-muted/30 border-b`,children:(0,T.jsx)(`span`,{className:`text-xs font-medium`,children:`Session Summary`})}),(0,T.jsxs)(`div`,{className:`p-3 space-y-3`,children:[(0,T.jsxs)(`div`,{className:`grid grid-cols-3 gap-2`,children:[(0,T.jsx)(mm,{label:`Total Tokens`,value:sm(c.totalTokens),icon:Xl}),(0,T.jsx)(mm,{label:`Input`,value:sm(c.totalInput),icon:Cl,color:`blue`}),(0,T.jsx)(mm,{label:`Output`,value:sm(c.totalOutput),icon:Cl,color:`green`})]}),s.length>1&&(0,T.jsxs)(`div`,{className:`grid grid-cols-2 gap-x-4 gap-y-1 text-xs pt-2 border-t border-muted/50`,children:[(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Avg per turn:`}),(0,T.jsx)(`span`,{className:`font-mono`,children:sm(c.avgTotal)})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Peak turn:`}),(0,T.jsx)(`span`,{className:`font-mono`,children:sm(c.peakTotal)})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Avg input:`}),(0,T.jsx)(`span`,{className:`font-mono text-blue-600 dark:text-blue-400`,children:sm(c.avgInput)})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Avg output:`}),(0,T.jsx)(`span`,{className:`font-mono text-emerald-600 dark:text-emerald-400`,children:sm(c.avgOutput)})]})]}),l.total>0&&(0,T.jsx)(`div`,{className:`pt-3 border-t border-muted/50`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,T.jsx)(`div`,{className:`text-muted-foreground text-xs mt-0.5`,children:`└─`}),(0,T.jsxs)(`div`,{className:`flex-1`,children:[(0,T.jsxs)(`div`,{className:`text-xs text-muted-foreground mb-2 flex items-center gap-1`,children:[(0,T.jsx)(Jl,{className:`h-3 w-3`}),`Total Composition (all turns)`]}),(0,T.jsx)(fm,{composition:l})]})]})})]})]})]})})]})}Td();function gm(){return(0,T.jsx)(`div`,{className:`flex items-center gap-2 py-3 px-2`,children:(0,T.jsx)(`div`,{className:`flex-1 border-t border-border/50`})})}function _m(e){let t=[],n=!1;for(let r=0;r100&&(i.includes(` -`)||i.trim().match(/[.!?]\s*$/))&&(t.push({type:`response.output_text.delta`,delta:i.trim()}),i=``);continue}a.type!==`response.usage.complete`&&t.push(a)}for(let[,e]of n)if(e.arguments.trim()&&e.arguments.trim().length>2){let n=r.get(e.callId)||e.name||`unknown`;t.push({type:`response.function_call.complete`,data:{name:n,arguments:e.arguments,call_id:e.callId}})}return i.trim()&&t.push({type:`response.output_text.delta`,delta:i.trim()}),t}function bm(e){switch(e.type){case`response.output_text.delta`:if(`delta`in e){let t=e.delta||``;return t.length>60?`${t.slice(0,60)}...`:t}return`Text output`;case`response.function_call.complete`:if(`data`in e&&e.data){let t=e.data,n=t.name||`unknown`;(!n||n===`unknown`)&&(n=`function_call`);let r=t.arguments?typeof t.arguments==`string`?t.arguments.slice(0,30):JSON.stringify(t.arguments).slice(0,30):``;return`Calling ${n}(${r}${r.length>=30?`...`:``})`}return`Function call`;case`response.function_call_arguments.delta`:return`delta`in e&&e.delta?`Function arg delta: ${e.delta.slice(0,30)}${e.delta.length>30?`...`:``}`:`Function arguments...`;case`response.function_result.complete`:{let t=e.output.slice(0,40);return`Function result: ${t}${t.length>=40?`...`:``}`}case`response.output_item.added`:{let t=e;return t.item.type===`function_call`?`Tool call: ${t.item.name}`:`Output item added`}case`response.workflow_event.completed`:return`data`in e&&e.data?`Executor: ${e.data.executor_id||`unknown`}`:`Workflow event`;case`response.trace.completed`:return`data`in e&&e.data?`Trace: ${e.data.operation_name||`unknown`}`:`Trace event`;case`response.completed`:if(`response`in e&&e.response&&`usage`in e.response){let t=e.response.usage;if(t)return`Response complete (${t.total_tokens} tokens)`}return`Response complete`;case`response.done`:return`Response complete`;case`error`:return`message`in e&&typeof e.message==`string`?e.message:`Error occurred`;default:return`${e.type}`}}function xm(e){switch(e){case`response.output_text.delta`:return nu;case`response.function_call.complete`:case`response.function_call.delta`:case`response.function_call_arguments.delta`:return Eu;case`response.function_result.complete`:return Al;case`response.output_item.added`:return Al;case`response.workflow_event.completed`:return _l;case`response.trace.completed`:return pu;case`response.completed`:return Al;case`response.done`:return Al;case`error`:return Ml;default:return Ol}}function Sm(e){switch(e){case`response.output_text.delta`:return`text-gray-600 dark:text-gray-400`;case`response.function_call.complete`:case`response.function_call.delta`:case`response.function_call_arguments.delta`:return`text-blue-600 dark:text-blue-400`;case`response.function_result.complete`:return`text-green-600 dark:text-green-400`;case`response.output_item.added`:return`text-green-600 dark:text-green-400`;case`response.workflow_event.completed`:return`text-purple-600 dark:text-purple-400`;case`response.trace.completed`:return`text-orange-600 dark:text-orange-400`;case`response.completed`:return`text-green-600 dark:text-green-400`;case`response.done`:return`text-green-600 dark:text-green-400`;case`error`:return`text-red-600 dark:text-red-400`;default:return`text-gray-600 dark:text-gray-400`}}function Cm({event:e}){let[t,n]=(0,y.useState)(!1),r=e.type||`unknown`,i=xm(r),a=Sm(r),o=`_uiTimestamp`in e&&typeof e._uiTimestamp==`number`?new Date(e._uiTimestamp*1e3).toLocaleTimeString():new Date().toLocaleTimeString(),s=bm(e),c=e.type===`response.function_call.complete`&&`data`in e&&e.data||e.type===`response.function_result.complete`||e.type===`response.output_item.added`&&vm(e)!==null||e.type===`response.workflow_event.completed`&&`data`in e&&e.data||e.type===`response.trace.completed`&&`data`in e&&e.data||e.type===`response.trace.completed`&&`data`in e&&e.data||e.type===`response.output_text.delta`&&`delta`in e&&e.delta&&e.delta.length>100||e.type===`response.completed`&&`response`in e&&e.response||e.type===`error`;return(0,T.jsxs)(`div`,{className:`border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors`,"data-devui-debug-event":r,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground mb-1`,children:[(0,T.jsx)(i,{className:`h-3 w-3 ${a}`}),(0,T.jsx)(`span`,{className:`font-mono`,children:o}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs py-0`,children:e.type?e.type.replace(`response.`,``):`unknown`})]}),(0,T.jsxs)(`div`,{className:`text-sm`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 ${c?`cursor-pointer`:``}`,onClick:()=>c&&n(!t),children:[c&&(0,T.jsx)(`div`,{className:`text-muted-foreground`,children:t?(0,T.jsx)(X,{className:`h-3 w-3`}):(0,T.jsx)(Z,{className:`h-3 w-3`})}),(0,T.jsx)(`div`,{className:`text-muted-foreground flex-1`,children:c&&s.length>80?`${s.slice(0,80)}...`:s})]}),t&&c&&(0,T.jsx)(`div`,{className:`mt-2 ml-5 p-3 bg-muted/30 rounded border`,children:(0,T.jsx)(wm,{event:e})})]})]})}function wm({event:e}){if(e.type===`error`){let t=e;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Ml,{className:`h-4 w-4 text-red-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Error Details`})]}),(0,T.jsxs)(`div`,{className:`text-xs`,children:[t.message&&(0,T.jsxs)(`div`,{className:`mb-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Message:`}),(0,T.jsx)(`div`,{className:`mt-1`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-destructive/10 border border-destructive/30 rounded p-2 text-destructive whitespace-pre-wrap break-all`,children:t.message})})]}),t.code&&(0,T.jsxs)(`div`,{className:`mb-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Code:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.code})]}),t.param&&(0,T.jsxs)(`div`,{className:`mb-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Parameter:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.param})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Raw Event:`}),(0,T.jsx)(`div`,{className:`mt-1`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all max-h-32 overflow-auto`,children:JSON.stringify(e,null,2)})})]})]})]})}switch(e.type){case`response.function_call.complete`:if(`data`in e&&e.data){let t=e.data;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Eu,{className:`h-4 w-4 text-blue-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Function Call`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Function:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded`,children:t.name||`unknown`})]}),t.call_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Call ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.call_id})]}),t.arguments&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Arguments:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:typeof t.arguments==`string`?t.arguments:JSON.stringify(t.arguments,null,1)})})]})]})]})}break;case`response.function_result.complete`:{let t=e;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Al,{className:`h-4 w-4 text-green-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Function Result`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Call ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.call_id})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Status:`}),(0,T.jsx)(`span`,{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${t.status===`completed`?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`}`,children:t.status})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Output:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:t.output})})]})]})]})}case`response.output_item.added`:{let t=vm(e);if(t)return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Al,{className:`h-4 w-4 text-green-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Function Result`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Call ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.call_id})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Status:`}),(0,T.jsx)(`span`,{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${t.status===`completed`?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`}`,children:t.status})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Output:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:t.output})})]})]})]});break}case`response.workflow_event.completed`:if(`data`in e&&e.data){let t=e.data;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(_l,{className:`h-4 w-4 text-purple-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Workflow Event`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Event Type:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono bg-purple-100 dark:bg-purple-900 px-2 py-1 rounded`,children:t.event_type||`unknown`})]}),t.executor_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Executor:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono`,children:t.executor_id})]}),t.timestamp&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Timestamp:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.timestamp})]}),t.data&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Data:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:typeof t.data==`string`?t.data:JSON.stringify(t.data,null,1)})})]})]})]})}break;case`response.trace.completed`:if(`data`in e&&e.data){let t=e.data;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(pu,{className:`h-4 w-4 text-orange-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Trace Event`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Operation:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono bg-orange-100 dark:bg-orange-900 px-2 py-1 rounded`,children:t.operation_name||`unknown`})]}),t.span_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Span ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.span_id})]}),t.trace_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Trace ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.trace_id})]}),t.duration_ms&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Duration:`}),(0,T.jsxs)(`span`,{className:`ml-2 font-mono text-xs`,children:[Number(t.duration_ms).toFixed(2),`ms`]})]}),t.status&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Status:`}),(0,T.jsx)(`span`,{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${t.status===`StatusCode.UNSET`||t.status===`OK`?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`}`,children:t.status||`unknown`})]}),t.entity_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Entity:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.entity_id})]}),t.attributes&&Object.keys(t.attributes).length>0&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Attributes:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all`,children:Om(t.attributes)})})]})]})]})}break;case`response.output_text.delta`:if(`delta`in e&&e.delta)return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(nu,{className:`h-4 w-4 text-gray-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Text Output`})]}),(0,T.jsx)(`div`,{className:`max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:e.delta})})]});break;case`response.completed`:if(`response`in e&&e.response){let t=e.response;return(0,T.jsx)(`div`,{className:`space-y-2`,children:(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[t.usage&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`div`,{children:(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Usage:`})}),(0,T.jsxs)(`div`,{className:`ml-4 space-y-1`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Input tokens:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono`,children:t.usage.input_tokens})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Output tokens:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono`,children:t.usage.output_tokens})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Total tokens:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono bg-green-100 dark:bg-green-900 px-2 py-1 rounded`,children:t.usage.total_tokens})]})]})]}),t.id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Response ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs break-all`,children:t.id})]}),t.model&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Model:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs break-all`,children:t.model})]})]})})}break;default:return(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:(0,T.jsx)(`pre`,{className:`bg-background border rounded p-2 overflow-auto max-h-32`,children:JSON.stringify(e,null,2)})})}return null}function Tm({events:e,processedEvents:t,isStreaming:n}){let r=(0,y.useRef)(null),i=[...(0,y.useMemo)(()=>_m(t),[t])].reverse();return(0,T.jsxs)(`div`,{className:`h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between p-3 border-b`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(_l,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium`,children:`Events`}),(0,T.jsxs)(ct,{variant:`outline`,children:[t.length,e.length>t.length?` (${e.length} raw)`:``]})]}),n&&(0,T.jsxs)(`div`,{className:`flex items-center gap-1 text-xs text-muted-foreground`,children:[(0,T.jsx)(`div`,{className:`h-2 w-2 animate-pulse rounded-full bg-green-500 dark:bg-green-400`}),`Streaming`]})]}),(0,T.jsx)(gf,{ref:r,className:`flex-1`,children:(0,T.jsx)(`div`,{className:`p-3`,children:t.length===0?(0,T.jsx)(`div`,{className:`text-center text-muted-foreground text-sm py-8`,children:e.length===0?`No events yet. Start a conversation to see real-time events.`:`Processing events... Accumulated events will appear here.`}):(0,T.jsx)(`div`,{className:`space-y-2`,children:i.map((e,t)=>`type`in e&&e.type===`separator`?(0,T.jsx)(gm,{},e.id):(0,T.jsx)(Cm,{event:e},`${e.type}-${t}`))})})})]})}function Em(e){let t=new Map;for(let n of e){if(!(`data`in n))continue;let e=n.data.response_id||`unknown`;t.has(e)||t.set(e,[]),t.get(e).push(n)}let n=[];for(let[e,r]of t){let t=new Map,i=[];for(let e of r){if(!(`data`in e))continue;let n=e.data,r=n.span_id||`span_${Math.random()}`;t.set(r,{event:e,data:n,children:[]})}for(let e of r){if(!(`data`in e))continue;let n=e.data,r=n.span_id||``,a=n.parent_span_id,o=t.get(r);o&&(a&&t.has(a)?t.get(a).children.push(o):i.push(o))}i.sort((e,t)=>(e.data.start_time||0)-(t.data.start_time||0));let a=e=>{e.children.sort((e,t)=>(e.data.start_time||0)-(t.data.start_time||0)),e.children.forEach(a)};i.forEach(a);let o=r[0],s=o&&`data`in o?o.data:null,c=Math.min(...r.map(e=>(`data`in e?e.data:null)?.start_time||Date.now()/1e3)),l=r.reduce((e,t)=>e+((`data`in t?t.data:null)?.duration_ms||0),0);n.push({response_id:e,timestamp:c,traces:i,totalDuration:l,entity_id:s?.entity_id})}return n.sort((e,t)=>t.timestamp-e.timestamp),n}function Dm(e){if(typeof e==`string`){let t=e.trim();if(t.startsWith(`[`)||t.startsWith(`{`))try{return Dm(JSON.parse(e))}catch{return e}return e}if(Array.isArray(e))return e.map(Dm);if(typeof e==`object`&&e){let t={};for(let[n,r]of Object.entries(e))t[n]=Dm(r);return t}return e}function Om(e){try{let t=Dm(e);return JSON.stringify(t,null,2)}catch{return JSON.stringify(e,null,2)}}function km(e){return e.includes(`invoke_agent`)||e.includes(`Agent`)?`bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200`:e.includes(`chat`)||e.includes(`Chat`)?`bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200`:e.includes(`tool`)||e.includes(`execute`)?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200`}function Am({node:e,depth:t=0}){let[n,r]=(0,y.useState)(t<2),[i,a]=(0,y.useState)(!1),{data:o}=e,s=o.operation_name||`Unknown`,c=o.duration_ms?`${Number(o.duration_ms).toFixed(1)}ms`:``,l=e.children.length>0,u=o.attributes?.[`gen_ai.usage.input_tokens`],d=o.attributes?.[`gen_ai.usage.output_tokens`],f=u!==void 0||d!==void 0;return(0,T.jsxs)(`div`,{className:`relative`,children:[t>0&&(0,T.jsx)(`div`,{className:`absolute left-0 top-0 bottom-0 border-l-2 border-muted`,style:{marginLeft:`${(t-1)*16+8}px`}}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 py-1.5 hover:bg-muted/50 rounded transition-colors`,style:{paddingLeft:`${t*16}px`},children:[(0,T.jsx)(`button`,{onClick:()=>l?r(!n):a(!i),className:`w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground`,children:l?n?(0,T.jsx)(X,{className:`h-3 w-3`}):(0,T.jsx)(Z,{className:`h-3 w-3`}):i?(0,T.jsx)(X,{className:`h-3 w-3`}):(0,T.jsx)(Z,{className:`h-3 w-3`})}),(0,T.jsx)(`span`,{className:`text-xs px-1.5 py-0.5 rounded font-medium ${km(s)}`,children:s.replace(`Agent.`,``).replace(`invoke_agent `,``)}),c&&(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground font-mono`,children:c}),f&&(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground font-mono`,children:[u!==void 0&&(0,T.jsxs)(`span`,{children:[`↑`,String(u)]}),u!==void 0&&d!==void 0&&(0,T.jsx)(`span`,{className:`mx-0.5`,children:`/`}),d!==void 0&&(0,T.jsxs)(`span`,{children:[`↓`,String(d)]})]})]}),i&&!l&&(0,T.jsx)(`div`,{className:`ml-4 mt-1 mb-2 p-2 bg-muted/30 rounded border text-xs`,style:{marginLeft:`${t*16+20}px`},children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[o.span_id&&(0,T.jsxs)(`div`,{className:`flex gap-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground w-20`,children:`Span ID:`}),(0,T.jsx)(`span`,{className:`font-mono text-xs break-all`,children:o.span_id})]}),o.trace_id&&(0,T.jsxs)(`div`,{className:`flex gap-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground w-20`,children:`Trace ID:`}),(0,T.jsx)(`span`,{className:`font-mono text-xs break-all`,children:o.trace_id})]}),o.status&&(0,T.jsxs)(`div`,{className:`flex gap-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground w-20`,children:`Status:`}),(0,T.jsx)(`span`,{className:`px-1.5 py-0.5 rounded text-xs ${o.status===`StatusCode.UNSET`||o.status===`OK`?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`}`,children:o.status})]}),o.attributes&&Object.keys(o.attributes).length>0&&(0,T.jsxs)(`div`,{className:`mt-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground block mb-1`,children:`Attributes:`}),(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 overflow-auto max-h-32 whitespace-pre-wrap break-all`,children:Om(o.attributes)})]})]})}),l&&n&&(0,T.jsx)(`div`,{children:e.children.map((e,n)=>(0,T.jsx)(Am,{node:e,depth:t+1},e.data.span_id||n))})]})}function jm({group:e}){let[t,n]=(0,y.useState)(!0),r=new Date(e.timestamp*1e3).toLocaleTimeString(),i=e.totalDuration>0?`${e.totalDuration.toFixed(0)}ms`:``,a=e.traces.reduce((e,t)=>{let n=e=>1+e.children.reduce((e,t)=>e+n(t),0);return e+n(t)},0);return(0,T.jsxs)(`div`,{className:`border rounded-lg overflow-hidden`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 p-2 bg-muted/50 cursor-pointer hover:bg-muted/70 transition-colors`,onClick:()=>n(!t),children:[(0,T.jsx)(`div`,{className:`text-muted-foreground`,children:t?(0,T.jsx)(X,{className:`h-4 w-4`}):(0,T.jsx)(Z,{className:`h-4 w-4`})}),(0,T.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:r}),e.entity_id&&(0,T.jsx)(ct,{variant:`outline`,className:`text-xs py-0`,children:e.entity_id.replace(`agent_`,``).replace(`workflow_`,``)}),(0,T.jsx)(`div`,{className:`flex-1`}),i&&(0,T.jsx)(ct,{variant:`secondary`,className:`text-xs py-0`,children:i}),(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[a,` span`,a===1?``:`s`]})]}),t&&(0,T.jsx)(`div`,{className:`p-2 border-t`,children:e.traces.map((e,t)=>(0,T.jsx)(Am,{node:e,depth:0},e.data.span_id||t))})]})}function Mm({events:e}){let t=Q(e=>e.debugTraceSubTab),n=Q(e=>e.setDebugTraceSubTab),r=e.filter(e=>e.type===`response.trace.completed`),i=Em(r);return(0,T.jsxs)(`div`,{className:`h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 p-3 border-b`,children:[(0,T.jsx)(pu,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium`,children:`Traces`}),(0,T.jsx)(ct,{variant:`outline`,children:r.length}),(0,T.jsx)(`div`,{className:`flex-1`}),(0,T.jsxs)(`div`,{className:`flex items-center bg-muted rounded-md p-1 min-w-0`,children:[(0,T.jsx)(`button`,{onClick:()=>n(`spans`),className:`px-3 py-1.5 text-xs rounded transition-colors truncate ${t===`spans`?`bg-background shadow-sm font-medium`:`text-muted-foreground hover:text-foreground`}`,children:`OTel Spans`}),(0,T.jsxs)(`button`,{onClick:()=>n(`context`),className:`px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-1.5 min-w-0 ${t===`context`?`bg-background shadow-sm font-medium`:`text-muted-foreground hover:text-foreground`}`,children:[(0,T.jsx)(Cl,{className:`h-3.5 w-3.5 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`truncate`,children:`Context Inspector`})]})]})]}),t===`spans`?(0,T.jsxs)(`div`,{className:`flex-1 flex flex-col min-h-0`,children:[r.length>0&&(0,T.jsx)(`div`,{className:`p-3 border-b flex-shrink-0`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(pu,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`OTel Spans`}),(0,T.jsxs)(ct,{variant:`outline`,className:`text-xs`,children:[i.length,` turn`,i.length===1?``:`s`]})]})}),r.length===0?(0,T.jsxs)(`div`,{className:`flex flex-col items-center text-center p-6 pt-9`,children:[(0,T.jsx)(Cl,{className:`h-8 w-8 text-muted-foreground mb-3`}),(0,T.jsx)(`div`,{className:`text-sm font-medium mb-1`,children:`No Data`}),(0,T.jsxs)(`div`,{className:`text-xs text-muted-foreground max-w-[200px]`,children:[`Run`,` `,(0,T.jsx)(`span`,{className:`font-mono bg-accent/10 px-1 rounded`,children:`devui --instrumentation`}),` `,`and start a conversation.`]})]}):(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsx)(`div`,{className:`p-3`,children:(0,T.jsx)(`div`,{className:`space-y-3`,children:i.map(e=>(0,T.jsx)(jm,{group:e},e.response_id))})})})]}):(0,T.jsx)(hm,{events:e})]})}function Nm({events:e,processedEvents:t}){let n=[],r=t.filter(e=>e.type===`response.function_call.complete`),i=e.filter(e=>vm(e)!==null),a=new Map;i.forEach(e=>{let t=vm(e);t&&a.set(t.call_id,e)}),r.forEach(e=>{if(n.push(e),`data`in e&&e.data&&e.data.call_id){let t=String(e.data.call_id),r=a.get(t);r&&(n.push(r),a.delete(t))}}),a.forEach(e=>{n.push(e)});let o=[..._m(n)].reverse();return(0,T.jsxs)(`div`,{className:`h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 p-3 border-b`,children:[(0,T.jsx)(Eu,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium`,children:`Tools`}),(0,T.jsx)(ct,{variant:`outline`,children:n.length})]}),(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsx)(`div`,{className:`p-3`,children:n.length===0?(0,T.jsx)(`div`,{className:`text-center text-muted-foreground text-sm py-8`,children:`No tool executions yet. Tool calls will appear here during conversations.`}):(0,T.jsx)(`div`,{className:`space-y-3`,children:o.map((e,t)=>`type`in e&&e.type===`separator`?(0,T.jsx)(gm,{},e.id):(0,T.jsx)(Pm,{event:e},t))})})})]})}function Pm({event:e}){let t=`_uiTimestamp`in e&&typeof e._uiTimestamp==`number`?new Date(e._uiTimestamp*1e3).toLocaleTimeString():new Date().toLocaleTimeString(),n=e.type===`response.function_call.complete`,r=vm(e),i=r!==null;if(!n&&!i)return null;let a=n&&`data`in e?e.data:null;return(0,T.jsxs)(`div`,{className:`border rounded p-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between mb-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Ou,{className:`h-4 w-4 text-yellow-600 dark:text-yellow-400`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:n?`Tool Call`:`Tool Result`}),n&&a&&a.name!==void 0&&(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`(`,String(a.name),`)`]})]}),(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground font-mono`,children:t})]}),n&&a&&(0,T.jsxs)(`div`,{className:`p-2 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-2`,children:[(0,T.jsx)(Eu,{className:`h-3 w-3 text-blue-600 dark:text-blue-400`}),(0,T.jsx)(`span`,{className:`text-xs font-mono bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded`,children:`CALL`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:String(a.name||`unknown`)})]}),a.arguments!==void 0&&(0,T.jsxs)(`div`,{className:`text-xs`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground mb-1 block`,children:`Arguments:`}),(0,T.jsx)(`pre`,{className:`p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap`,children:typeof a.arguments==`string`?a.arguments:JSON.stringify(a.arguments,null,1)})]})]}),i&&r&&(0,T.jsxs)(`div`,{className:`p-2 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-2`,children:[(0,T.jsx)(Al,{className:`h-3 w-3 text-green-600 dark:text-green-400`}),(0,T.jsx)(`span`,{className:`text-xs font-mono bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded`,children:`RESULT`}),r.status!==`completed`&&(0,T.jsx)(`span`,{className:`ml-auto px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`,children:r.status})]}),(0,T.jsxs)(`div`,{className:`text-xs space-y-1`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Call ID:`}),(0,T.jsx)(`span`,{className:`font-mono text-xs break-all`,children:r.call_id})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground block mb-1`,children:`Output:`}),(0,T.jsx)(`pre`,{className:`p-2 bg-background border rounded text-xs overflow-auto max-h-32 break-all whitespace-pre-wrap`,children:r.output})]})]})]})]})}function Fm({events:e,isStreaming:t=!1,onMinimize:n}){let r=Q(e=>e.debugPanelTab),i=Q(e=>e.setDebugPanelTab),a=(0,y.useMemo)(()=>ym(e),[e]),o=(0,y.useMemo)(()=>({eventsCount:a.length,tracesCount:e.filter(e=>e.type===`response.trace.completed`).length,toolsCount:a.filter(e=>e.type===`response.function_call.complete`).length+e.filter(e=>vm(e)!==null).length}),[e,a]);return(0,T.jsx)(`div`,{className:`flex-1 border-l flex flex-col min-h-0`,children:(0,T.jsxs)(Lf,{value:r,onValueChange:e=>i(e),className:`flex-1 flex flex-col min-h-0`,children:[(0,T.jsxs)(`div`,{className:`px-3 pt-3 flex items-center gap-2 flex-shrink-0`,children:[(0,T.jsxs)(Rf,{className:`flex-1`,children:[(0,T.jsxs)(zf,{value:`events`,className:`flex-1 gap-1.5`,children:[`Events`,o.eventsCount>0&&(0,T.jsx)(`span`,{className:`text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`,children:o.eventsCount})]}),(0,T.jsxs)(zf,{value:`traces`,className:`flex-1 gap-1.5`,children:[`Traces`,o.tracesCount>0&&(0,T.jsx)(`span`,{className:`text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`,children:o.tracesCount})]}),(0,T.jsxs)(zf,{value:`tools`,className:`flex-1 gap-1.5`,children:[`Tools`,o.toolsCount>0&&(0,T.jsx)(`span`,{className:`text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`,children:o.toolsCount})]})]}),n&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:n,className:`h-8 w-8 p-0 flex-shrink-0`,title:`Minimize debug panel`,children:(0,T.jsx)(Z,{className:`h-4 w-4`})})]}),(0,T.jsx)(Bf,{value:`events`,className:`flex-1 mt-0 overflow-hidden`,children:(0,T.jsx)(Tm,{events:e,processedEvents:a,isStreaming:t})}),(0,T.jsx)(Bf,{value:`traces`,className:`flex-1 mt-0 overflow-hidden`,children:(0,T.jsx)(Mm,{events:e})}),(0,T.jsx)(Bf,{value:`tools`,className:`flex-1 mt-0 overflow-hidden`,children:(0,T.jsx)(Nm,{events:e,processedEvents:a})})]})})}function Im({open:e,onOpenChange:t,children:n}){return e?(0,T.jsxs)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center`,children:[(0,T.jsx)(`div`,{className:`absolute inset-0 bg-black/50`,onClick:()=>{t(!1)}}),(0,T.jsx)(`div`,{className:`relative z-10`,onClick:e=>{e.stopPropagation()},onMouseDown:e=>{e.stopPropagation()},onMouseUp:e=>e.stopPropagation(),children:n})]}):null}function Lm({children:e,className:t=``}){return(0,T.jsx)(`div`,{className:`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${t.includes(`w-[`)||t.includes(`w-full`)||t.includes(`max-w-`)?``:`max-w-lg w-full`} ${t}`,children:e})}function Rm({children:e,className:t=``}){return(0,T.jsx)(`div`,{className:`space-y-2 ${t}`,children:e})}function zm({children:e,className:t=``}){return(0,T.jsx)(`h2`,{className:`text-lg font-semibold ${t}`,children:e})}function Bm({children:e,className:t=``}){return(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground ${t}`,children:e})}function Vm({onClose:e}){return(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:e,className:`absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100`,children:(0,T.jsx)(Du,{className:`h-4 w-4`})})}function Hm({children:e}){return(0,T.jsx)(`div`,{className:`flex justify-end gap-2 p-4 border-t bg-muted/50`,children:e})}function Um({className:e,type:t,...n}){return(0,T.jsx)(`input`,{type:t,"data-slot":`input`,className:at(`file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm`,`focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`,`aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive`,e),...n})}var Wm=`Label`,Gm=y.forwardRef((e,t)=>(0,T.jsx)(J.label,{...e,ref:t,onMouseDown:t=>{t.target.closest(`button, input, select, textarea`)||(e.onMouseDown?.(t),!t.defaultPrevented&&t.detail>1&&t.preventDefault())}}));Gm.displayName=Wm;var Km=Gm;function qm({className:e,...t}){return(0,T.jsx)(Km,{"data-slot":`label`,className:at(`flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50`,e),...t})}var Jm=`Switch`,[Ym,Xm]=lt(Jm),[Zm,Qm]=Ym(Jm),$m=y.forwardRef((e,t)=>{let{__scopeSwitch:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c=`on`,onCheckedChange:l,form:u,...d}=e,[f,p]=y.useState(null),m=C(t,e=>p(e)),h=y.useRef(!1),g=f?u||!!f.closest(`form`):!0,[_,v]=pt({prop:i,defaultProp:a??!1,onChange:l,caller:Jm});return(0,T.jsxs)(Zm,{scope:n,checked:_,disabled:s,children:[(0,T.jsx)(J.button,{type:`button`,role:`switch`,"aria-checked":_,"aria-required":o,"data-state":ih(_),"data-disabled":s?``:void 0,disabled:s,value:c,...d,ref:m,onClick:q(e.onClick,e=>{v(e=>!e),g&&(h.current=e.isPropagationStopped(),h.current||e.stopPropagation())})}),g&&(0,T.jsx)(rh,{control:f,bubbles:!h.current,name:r,value:c,checked:_,required:o,disabled:s,form:u,style:{transform:`translateX(-100%)`}})]})});$m.displayName=Jm;var eh=`SwitchThumb`,th=y.forwardRef((e,t)=>{let{__scopeSwitch:n,...r}=e,i=Qm(eh,n);return(0,T.jsx)(J.span,{"data-state":ih(i.checked),"data-disabled":i.disabled?``:void 0,...r,ref:t})});th.displayName=eh;var nh=`SwitchBubbleInput`,rh=y.forwardRef(({__scopeSwitch:e,control:t,checked:n,bubbles:r=!0,...i},a)=>{let o=y.useRef(null),s=C(o,a),c=Vf(n),l=Ti(t);return y.useEffect(()=>{let e=o.current;if(!e)return;let t=window.HTMLInputElement.prototype,i=Object.getOwnPropertyDescriptor(t,`checked`).set;if(c!==n&&i){let t=new Event(`click`,{bubbles:r});i.call(e,n),e.dispatchEvent(t)}},[c,n,r]),(0,T.jsx)(`input`,{type:`checkbox`,"aria-hidden":!0,defaultChecked:n,...i,tabIndex:-1,ref:s,style:{...i.style,...l,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0}})});rh.displayName=nh;function ih(e){return e?`checked`:`unchecked`}var ah=$m,oh=th,sh=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(ah,{className:at(`peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input`,e),...t,ref:n,children:(0,T.jsx)(oh,{className:at(`pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0`)})}));sh.displayName=ah.displayName,Dd();var ch=[`gpt-4.1`,`gpt-4.1-mini`,`o1`,`o1-mini`,`o3-mini`];function lh({open:e,onOpenChange:t,onBackendUrlChange:n}){let[r,i]=(0,y.useState)(`general`),{oaiMode:a,setOAIMode:o,azureDeploymentEnabled:s,setAzureDeploymentEnabled:c,authRequired:l,serverCapabilities:u,serverVersion:d,runtime:f,uiMode:p,streamingEnabled:m,setStreamingEnabled:h}=Q(),[g,_]=(0,y.useState)(()=>localStorage.getItem(`devui_backend_url`)||``),[v,b]=(0,y.useState)(g),[x,S]=(0,y.useState)(!!localStorage.getItem(`devui_auth_token`)),[C,w]=(0,y.useState)(``),E=()=>{try{new URL(v),localStorage.setItem(`devui_backend_url`,v),_(v),n?.(v),t(!1),window.location.reload()}catch{alert(`Please enter a valid URL (e.g., http://localhost:8080)`)}},D=()=>{localStorage.removeItem(`devui_backend_url`),b(``),_(``),n?.(``),window.location.reload()},O=()=>{C.trim()&&(localStorage.setItem(`devui_auth_token`,C.trim()),S(!0),w(``),window.location.reload())},k=()=>{localStorage.removeItem(`devui_auth_token`),S(!1),w(``),window.location.reload()},A=v!==g,j=!localStorage.getItem(`devui_backend_url`);return(0,T.jsx)(Im,{open:e,onOpenChange:t,children:(0,T.jsxs)(Lm,{className:`w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]`,children:[(0,T.jsx)(Rm,{className:`p-6 pb-2 flex-shrink-0`,children:(0,T.jsx)(zm,{children:`Settings`})}),(0,T.jsx)(Vm,{onClose:()=>t(!1)}),(0,T.jsxs)(`div`,{className:`flex border-b px-6 flex-shrink-0`,children:[(0,T.jsxs)(`button`,{onClick:()=>i(`general`),className:`px-4 py-2 text-sm font-medium transition-colors relative ${r===`general`?`text-foreground`:`text-muted-foreground hover:text-foreground`}`,children:[`General`,r===`general`&&(0,T.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 h-0.5 bg-primary`})]}),u.openai_proxy&&(0,T.jsxs)(`button`,{onClick:()=>i(`proxy`),className:`px-4 py-2 text-sm font-medium transition-colors relative ${r===`proxy`?`text-foreground`:`text-muted-foreground hover:text-foreground`}`,children:[`OpenAI Proxy`,r===`proxy`&&(0,T.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 h-0.5 bg-primary`})]}),(0,T.jsxs)(`button`,{onClick:()=>i(`about`),className:`px-4 py-2 text-sm font-medium transition-colors relative ${r===`about`?`text-foreground`:`text-muted-foreground hover:text-foreground`}`,children:[`About`,r===`about`&&(0,T.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 h-0.5 bg-primary`})]})]}),(0,T.jsxs)(`div`,{className:`px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]`,children:[r===`general`&&(0,T.jsxs)(`div`,{className:`space-y-6 pt-4`,children:[(0,T.jsxs)(`div`,{className:`space-y-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsx)(qm,{htmlFor:`backend-url`,className:`text-sm font-medium`,children:`Backend URL`}),!j&&(0,T.jsxs)(K,{variant:`ghost`,size:`sm`,onClick:D,className:`h-7 text-xs`,title:`Reset to default`,children:[(0,T.jsx)(fu,{className:`h-3 w-3 mr-1`}),`Reset`]})]}),(0,T.jsx)(Um,{id:`backend-url`,type:`url`,value:v,onChange:e=>b(e.target.value),placeholder:`http://localhost:8080`,className:`font-mono text-sm`}),(0,T.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Default: `,(0,T.jsx)(`span`,{className:`font-mono`,children:``})]}),(0,T.jsx)(`div`,{className:`flex gap-2 pt-2 min-h-[36px]`,children:A&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(K,{onClick:E,size:`sm`,className:`flex-1`,children:`Apply & Reload`}),(0,T.jsx)(K,{onClick:()=>b(g),variant:`outline`,size:`sm`,className:`flex-1`,children:`Cancel`})]})})]}),(l||x)&&(0,T.jsxs)(`div`,{className:`space-y-3 border-t pt-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Authentication Token`}),!l&&x&&(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`(Not required by current backend)`})]}),x?(0,T.jsxs)(`div`,{className:`space-y-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Um,{type:`password`,value:`••••••••••••••••••••`,disabled:!0,className:`font-mono text-sm flex-1`}),(0,T.jsx)(K,{variant:`destructive`,size:`sm`,onClick:k,className:`flex-shrink-0`,children:`Clear`})]}),(0,T.jsx)(`p`,{className:`text-xs text-green-600 dark:text-green-400`,children:`✓ Token configured and stored locally`})]}):(0,T.jsxs)(`div`,{className:`space-y-3`,children:[(0,T.jsx)(Um,{type:`password`,value:C,onChange:e=>w(e.target.value),placeholder:`Enter bearer token`,className:`font-mono text-sm`,onKeyDown:e=>{e.key===`Enter`&&C.trim()&&O()}}),(0,T.jsx)(K,{onClick:O,size:`sm`,disabled:!C.trim(),className:`w-full`,children:`Save & Reload`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:l?`Required by backend (started with --auth flag)`:`Not required by current backend`})]})]}),u.deployment&&(0,T.jsxs)(`div`,{className:`space-y-3 border-t pt-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`space-y-0.5`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Azure Deployment`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Enable one-click deployment to Azure Container Apps`})]}),(0,T.jsx)(sh,{checked:s,onCheckedChange:c})]}),(0,T.jsxs)(`details`,{className:`group`,children:[(0,T.jsxs)(`summary`,{className:`cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1`,children:[(0,T.jsx)(Z,{className:`h-3 w-3 transition-transform group-open:rotate-90`}),`Learn more about Azure deployment`]}),(0,T.jsxs)(`div`,{className:`mt-3 space-y-3 pl-4`,children:[(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground leading-relaxed`,children:`When enabled, agents that support deployment will show a "Deploy to Azure" button. This allows you to deploy your agent to Azure Container Apps directly from DevUI.`}),(0,T.jsxs)(`div`,{className:`space-y-1.5`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium`,children:`When enabled:`}),(0,T.jsxs)(`ul`,{className:`text-xs text-muted-foreground space-y-0.5 list-disc list-inside`,children:[(0,T.jsx)(`li`,{children:`Shows "Deploy to Azure" for supported agents`}),(0,T.jsx)(`li`,{children:`Requires Azure CLI and proper authentication`}),(0,T.jsx)(`li`,{children:`Backend must have deployment capabilities enabled`})]})]}),(0,T.jsxs)(`div`,{className:`space-y-1.5`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium`,children:`When disabled:`}),(0,T.jsxs)(`ul`,{className:`text-xs text-muted-foreground space-y-0.5 list-disc list-inside`,children:[(0,T.jsx)(`li`,{children:`Shows "Deployment Guide" for all agents`}),(0,T.jsx)(`li`,{children:`Provides Docker templates and manual deployment instructions`}),(0,T.jsx)(`li`,{children:`No backend deployment capabilities required`})]})]})]})]})]}),(0,T.jsx)(`div`,{className:`space-y-3 border-t pt-6`,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`space-y-0.5`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Show Tool Calls`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Display function/tool calls and results in chat messages`})]}),(0,T.jsx)(sh,{checked:Q.getState().showToolCalls,onCheckedChange:e=>Q.getState().setShowToolCalls(e)})]})}),(0,T.jsxs)(`div`,{className:`space-y-3 border-t pt-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`space-y-0.5`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Streaming Mode`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Stream responses token-by-token as they're generated`})]}),(0,T.jsx)(sh,{checked:m,onCheckedChange:h})]}),!m&&(0,T.jsxs)(`div`,{className:`flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded`,children:[(0,T.jsx)(Jl,{className:`h-3.5 w-3.5 flex-shrink-0 mt-0.5`}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`p`,{className:`font-medium`,children:`Non-streaming mode limitations:`}),(0,T.jsxs)(`ul`,{className:`mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80`,children:[(0,T.jsx)(`li`,{children:`Tool calls won't display in real-time`}),(0,T.jsx)(`li`,{children:`No typing indicator during generation`}),(0,T.jsx)(`li`,{children:`Response appears all at once when complete`})]})]})]})]})]}),r===`proxy`&&u.openai_proxy&&(0,T.jsxs)(`div`,{className:`space-y-6 pt-4`,children:[(0,T.jsxs)(`div`,{className:`space-y-4`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`space-y-0.5`,children:[(0,T.jsx)(qm,{className:`text-base font-medium`,children:`OpenAI Proxy Mode`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Route requests through DevUI backend to OpenAI API`})]}),(0,T.jsx)(sh,{checked:a.enabled,onCheckedChange:e=>o({...a,enabled:e})})]}),!a.enabled&&(0,T.jsx)(`div`,{className:`bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,T.jsx)(Jl,{className:`h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400`}),(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium`,children:`About OpenAI Proxy Mode`}),(0,T.jsxs)(`p`,{className:`text-xs text-muted-foreground leading-relaxed`,children:[`When enabled, your chat requests are sent to your DevUI backend`,` `,(0,T.jsxs)(`span`,{className:`font-mono font-semibold`,children:[`(`,g,`)`]}),`, which then forwards them to OpenAI's API. This keeps your`,` `,(0,T.jsx)(`span`,{className:`font-mono font-semibold`,children:`OPENAI_API_KEY`}),` `,`secure on the server instead of exposing it in the browser.`]}),(0,T.jsxs)(`div`,{className:`space-y-1.5 pt-1`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium`,children:`Requirements:`}),(0,T.jsxs)(`ul`,{className:`text-xs text-muted-foreground space-y-0.5 list-disc list-inside`,children:[(0,T.jsxs)(`li`,{children:[`Backend must have`,` `,(0,T.jsx)(`span`,{className:`font-mono`,children:`OPENAI_API_KEY`}),` `,`configured`]}),(0,T.jsx)(`li`,{children:`Backend must support OpenAI Responses API proxying (DevUI does)`})]})]}),(0,T.jsxs)(`div`,{className:`space-y-1.5 pt-1`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium`,children:`Why use this?`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Quickly test and compare OpenAI models directly through the DevUI interface without creating custom agents or exposing API keys in the browser.`})]})]})]})}),a.enabled&&(0,T.jsxs)(`div`,{className:`space-y-4 pl-4 border-l-2 border-muted`,children:[(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Model`}),(0,T.jsx)(Um,{type:`text`,value:a.model,onChange:e=>o({...a,model:e.target.value}),placeholder:`gpt-4.1-mini`,className:`font-mono text-sm`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)`})]}),(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(qm,{className:`text-xs text-muted-foreground`,children:`Common presets`}),(0,T.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:ch.map(e=>(0,T.jsx)(K,{variant:a.model===e?`default`:`outline`,size:`sm`,onClick:()=>o({...a,model:e}),className:`text-xs h-7`,children:e},e))})]}),(0,T.jsxs)(`details`,{className:`group`,children:[(0,T.jsxs)(`summary`,{className:`cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1`,children:[(0,T.jsx)(Z,{className:`h-3 w-3 transition-transform group-open:rotate-90`}),`Advanced Parameters (optional)`]}),(0,T.jsxs)(`div`,{className:`space-y-3 mt-3 pl-4`,children:[(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(qm,{className:`text-xs`,children:`Temperature`}),(0,T.jsx)(Um,{type:`number`,step:`0.1`,min:`0`,max:`2`,value:a.temperature??``,onChange:e=>o({...a,temperature:e.target.value?parseFloat(e.target.value):void 0}),placeholder:`1.0 (default)`,className:`text-sm`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Controls randomness (0-2)`})]}),(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(qm,{className:`text-xs`,children:`Max Output Tokens`}),(0,T.jsx)(Um,{type:`number`,min:`1`,value:a.max_output_tokens??``,onChange:e=>o({...a,max_output_tokens:e.target.value?parseInt(e.target.value):void 0}),placeholder:`Auto`,className:`text-sm`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Maximum tokens in response`})]}),(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(qm,{className:`text-xs`,children:`Top P`}),(0,T.jsx)(Um,{type:`number`,step:`0.1`,min:`0`,max:`1`,value:a.top_p??``,onChange:e=>o({...a,top_p:e.target.value?parseFloat(e.target.value):void 0}),placeholder:`1.0 (default)`,className:`text-sm`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Nucleus sampling (0-1)`})]}),(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(qm,{className:`text-xs`,children:`Reasoning Effort (o-series models)`}),(0,T.jsxs)(`select`,{value:a.reasoning_effort??``,onChange:e=>o({...a,reasoning_effort:e.target.value?e.target.value:void 0}),className:`flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring`,children:[(0,T.jsx)(`option`,{value:``,children:`Auto (default)`}),(0,T.jsx)(`option`,{value:`minimal`,children:`Minimal`}),(0,T.jsx)(`option`,{value:`low`,children:`Low`}),(0,T.jsx)(`option`,{value:`medium`,children:`Medium`}),(0,T.jsx)(`option`,{value:`high`,children:`High`})]}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Constrains reasoning effort (faster/cheaper vs thorough)`})]})]})]})]})]}),a.enabled&&(0,T.jsxs)(`div`,{className:`flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded`,children:[(0,T.jsx)(Jl,{className:`h-3.5 w-3.5 flex-shrink-0 mt-0.5`}),(0,T.jsx)(`div`,{className:`space-y-1`,children:(0,T.jsxs)(`p`,{children:[`Requests route through`,` `,(0,T.jsx)(`span`,{className:`font-mono font-semibold`,children:g}),` `,`to OpenAI API. Server must have`,` `,(0,T.jsx)(`span`,{className:`font-mono font-semibold`,children:`OPENAI_API_KEY`}),` `,`configured.`]})})]})]}),r===`about`&&(0,T.jsxs)(`div`,{className:`space-y-4 pt-4`,children:[(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`DevUI is a sample app for getting started with Agent Framework.`}),(0,T.jsxs)(`div`,{className:`space-y-2 text-sm`,children:[(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Version:`}),(0,T.jsx)(`span`,{className:`font-mono`,children:d||`Unknown`})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Runtime:`}),(0,T.jsx)(`span`,{className:`font-mono capitalize`,children:f||`Unknown`})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`UI Mode:`}),(0,T.jsx)(`span`,{className:`font-mono capitalize`,children:p||`Unknown`})]})]}),(u||l!==void 0)&&(0,T.jsxs)(`div`,{className:`space-y-2 pt-2`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wide`,children:`Capabilities`}),(0,T.jsxs)(`div`,{className:`space-y-1 text-sm`,children:[u?.instrumentation!==void 0&&(0,T.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Instrumentation:`}),(0,T.jsx)(`span`,{className:`text-xs px-2 py-0.5 rounded-full ${u.instrumentation?`bg-green-500/10 text-green-600 dark:text-green-400`:`bg-muted text-muted-foreground`}`,children:u.instrumentation?`Enabled`:`Disabled`})]}),u?.openai_proxy!==void 0&&(0,T.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`OpenAI Proxy:`}),(0,T.jsx)(`span`,{className:`text-xs px-2 py-0.5 rounded-full ${u.openai_proxy?`bg-green-500/10 text-green-600 dark:text-green-400`:`bg-muted text-muted-foreground`}`,children:u.openai_proxy?`Available`:`Not Configured`})]}),u?.deployment!==void 0&&(0,T.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Deployment:`}),(0,T.jsx)(`span`,{className:`text-xs px-2 py-0.5 rounded-full ${u.deployment?`bg-green-500/10 text-green-600 dark:text-green-400`:`bg-muted text-muted-foreground`}`,children:u.deployment?`Available`:`Disabled`})]}),l!==void 0&&(0,T.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Authentication:`}),(0,T.jsx)(`span`,{className:`text-xs px-2 py-0.5 rounded-full ${l?`bg-blue-500/10 text-blue-600 dark:text-blue-400`:`bg-muted text-muted-foreground`}`,children:l?`Required`:`Not Required`})]})]})]}),(0,T.jsx)(`div`,{className:`flex justify-center pt-2`,children:(0,T.jsxs)(K,{variant:`outline`,size:`sm`,onClick:()=>window.open(`https://github.com/microsoft/agent-framework`,`_blank`),className:`text-xs`,children:[(0,T.jsx)(Vl,{className:`h-3 w-3 mr-1`}),`Learn More about Agent Framework`]})})]})]})]})})}function uh(e,t){if(t.type!==`response.output_text.delta`&&t.type!==`response.refusal.delta`||!(`delta`in t)||typeof t.delta!=`string`||t.delta.length===0)return e;let n=t.type===`response.refusal.delta`?`refusal`:`text`,r=e.map(e=>({...e})),i=r.find(e=>e.itemId===t.item_id&&e.contentIndex===(t.content_index??0)&&e.type===n);return i?i.text+=t.delta:r.push({itemId:t.item_id,contentIndex:t.content_index??0,type:n,text:t.delta}),r}var dh=`devui_streaming_state_`,fh=1440*60*1e3,ph=16*1024;function mh(e){return`${dh}${e}`}function hh(e){if(e.accumulatedParts?.length){let t=[],n=ph;for(let r of[...e.accumulatedParts].reverse()){if(n<=0)break;let e=r.text.slice(-n);t.unshift({...r,text:e}),n-=e.length}let r=t.map(e=>e.text).join(``);return{...e,accumulatedParts:t,accumulatedText:r,accumulatedTextIsPreview:e.accumulatedTextIsPreview||r.lengthe+t.text.length,0)}}return e.accumulatedText===void 0||e.accumulatedText.length<=ph?e:{...e,accumulatedText:e.accumulatedText.slice(-16384),accumulatedTextIsPreview:!0}}function gh(e){let t=mh(e),n=localStorage.getItem(t);if(!n)return null;let r=JSON.parse(n);return Date.now()-r.timestamp>fh?(xh(e),null):hh(r)}function _h({conversationId:e,responseId:t,lastMessageId:n,lastSequenceNumber:r=-1,accumulatedText:i,accumulatedTextIsPreview:a=!1,accumulatedTextType:o,accumulatedParts:s}){return hh({conversationId:e,responseId:t,lastMessageId:n,lastSequenceNumber:r,timestamp:Date.now(),completed:!1,accumulatedText:i,accumulatedTextIsPreview:a,accumulatedTextType:o,accumulatedParts:s})}function vh(e,t,n,r){let i=`sequence_number`in t?t.sequence_number:void 0,a={...e,responseId:n,lastMessageId:r,timestamp:Date.now(),completed:t.type===`response.completed`||t.type===`response.failed`};if(i!==void 0&&(a.lastSequenceNumber=i),(t.type===`response.output_text.delta`||t.type===`response.refusal.delta`)&&`delta`in t&&typeof t.delta==`string`&&t.delta.length>0){let n=t.type===`response.refusal.delta`?`refusal`:`text`,r=uh(e.accumulatedParts?e.accumulatedParts.map(e=>({...e})):e.accumulatedText?[{itemId:e.lastMessageId,contentIndex:0,type:e.accumulatedTextType??`text`,text:e.accumulatedText}]:[],t);a.accumulatedParts=r,a.accumulatedText=r.map(e=>e.text).join(``),a.accumulatedTextIsPreview=e.accumulatedTextIsPreview,a.accumulatedTextType=n}return hh(a)}function yh(e){try{let t=mh(e.conversationId),n=JSON.stringify(hh(e));localStorage.setItem(t,n)}catch(t){console.error(`Failed to save streaming state:`,t);try{Sh();let t=mh(e.conversationId),n=JSON.stringify(hh(e));localStorage.setItem(t,n)}catch{console.error(`Failed to save streaming state even after cleanup`)}}}function bh(e){try{let t=gh(e);return!t||t.completed?null:t}catch(e){return console.error(`Failed to load streaming state:`,e),null}}function xh(e){try{let t=mh(e);localStorage.removeItem(t)}catch(e){console.error(`Failed to clear streaming state:`,e)}}function Sh(){try{let e=Object.keys(localStorage),t=Date.now();for(let n of e)if(n.startsWith(dh))try{let e=localStorage.getItem(n);if(e){let r=JSON.parse(e);(t-r.timestamp>fh||r.completed)&&localStorage.removeItem(n)}}catch{localStorage.removeItem(n)}}catch(e){console.error(`Failed to clear expired streaming states:`,e)}}function Ch(){Sh()}function wh(){let[e,t]=(0,y.useState)(!1),n=(0,y.useRef)(null);return{isCancelling:e,createAbortSignal:(0,y.useCallback)(()=>(n.current=new AbortController,t(!1),n.current.signal),[]),handleCancel:(0,y.useCallback)(()=>{n.current&&=(t(!0),n.current.abort(),null)},[]),resetCancelling:(0,y.useCallback)(()=>{t(!1)},[]),cleanup:(0,y.useCallback)(()=>{n.current&&=(n.current.abort(),null)},[])}}function Th(e){return e instanceof DOMException&&e.name===`AbortError`}function Eh(e={}){let{onDrop:t,disabled:n=!1}=e,[r,i]=(0,y.useState)(!1),[a,o]=(0,y.useState)([]),s=(0,y.useRef)(0),c=(0,y.useCallback)(e=>{e.preventDefault(),e.stopPropagation(),!n&&(s.current++,e.dataTransfer.items&&e.dataTransfer.items.length>0&&i(!0))},[n]),l=(0,y.useCallback)(e=>{e.preventDefault(),e.stopPropagation(),!n&&(s.current--,s.current===0&&i(!1))},[n]),u=(0,y.useCallback)(e=>{e.preventDefault(),e.stopPropagation()},[]),d=(0,y.useCallback)(e=>{if(e.preventDefault(),e.stopPropagation(),i(!1),s.current=0,n)return;let r=Array.from(e.dataTransfer.files);r.length>0&&(o(r),t?.(r))},[n,t]);return{isDragOver:r,droppedFiles:a,clearDroppedFiles:(0,y.useCallback)(()=>{o([])},[]),dragHandlers:{onDragEnter:c,onDragLeave:l,onDragOver:u,onDrop:d}}}var Dh=`modulepreload`,Oh=function(e,t){return new URL(e,t).href},kh={},Ah=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=Oh(t,n),t in kh)return;kh[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:Dh,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},jh=``,Mh=1e3,Nh=10,Ph=250;function Fh(){return localStorage.getItem(`devui_backend_url`)||jh}function Ih(e){return new Promise(t=>setTimeout(t,e))}var Lh=new class{baseUrl;authToken=null;constructor(e){this.baseUrl=e||Fh(),this.authToken=localStorage.getItem(`devui_auth_token`)}setBaseUrl(e){this.baseUrl=e}getBaseUrl(){return this.baseUrl}setAuthToken(e){this.authToken=e,e?localStorage.setItem(`devui_auth_token`,e):localStorage.removeItem(`devui_auth_token`)}getAuthToken(){return this.authToken}clearAuthToken(){this.setAuthToken(null)}async request(e,t={}){let n=`${this.baseUrl}${e}`,r={"Content-Type":`application/json`,...t.headers};this.authToken&&(r.Authorization=`Bearer ${this.authToken}`);let i=await fetch(n,{...t,headers:r});if(!i.ok){if(i.status===401)throw this.clearAuthToken(),Error(`UNAUTHORIZED`);let e=`API request failed: ${i.status} ${i.statusText}`;try{let t=await i.json();t.detail?typeof t.detail==`string`?e=t.detail:typeof t.detail==`object`&&t.detail.error?.message&&(e=t.detail.error.message):t.error?.message&&(e=t.error.message)}catch{}throw Error(e)}return i.json()}async getHealth(){return this.request(`/health`)}async getMeta(){return this.request(`/meta`)}async getEntities(){let e=(await this.request(`/v1/entities`)).entities.map(e=>{if(e.type===`agent`)return{id:e.id,name:e.name,description:e.description,type:`agent`,source:e.source||`directory`,tools:(e.tools||[]).map(e=>typeof e==`string`?e:JSON.stringify(e)),has_env:!!(e.required_env_vars&&e.required_env_vars.length>0),module_path:typeof e.metadata?.module_path==`string`?e.metadata.module_path:void 0,required_env_vars:e.required_env_vars,metadata:e.metadata,deployment_supported:e.deployment_supported,deployment_reason:e.deployment_reason,instructions:e.instructions,model_id:e.model_id,chat_client_type:e.chat_client_type,context_provider:e.context_provider,middleware:e.middleware};{let t=e.executors||e.tools||[],n=e.start_executor_id||``;if(!n&&t.length>0){let e=t[0];typeof e==`string`&&(n=e)}return{id:e.id,name:e.name,description:e.description,type:`workflow`,source:e.source||`directory`,executors:t.map(e=>typeof e==`string`?e:JSON.stringify(e)),has_env:!!(e.required_env_vars&&e.required_env_vars.length>0),module_path:typeof e.metadata?.module_path==`string`?e.metadata.module_path:void 0,required_env_vars:e.required_env_vars,metadata:e.metadata,deployment_supported:e.deployment_supported,deployment_reason:e.deployment_reason,input_schema:e.input_schema||{type:`string`},input_type_name:e.input_type_name||`Input`,start_executor_id:n,tools:[]}}});return{entities:e,agents:e.filter(e=>e.type===`agent`),workflows:e.filter(e=>e.type===`workflow`)}}async getAgents(){let{agents:e}=await this.getEntities();return e}async getWorkflows(){let{workflows:e}=await this.getEntities();return e}async getAgentInfo(e){return this.request(`/v1/entities/${e}/info?type=agent`)}async getWorkflowInfo(e){return this.request(`/v1/entities/${e}/info?type=workflow`)}async reloadEntity(e){return this.request(`/v1/entities/${e}/reload`,{method:`POST`})}async createConversation(e){let{oaiMode:t}=await Ah(()=>Promise.resolve().then(()=>(Dd(),Ed)).then(e=>({oaiMode:e.useDevUIStore.getState().oaiMode})),void 0,import.meta.url),n={};t.enabled&&(n[`X-Proxy-Backend`]=`openai`);let r=await this.request(`/v1/conversations`,{method:`POST`,headers:n,body:JSON.stringify({metadata:e})});return{id:r.id,object:`conversation`,created_at:r.created_at,metadata:r.metadata}}async listConversations(e){let t=e?`/v1/conversations?agent_id=${encodeURIComponent(e)}`:`/v1/conversations`,n=await this.request(t);return{data:n.data.map(e=>({id:e.id,object:`conversation`,created_at:e.created_at,metadata:e.metadata})),has_more:n.has_more}}async getConversation(e){let t=await this.request(`/v1/conversations/${e}`);return{id:t.id,object:`conversation`,created_at:t.created_at,metadata:t.metadata}}async deleteConversation(e){try{return await this.request(`/v1/conversations/${e}`,{method:`DELETE`}),xh(e),!0}catch{return!1}}async listConversationItems(e,t){let n=new URLSearchParams;t?.limit&&n.set(`limit`,t.limit.toString()),t?.after&&n.set(`after`,t.after),t?.order&&n.set(`order`,t.order);let r=n.toString(),i=`/v1/conversations/${e}/items${r?`?${r}`:``}`;return this.request(i)}async getConversationItem(e,t){let n=`/v1/conversations/${e}/items/${t}`;return this.request(n)}async deleteConversationItem(e,t){let n=await fetch(`${this.baseUrl}/v1/conversations/${e}/items/${t}`,{method:`DELETE`});if(!n.ok)throw Error(`Failed to delete item: ${n.statusText}`)}async*streamOpenAIResponse(e,t,n,r){let{oaiMode:i}=await Ah(()=>Promise.resolve().then(()=>(Dd(),Ed)).then(e=>({oaiMode:e.useDevUIStore.getState().oaiMode})),void 0,import.meta.url);i.enabled&&(e.model=i.model,i.temperature!==void 0&&(e.temperature=i.temperature),i.max_output_tokens!==void 0&&(e.max_output_tokens=i.max_output_tokens),i.top_p!==void 0&&(e.top_p=i.top_p),i.instructions!==void 0&&(e.instructions=i.instructions),i.reasoning_effort!==void 0&&(e.reasoning={effort:i.reasoning_effort}));let a=-1,o=0,s=!1,c=r,l,u=0,d=t?bh(t):null,f=d?{...d}:null,p=(e=!1)=>{if(!t||!f)return;let n=Date.now();!e&&n-u{!t||!c||(f=vh(f??_h({conversationId:t,responseId:c,lastMessageId:l,lastSequenceNumber:a,accumulatedText:d?.accumulatedText,accumulatedTextIsPreview:d?.accumulatedTextIsPreview,accumulatedTextType:d?.accumulatedTextType,accumulatedParts:d?.accumulatedParts}),e,c,l),p(!((e.type===`response.output_text.delta`||e.type===`response.refusal.delta`)&&`delta`in e&&typeof e.delta==`string`&&e.delta.length>0)))};for(d&&(r||(c=d.responseId),a=d.lastSequenceNumber,l=d.lastMessageId,s=d.lastSequenceNumber>=0||!!d.accumulatedText);o<=Nh;)try{let r;if(c){let e=new URLSearchParams;e.set(`stream`,`true`),a>=0&&e.set(`starting_after`,a.toString());let t=`${this.baseUrl}/v1/responses/${c}?${e.toString()}`,i={Accept:`text/event-stream`};this.authToken&&(i.Authorization=`Bearer ${this.authToken}`),r=await fetch(t,{method:`GET`,headers:i,signal:n})}else{let t=`${this.baseUrl}/v1/responses`,a={"Content-Type":`application/json`,Accept:`text/event-stream`};i.enabled&&(a[`X-Proxy-Backend`]=`openai`),this.authToken&&(a.Authorization=`Bearer ${this.authToken}`),r=await fetch(t,{method:`POST`,headers:a,body:JSON.stringify(e),signal:n})}if(!r.ok){if(r.status===401)throw this.clearAuthToken(),Error(`UNAUTHORIZED`);if(r.status>=400&&r.status<500){let e=`Client error ${r.status}`;try{let t=await r.json();t.error&&t.error.message?e=t.error.message:t.detail&&(e=t.detail)}catch{}throw Error(`CLIENT_ERROR: ${e}`)}let e=`Request failed with status ${r.status}`;try{let t=await r.json();t.error&&t.error.message?e=t.error.message:t.detail&&(e=t.detail)}catch{}throw Error(e)}let o=r.body?.getReader();if(!o)throw Error(`Response body is not readable`);let p=new TextDecoder,h=``;try{for(;;){if(n?.aborted)throw new DOMException(`Request aborted`,`AbortError`);let{done:e,value:r}=await o.read();if(e){t&&(xh(t),f=null);return}let i=p.decode(r,{stream:!0});h+=i;let g=h.split(` -`);h=g.pop()||``;for(let e of g)if(e.startsWith(`data: `)){let n=e.slice(6);if(n===`[DONE]`){t&&(xh(t),f=null);return}try{let e=JSON.parse(n);if(`response`in e&&e.response&&typeof e.response==`object`&&`id`in e.response){let t=e.response.id;(!c||c!==t)&&(c=t)}else if(`id`in e&&typeof e.id==`string`&&e.id.startsWith(`resp_`)){let t=e.id;(!c||c!==t)&&(c=t)}`item_id`in e&&e.item_id&&(l=e.item_id);let r=`sequence_number`in e?e.sequence_number:void 0;if(r!==void 0)if(s&&r<=1&&a>1)t&&xh(t),d=null,f=null,u=0,yield{type:`error`,message:`Connection lost - previous response failed. Starting new response.`},a=r,s=!0,m(e),yield e;else if(r<=a)continue;else a=r,s=!0,m(e),yield e;else s=!0,m(e),yield e}catch(e){console.error(`Failed to parse OpenAI SSE event:`,e)}}}}finally{o.releaseLock()}}catch(e){let n=e instanceof Error?e.message:String(e);if(Th(e))throw t&&(xh(t),f=null),e;if(n===`UNAUTHORIZED`||n.startsWith(`CLIENT_ERROR:`))throw e;if(o++,o>Nh)throw Error(`Connection failed after ${Nh} retry attempts: ${n}`);await Ih(Math.min(Mh*2**(o-1),3e4))}}async*streamAgentExecutionOpenAI(e,t,n,r){let i={metadata:{entity_id:e},input:t.input,stream:!0,conversation:t.conversation_id};return yield*this.streamAgentExecutionOpenAIDirect(e,i,t.conversation_id,n,r)}async*streamAgentExecutionOpenAIDirect(e,t,n,r,i){yield*this.streamOpenAIResponse(t,n,r,i)}async*streamWorkflowExecutionOpenAI(e,t,n){let r={metadata:{entity_id:e},input:JSON.stringify(t.input_data||{}),stream:!0,conversation:t.conversation_id,extra_body:t.checkpoint_id?{entity_id:e,checkpoint_id:t.checkpoint_id}:void 0};yield*this.streamOpenAIResponse(r,t.conversation_id,n)}async runAgentSync(e,t){let{oaiMode:n}=await Ah(()=>Promise.resolve().then(()=>(Dd(),Ed)).then(e=>({oaiMode:e.useDevUIStore.getState().oaiMode})),void 0,import.meta.url),r={metadata:{entity_id:e},input:t.input,stream:!1,conversation:t.conversation_id};n.enabled&&(r.model=n.model,n.temperature!==void 0&&(r.temperature=n.temperature),n.max_output_tokens!==void 0&&(r.max_output_tokens=n.max_output_tokens));let i={};return n.enabled&&(i[`X-Proxy-Backend`]=`openai`),this.request(`/v1/responses`,{method:`POST`,headers:i,body:JSON.stringify(r)})}async runWorkflowSync(e,t){let n={metadata:{entity_id:e},input:JSON.stringify(t.input_data||{}),stream:!1,conversation:t.conversation_id,extra_body:t.checkpoint_id?{entity_id:e,checkpoint_id:t.checkpoint_id}:void 0};return this.request(`/v1/responses`,{method:`POST`,body:JSON.stringify(n)})}clearStreamingState(e){xh(e)}async*streamDeployment(e){let t=await fetch(`${this.baseUrl}/v1/deployments`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...e,stream:!0})});if(!t.ok)throw Error(`Deployment failed: ${t.statusText}`);let n=t.body?.getReader();if(!n)throw Error(`No response body`);let r=new TextDecoder,i=``;try{for(;;){let{done:e,value:t}=await n.read();if(e)break;i+=r.decode(t,{stream:!0});let a=i.split(` -`);i=a.pop()||``;for(let e of a)if(e.startsWith(`data: `)){let t=e.slice(6);if(t===`[DONE]`)return;try{yield JSON.parse(t)}catch(e){yield{type:`deploy.error`,message:`Failed to parse deployment event: ${e instanceof Error?e.message:`Unknown error`}`}}}}}catch(e){throw yield{type:`deploy.failed`,message:`Stream interrupted: ${e instanceof Error?e.message:`Unknown error`}`},e}finally{n.releaseLock()}}async listWorkflowSessions(e){let t=`/v1/conversations?entity_id=${encodeURIComponent(e)}&type=workflow_session`;return{data:(await this.request(t)).data.map(t=>({conversation_id:t.id,entity_id:t.metadata?.entity_id||e,created_at:t.created_at,metadata:{name:t.metadata?.name||`Session ${new Date(t.created_at*1e3).toLocaleString()}`,description:t.metadata?.description,type:`workflow_session`,checkpoint_summary:t.metadata?.checkpoint_summary}}))}}async createWorkflowSession(e,t){let n={entity_id:e,type:`workflow_session`,name:t?.name||`Session ${new Date().toLocaleString()}`,...t?.description&&{description:t.description}},r=await this.createConversation(n);return{conversation_id:r.id,entity_id:e,created_at:r.created_at,metadata:{name:n.name,description:n.description,type:`workflow_session`}}}async deleteWorkflowSession(e,t){if(!await this.deleteConversation(t))throw Error(`Failed to delete workflow session`)}};Dd();function Rh({open:e,onClose:t,agentName:n=`Agent`,entity:r}){let i=Q(e=>e.azureDeploymentEnabled)&&(r?.deployment_supported??!1),[a,o]=(0,y.useState)(i?`azure`:`docker`),[s,c]=(0,y.useState)(null),l=(0,y.useRef)(null),u=(0,y.useRef)(null),d=Q(e=>e.isDeploying),f=Q(e=>e.deploymentLogs),p=Q(e=>e.lastDeployment),m=Q(e=>e.startDeployment),h=Q(e=>e.addDeploymentLog),g=Q(e=>e.setDeploymentResult),_=Q(e=>e.stopDeployment),v=Q(e=>e.clearDeploymentState),b=e=>{let t=e.toLowerCase().replace(/[_\s]+/g,`-`).replace(/[^a-z0-9-]/g,``).replace(/--+/g,`-`).replace(/^[^a-z]+/,``).replace(/-$/,``);return(t.match(/^[a-z]/)?t:`app-${t}`).substring(0,31)},x=r?b(r.id):``,[S,C]=(0,y.useState)(`my-test-rg`),[w,E]=(0,y.useState)(x),[D,O]=(0,y.useState)(`eastus`),[k,A]=(0,y.useState)(null);(0,y.useEffect)(()=>{if(r){let e=b(r.id);E(e),A(j(e))}},[r?.id]),(0,y.useEffect)(()=>{u.current&&f.length>0&&(u.current.scrollTop=u.current.scrollHeight)},[f]);let j=e=>e?e.length>=32?`App name must be less than 32 characters`:/^[a-z0-9-]+$/.test(e)?/^[a-z]/.test(e)?/[a-z0-9]$/.test(e)?e.includes(`--`)?`App name cannot contain consecutive hyphens (--)`:null:`App name must end with a letter or number`:`App name must start with a lowercase letter`:`App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)`:null;(0,y.useEffect)(()=>()=>{l.current&&clearTimeout(l.current)},[]);let M=async()=>{if(!r?.id||!S||!w)return;let e=S.trim(),t=w.trim(),n=j(t);if(n){A(n);return}try{m();for await(let n of Lh.streamDeployment({entity_id:r.id,resource_group:e,app_name:t,region:D,ui_mode:`user`}))h(n.message),n.type===`deploy.completed`&&n.url&&n.auth_token?g({url:n.url,authToken:n.auth_token}):n.type===`deploy.failed`&&_()}catch(e){h(`Error: ${e instanceof Error?e.message:`Deployment failed`}`),_()}},N=async(e,t)=>{try{await navigator.clipboard.writeText(e),c(t),l.current&&clearTimeout(l.current),l.current=setTimeout(()=>{c(null),l.current=null},2e3)}catch{c(null)}},P=`# Dockerfile for ${n} +`)||i.trim().match(/[.!?]\s*$/))&&(t.push({type:`response.output_text.delta`,delta:i.trim()}),i=``);continue}a.type!==`response.usage.complete`&&t.push(a)}for(let[,e]of n)if(e.arguments.trim()&&e.arguments.trim().length>2){let n=r.get(e.callId)||e.name||`unknown`;t.push({type:`response.function_call.complete`,data:{name:n,arguments:e.arguments,call_id:e.callId}})}return i.trim()&&t.push({type:`response.output_text.delta`,delta:i.trim()}),t}function bm(e){switch(e.type){case`response.output_text.delta`:if(`delta`in e){let t=e.delta||``;return t.length>60?`${t.slice(0,60)}...`:t}return`Text output`;case`response.function_call.complete`:if(`data`in e&&e.data){let t=e.data,n=t.name||`unknown`;(!n||n===`unknown`)&&(n=`function_call`);let r=t.arguments?typeof t.arguments==`string`?t.arguments.slice(0,30):JSON.stringify(t.arguments).slice(0,30):``;return`Calling ${n}(${r}${r.length>=30?`...`:``})`}return`Function call`;case`response.function_call_arguments.delta`:return`delta`in e&&e.delta?`Function arg delta: ${e.delta.slice(0,30)}${e.delta.length>30?`...`:``}`:`Function arguments...`;case`response.function_result.complete`:{let t=e.output.slice(0,40);return`Function result: ${t}${t.length>=40?`...`:``}`}case`response.output_item.added`:{let t=e;return t.item.type===`function_call`?`Tool call: ${t.item.name}`:`Output item added`}case`response.workflow_event.completed`:return`data`in e&&e.data?`Executor: ${e.data.executor_id||`unknown`}`:`Workflow event`;case`response.trace.completed`:return`data`in e&&e.data?`Trace: ${e.data.operation_name||`unknown`}`:`Trace event`;case`response.completed`:if(`response`in e&&e.response&&`usage`in e.response){let t=e.response.usage;if(t)return`Response complete (${t.total_tokens} tokens)`}return`Response complete`;case`response.done`:return`Response complete`;case`error`:return`message`in e&&typeof e.message==`string`?e.message:`Error occurred`;default:return`${e.type}`}}function xm(e){switch(e){case`response.output_text.delta`:return nu;case`response.function_call.complete`:case`response.function_call.delta`:case`response.function_call_arguments.delta`:return Eu;case`response.function_result.complete`:return Al;case`response.output_item.added`:return Al;case`response.workflow_event.completed`:return _l;case`response.trace.completed`:return pu;case`response.completed`:return Al;case`response.done`:return Al;case`error`:return Ml;default:return Ol}}function Sm(e){switch(e){case`response.output_text.delta`:return`text-gray-600 dark:text-gray-400`;case`response.function_call.complete`:case`response.function_call.delta`:case`response.function_call_arguments.delta`:return`text-blue-600 dark:text-blue-400`;case`response.function_result.complete`:return`text-green-600 dark:text-green-400`;case`response.output_item.added`:return`text-green-600 dark:text-green-400`;case`response.workflow_event.completed`:return`text-purple-600 dark:text-purple-400`;case`response.trace.completed`:return`text-orange-600 dark:text-orange-400`;case`response.completed`:return`text-green-600 dark:text-green-400`;case`response.done`:return`text-green-600 dark:text-green-400`;case`error`:return`text-red-600 dark:text-red-400`;default:return`text-gray-600 dark:text-gray-400`}}function Cm({event:e}){let[t,n]=(0,y.useState)(!1),r=e.type||`unknown`,i=xm(r),a=Sm(r),o=`_uiTimestamp`in e&&typeof e._uiTimestamp==`number`?new Date(e._uiTimestamp*1e3).toLocaleTimeString():new Date().toLocaleTimeString(),s=bm(e),c=e.type===`response.function_call.complete`&&`data`in e&&e.data||e.type===`response.function_result.complete`||e.type===`response.output_item.added`&&vm(e)!==null||e.type===`response.workflow_event.completed`&&`data`in e&&e.data||e.type===`response.trace.completed`&&`data`in e&&e.data||e.type===`response.trace.completed`&&`data`in e&&e.data||e.type===`response.output_text.delta`&&`delta`in e&&e.delta&&e.delta.length>100||e.type===`response.completed`&&`response`in e&&e.response||e.type===`error`;return(0,T.jsxs)(`div`,{className:`border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors`,"data-devui-debug-event":r,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground mb-1`,children:[(0,T.jsx)(i,{className:`h-3 w-3 ${a}`}),(0,T.jsx)(`span`,{className:`font-mono`,children:o}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs py-0`,children:e.type?e.type.replace(`response.`,``):`unknown`})]}),(0,T.jsxs)(`div`,{className:`text-sm`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 ${c?`cursor-pointer`:``}`,onClick:()=>c&&n(!t),children:[c&&(0,T.jsx)(`div`,{className:`text-muted-foreground`,children:t?(0,T.jsx)(X,{className:`h-3 w-3`}):(0,T.jsx)(Z,{className:`h-3 w-3`})}),(0,T.jsx)(`div`,{className:`text-muted-foreground flex-1`,children:c&&s.length>80?`${s.slice(0,80)}...`:s})]}),t&&c&&(0,T.jsx)(`div`,{className:`mt-2 ml-5 p-3 bg-muted/30 rounded border`,children:(0,T.jsx)(wm,{event:e})})]})]})}function wm({event:e}){if(e.type===`error`){let t=e;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Ml,{className:`h-4 w-4 text-red-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Error Details`})]}),(0,T.jsxs)(`div`,{className:`text-xs`,children:[t.message&&(0,T.jsxs)(`div`,{className:`mb-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Message:`}),(0,T.jsx)(`div`,{className:`mt-1`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-destructive/10 border border-destructive/30 rounded p-2 text-destructive whitespace-pre-wrap break-all`,children:t.message})})]}),t.code&&(0,T.jsxs)(`div`,{className:`mb-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Code:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.code})]}),t.param&&(0,T.jsxs)(`div`,{className:`mb-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Parameter:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.param})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Raw Event:`}),(0,T.jsx)(`div`,{className:`mt-1`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all max-h-32 overflow-auto`,children:JSON.stringify(e,null,2)})})]})]})]})}switch(e.type){case`response.function_call.complete`:if(`data`in e&&e.data){let t=e.data;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Eu,{className:`h-4 w-4 text-blue-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Function Call`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Function:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded`,children:t.name||`unknown`})]}),t.call_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Call ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.call_id})]}),t.arguments&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Arguments:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:typeof t.arguments==`string`?t.arguments:JSON.stringify(t.arguments,null,1)})})]})]})]})}break;case`response.function_result.complete`:{let t=e;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Al,{className:`h-4 w-4 text-green-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Function Result`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Call ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.call_id})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Status:`}),(0,T.jsx)(`span`,{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${t.status===`completed`?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`}`,children:t.status})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Output:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:t.output})})]})]})]})}case`response.output_item.added`:{let t=vm(e);if(t)return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Al,{className:`h-4 w-4 text-green-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Function Result`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Call ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.call_id})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Status:`}),(0,T.jsx)(`span`,{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${t.status===`completed`?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`}`,children:t.status})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Output:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:t.output})})]})]})]});break}case`response.workflow_event.completed`:if(`data`in e&&e.data){let t=e.data;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(_l,{className:`h-4 w-4 text-purple-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Workflow Event`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Event Type:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono bg-purple-100 dark:bg-purple-900 px-2 py-1 rounded`,children:t.event_type||`unknown`})]}),t.executor_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Executor:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono`,children:t.executor_id})]}),t.timestamp&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Timestamp:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.timestamp})]}),t.data&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Data:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:typeof t.data==`string`?t.data:JSON.stringify(t.data,null,1)})})]})]})]})}break;case`response.trace.completed`:if(`data`in e&&e.data){let t=e.data;return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(pu,{className:`h-4 w-4 text-orange-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Trace Event`})]}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Operation:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono bg-orange-100 dark:bg-orange-900 px-2 py-1 rounded`,children:t.operation_name||`unknown`})]}),t.span_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Span ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.span_id})]}),t.trace_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Trace ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.trace_id})]}),t.duration_ms&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Duration:`}),(0,T.jsxs)(`span`,{className:`ml-2 font-mono text-xs`,children:[Number(t.duration_ms).toFixed(2),`ms`]})]}),t.status&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Status:`}),(0,T.jsx)(`span`,{className:`ml-2 px-2 py-1 rounded text-xs font-medium ${t.status===`StatusCode.UNSET`||t.status===`OK`?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`}`,children:t.status||`unknown`})]}),t.entity_id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Entity:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs`,children:t.entity_id})]}),t.attributes&&Object.keys(t.attributes).length>0&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Attributes:`}),(0,T.jsx)(`div`,{className:`mt-1 max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap break-all`,children:Om(t.attributes)})})]})]})]})}break;case`response.output_text.delta`:if(`delta`in e&&e.delta)return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(nu,{className:`h-4 w-4 text-gray-500`}),(0,T.jsx)(`span`,{className:`font-semibold text-sm`,children:`Text Output`})]}),(0,T.jsx)(`div`,{className:`max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all`,children:e.delta})})]});break;case`response.completed`:if(`response`in e&&e.response){let t=e.response;return(0,T.jsx)(`div`,{className:`space-y-2`,children:(0,T.jsxs)(`div`,{className:`grid grid-cols-1 gap-2 text-xs`,children:[t.usage&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`div`,{children:(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Usage:`})}),(0,T.jsxs)(`div`,{className:`ml-4 space-y-1`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Input tokens:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono`,children:t.usage.input_tokens})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Output tokens:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono`,children:t.usage.output_tokens})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Total tokens:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono bg-green-100 dark:bg-green-900 px-2 py-1 rounded`,children:t.usage.total_tokens})]})]})]}),t.id&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Response ID:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs break-all`,children:t.id})]}),t.model&&(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`font-medium text-muted-foreground`,children:`Model:`}),(0,T.jsx)(`span`,{className:`ml-2 font-mono text-xs break-all`,children:t.model})]})]})})}break;default:return(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground`,children:(0,T.jsx)(`pre`,{className:`bg-background border rounded p-2 overflow-auto max-h-32`,children:JSON.stringify(e,null,2)})})}return null}function Tm({events:e,processedEvents:t,isStreaming:n}){let r=(0,y.useRef)(null),i=[...(0,y.useMemo)(()=>_m(t),[t])].reverse();return(0,T.jsxs)(`div`,{className:`h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between p-3 border-b`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(_l,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium`,children:`Events`}),(0,T.jsxs)(ct,{variant:`outline`,children:[t.length,e.length>t.length?` (${e.length} raw)`:``]})]}),n&&(0,T.jsxs)(`div`,{className:`flex items-center gap-1 text-xs text-muted-foreground`,children:[(0,T.jsx)(`div`,{className:`h-2 w-2 animate-pulse rounded-full bg-green-500 dark:bg-green-400`}),`Streaming`]})]}),(0,T.jsx)(gf,{ref:r,className:`flex-1`,children:(0,T.jsx)(`div`,{className:`p-3`,children:t.length===0?(0,T.jsx)(`div`,{className:`text-center text-muted-foreground text-sm py-8`,children:e.length===0?`No events yet. Start a conversation to see real-time events.`:`Processing events... Accumulated events will appear here.`}):(0,T.jsx)(`div`,{className:`space-y-2`,children:i.map((e,t)=>`type`in e&&e.type===`separator`?(0,T.jsx)(gm,{},e.id):(0,T.jsx)(Cm,{event:e},`${e.type}-${t}`))})})})]})}function Em(e){let t=new Map;for(let n of e){if(!(`data`in n))continue;let e=n.data.response_id||`unknown`;t.has(e)||t.set(e,[]),t.get(e).push(n)}let n=[];for(let[e,r]of t){let t=new Map,i=[];for(let e of r){if(!(`data`in e))continue;let n=e.data,r=n.span_id||`span_${Math.random()}`;t.set(r,{event:e,data:n,children:[]})}for(let e of r){if(!(`data`in e))continue;let n=e.data,r=n.span_id||``,a=n.parent_span_id,o=t.get(r);o&&(a&&t.has(a)?t.get(a).children.push(o):i.push(o))}i.sort((e,t)=>(e.data.start_time||0)-(t.data.start_time||0));let a=e=>{e.children.sort((e,t)=>(e.data.start_time||0)-(t.data.start_time||0)),e.children.forEach(a)};i.forEach(a);let o=r[0],s=o&&`data`in o?o.data:null,c=Math.min(...r.map(e=>(`data`in e?e.data:null)?.start_time||Date.now()/1e3)),l=r.reduce((e,t)=>e+((`data`in t?t.data:null)?.duration_ms||0),0);n.push({response_id:e,timestamp:c,traces:i,totalDuration:l,entity_id:s?.entity_id})}return n.sort((e,t)=>t.timestamp-e.timestamp),n}function Dm(e){if(typeof e==`string`){let t=e.trim();if(t.startsWith(`[`)||t.startsWith(`{`))try{return Dm(JSON.parse(e))}catch{return e}return e}if(Array.isArray(e))return e.map(Dm);if(typeof e==`object`&&e){let t={};for(let[n,r]of Object.entries(e))t[n]=Dm(r);return t}return e}function Om(e){try{let t=Dm(e);return JSON.stringify(t,null,2)}catch{return JSON.stringify(e,null,2)}}function km(e){return e.includes(`invoke_agent`)||e.includes(`Agent`)?`bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200`:e.includes(`chat`)||e.includes(`Chat`)?`bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200`:e.includes(`tool`)||e.includes(`execute`)?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-orange-100 dark:bg-orange-900 text-orange-800 dark:text-orange-200`}function Am({node:e,depth:t=0}){let[n,r]=(0,y.useState)(t<2),[i,a]=(0,y.useState)(!1),{data:o}=e,s=o.operation_name||`Unknown`,c=o.duration_ms?`${Number(o.duration_ms).toFixed(1)}ms`:``,l=e.children.length>0,u=o.attributes?.[`gen_ai.usage.input_tokens`],d=o.attributes?.[`gen_ai.usage.output_tokens`],f=u!==void 0||d!==void 0;return(0,T.jsxs)(`div`,{className:`relative`,children:[t>0&&(0,T.jsx)(`div`,{className:`absolute left-0 top-0 bottom-0 border-l-2 border-muted`,style:{marginLeft:`${(t-1)*16+8}px`}}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 py-1.5 hover:bg-muted/50 rounded transition-colors`,style:{paddingLeft:`${t*16}px`},children:[(0,T.jsx)(`button`,{onClick:()=>l?r(!n):a(!i),className:`w-4 h-4 flex items-center justify-center text-muted-foreground hover:text-foreground`,children:l?n?(0,T.jsx)(X,{className:`h-3 w-3`}):(0,T.jsx)(Z,{className:`h-3 w-3`}):i?(0,T.jsx)(X,{className:`h-3 w-3`}):(0,T.jsx)(Z,{className:`h-3 w-3`})}),(0,T.jsx)(`span`,{className:`text-xs px-1.5 py-0.5 rounded font-medium ${km(s)}`,children:s.replace(`Agent.`,``).replace(`invoke_agent `,``)}),c&&(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground font-mono`,children:c}),f&&(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground font-mono`,children:[u!==void 0&&(0,T.jsxs)(`span`,{children:[`↑`,String(u)]}),u!==void 0&&d!==void 0&&(0,T.jsx)(`span`,{className:`mx-0.5`,children:`/`}),d!==void 0&&(0,T.jsxs)(`span`,{children:[`↓`,String(d)]})]})]}),i&&!l&&(0,T.jsx)(`div`,{className:`ml-4 mt-1 mb-2 p-2 bg-muted/30 rounded border text-xs`,style:{marginLeft:`${t*16+20}px`},children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[o.span_id&&(0,T.jsxs)(`div`,{className:`flex gap-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground w-20`,children:`Span ID:`}),(0,T.jsx)(`span`,{className:`font-mono text-xs break-all`,children:o.span_id})]}),o.trace_id&&(0,T.jsxs)(`div`,{className:`flex gap-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground w-20`,children:`Trace ID:`}),(0,T.jsx)(`span`,{className:`font-mono text-xs break-all`,children:o.trace_id})]}),o.status&&(0,T.jsxs)(`div`,{className:`flex gap-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground w-20`,children:`Status:`}),(0,T.jsx)(`span`,{className:`px-1.5 py-0.5 rounded text-xs ${o.status===`StatusCode.UNSET`||o.status===`OK`?`bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200`:`bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`}`,children:o.status})]}),o.attributes&&Object.keys(o.attributes).length>0&&(0,T.jsxs)(`div`,{className:`mt-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground block mb-1`,children:`Attributes:`}),(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 overflow-auto max-h-32 whitespace-pre-wrap break-all`,children:Om(o.attributes)})]})]})}),l&&n&&(0,T.jsx)(`div`,{children:e.children.map((e,n)=>(0,T.jsx)(Am,{node:e,depth:t+1},e.data.span_id||n))})]})}function jm({group:e}){let[t,n]=(0,y.useState)(!0),r=new Date(e.timestamp*1e3).toLocaleTimeString(),i=e.totalDuration>0?`${e.totalDuration.toFixed(0)}ms`:``,a=e.traces.reduce((e,t)=>{let n=e=>1+e.children.reduce((e,t)=>e+n(t),0);return e+n(t)},0);return(0,T.jsxs)(`div`,{className:`border rounded-lg overflow-hidden`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 p-2 bg-muted/50 cursor-pointer hover:bg-muted/70 transition-colors`,onClick:()=>n(!t),children:[(0,T.jsx)(`div`,{className:`text-muted-foreground`,children:t?(0,T.jsx)(X,{className:`h-4 w-4`}):(0,T.jsx)(Z,{className:`h-4 w-4`})}),(0,T.jsx)(`span`,{className:`font-mono text-xs text-muted-foreground`,children:r}),e.entity_id&&(0,T.jsx)(ct,{variant:`outline`,className:`text-xs py-0`,children:e.entity_id.replace(`agent_`,``).replace(`workflow_`,``)}),(0,T.jsx)(`div`,{className:`flex-1`}),i&&(0,T.jsx)(ct,{variant:`secondary`,className:`text-xs py-0`,children:i}),(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[a,` span`,a===1?``:`s`]})]}),t&&(0,T.jsx)(`div`,{className:`p-2 border-t`,children:e.traces.map((e,t)=>(0,T.jsx)(Am,{node:e,depth:0},e.data.span_id||t))})]})}function Mm({events:e}){let t=Q(e=>e.debugTraceSubTab),n=Q(e=>e.setDebugTraceSubTab),r=Q(e=>e.runtime),i=Q(e=>e.serverCapabilities.trace_retrieval),a=e.filter(e=>e.type===`response.trace.completed`),o=Em(a);return(0,T.jsxs)(`div`,{className:`h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 p-3 border-b`,children:[(0,T.jsx)(pu,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium`,children:`Traces`}),(0,T.jsx)(ct,{variant:`outline`,children:a.length}),(0,T.jsx)(`div`,{className:`flex-1`}),(0,T.jsxs)(`div`,{className:`flex items-center bg-muted rounded-md p-1 min-w-0`,children:[(0,T.jsx)(`button`,{onClick:()=>n(`spans`),className:`px-3 py-1.5 text-xs rounded transition-colors truncate ${t===`spans`?`bg-background shadow-sm font-medium`:`text-muted-foreground hover:text-foreground`}`,children:`OTel Spans`}),(0,T.jsxs)(`button`,{onClick:()=>n(`context`),className:`px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-1.5 min-w-0 ${t===`context`?`bg-background shadow-sm font-medium`:`text-muted-foreground hover:text-foreground`}`,children:[(0,T.jsx)(Cl,{className:`h-3.5 w-3.5 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`truncate`,children:`Context Inspector`})]})]})]}),t===`spans`?(0,T.jsxs)(`div`,{className:`flex-1 flex flex-col min-h-0`,children:[a.length>0&&(0,T.jsx)(`div`,{className:`p-3 border-b flex-shrink-0`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(pu,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`OTel Spans`}),(0,T.jsxs)(ct,{variant:`outline`,className:`text-xs`,children:[o.length,` turn`,o.length===1?``:`s`]})]})}),a.length===0?(0,T.jsxs)(`div`,{className:`flex flex-col items-center text-center p-6 pt-9`,children:[(0,T.jsx)(Cl,{className:`h-8 w-8 text-muted-foreground mb-3`}),(0,T.jsx)(`div`,{className:`text-sm font-medium mb-1`,children:`No Data`}),(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground max-w-[200px]`,children:i?`Start a conversation to view OpenTelemetry spans from the Aspire Dashboard.`:r===`dotnet`?`Trace retrieval is not available for this server.`:(0,T.jsxs)(T.Fragment,{children:[`Run`,` `,(0,T.jsx)(`span`,{className:`font-mono bg-accent/10 px-1 rounded`,children:`devui --instrumentation`}),` `,`and start a conversation.`]})})]}):(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsx)(`div`,{className:`p-3`,children:(0,T.jsx)(`div`,{className:`space-y-3`,children:o.map(e=>(0,T.jsx)(jm,{group:e},e.response_id))})})})]}):(0,T.jsx)(hm,{events:e})]})}function Nm({events:e,processedEvents:t}){let n=[],r=t.filter(e=>e.type===`response.function_call.complete`),i=e.filter(e=>vm(e)!==null),a=new Map;i.forEach(e=>{let t=vm(e);t&&a.set(t.call_id,e)}),r.forEach(e=>{if(n.push(e),`data`in e&&e.data&&e.data.call_id){let t=String(e.data.call_id),r=a.get(t);r&&(n.push(r),a.delete(t))}}),a.forEach(e=>{n.push(e)});let o=[..._m(n)].reverse();return(0,T.jsxs)(`div`,{className:`h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 p-3 border-b`,children:[(0,T.jsx)(Eu,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`font-medium`,children:`Tools`}),(0,T.jsx)(ct,{variant:`outline`,children:n.length})]}),(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsx)(`div`,{className:`p-3`,children:n.length===0?(0,T.jsx)(`div`,{className:`text-center text-muted-foreground text-sm py-8`,children:`No tool executions yet. Tool calls will appear here during conversations.`}):(0,T.jsx)(`div`,{className:`space-y-3`,children:o.map((e,t)=>`type`in e&&e.type===`separator`?(0,T.jsx)(gm,{},e.id):(0,T.jsx)(Pm,{event:e},t))})})})]})}function Pm({event:e}){let t=`_uiTimestamp`in e&&typeof e._uiTimestamp==`number`?new Date(e._uiTimestamp*1e3).toLocaleTimeString():new Date().toLocaleTimeString(),n=e.type===`response.function_call.complete`,r=vm(e),i=r!==null;if(!n&&!i)return null;let a=n&&`data`in e?e.data:null;return(0,T.jsxs)(`div`,{className:`border rounded p-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between mb-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Ou,{className:`h-4 w-4 text-yellow-600 dark:text-yellow-400`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:n?`Tool Call`:`Tool Result`}),n&&a&&a.name!==void 0&&(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[`(`,String(a.name),`)`]})]}),(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground font-mono`,children:t})]}),n&&a&&(0,T.jsxs)(`div`,{className:`p-2 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-2`,children:[(0,T.jsx)(Eu,{className:`h-3 w-3 text-blue-600 dark:text-blue-400`}),(0,T.jsx)(`span`,{className:`text-xs font-mono bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded`,children:`CALL`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:String(a.name||`unknown`)})]}),a.arguments!==void 0&&(0,T.jsxs)(`div`,{className:`text-xs`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground mb-1 block`,children:`Arguments:`}),(0,T.jsx)(`pre`,{className:`p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap`,children:typeof a.arguments==`string`?a.arguments:JSON.stringify(a.arguments,null,1)})]})]}),i&&r&&(0,T.jsxs)(`div`,{className:`p-2 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-2`,children:[(0,T.jsx)(Al,{className:`h-3 w-3 text-green-600 dark:text-green-400`}),(0,T.jsx)(`span`,{className:`text-xs font-mono bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded`,children:`RESULT`}),r.status!==`completed`&&(0,T.jsx)(`span`,{className:`ml-auto px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200`,children:r.status})]}),(0,T.jsxs)(`div`,{className:`text-xs space-y-1`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Call ID:`}),(0,T.jsx)(`span`,{className:`font-mono text-xs break-all`,children:r.call_id})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground block mb-1`,children:`Output:`}),(0,T.jsx)(`pre`,{className:`p-2 bg-background border rounded text-xs overflow-auto max-h-32 break-all whitespace-pre-wrap`,children:r.output})]})]})]})]})}function Fm({events:e,isStreaming:t=!1,onMinimize:n}){let r=Q(e=>e.debugPanelTab),i=Q(e=>e.setDebugPanelTab),a=(0,y.useMemo)(()=>ym(e),[e]),o=(0,y.useMemo)(()=>({eventsCount:a.length,tracesCount:e.filter(e=>e.type===`response.trace.completed`).length,toolsCount:a.filter(e=>e.type===`response.function_call.complete`).length+e.filter(e=>vm(e)!==null).length}),[e,a]);return(0,T.jsx)(`div`,{className:`flex-1 border-l flex flex-col min-h-0`,children:(0,T.jsxs)(Lf,{value:r,onValueChange:e=>i(e),className:`flex-1 flex flex-col min-h-0`,children:[(0,T.jsxs)(`div`,{className:`px-3 pt-3 flex items-center gap-2 flex-shrink-0`,children:[(0,T.jsxs)(Rf,{className:`flex-1`,children:[(0,T.jsxs)(zf,{value:`events`,className:`flex-1 gap-1.5`,children:[`Events`,o.eventsCount>0&&(0,T.jsx)(`span`,{className:`text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`,children:o.eventsCount})]}),(0,T.jsxs)(zf,{value:`traces`,className:`flex-1 gap-1.5`,children:[`Traces`,o.tracesCount>0&&(0,T.jsx)(`span`,{className:`text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`,children:o.tracesCount})]}),(0,T.jsxs)(zf,{value:`tools`,className:`flex-1 gap-1.5`,children:[`Tools`,o.toolsCount>0&&(0,T.jsx)(`span`,{className:`text-[10px] bg-muted-foreground/20 text-muted-foreground px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`,children:o.toolsCount})]})]}),n&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:n,className:`h-8 w-8 p-0 flex-shrink-0`,title:`Minimize debug panel`,children:(0,T.jsx)(Z,{className:`h-4 w-4`})})]}),(0,T.jsx)(Bf,{value:`events`,className:`flex-1 mt-0 overflow-hidden`,children:(0,T.jsx)(Tm,{events:e,processedEvents:a,isStreaming:t})}),(0,T.jsx)(Bf,{value:`traces`,className:`flex-1 mt-0 overflow-hidden`,children:(0,T.jsx)(Mm,{events:e})}),(0,T.jsx)(Bf,{value:`tools`,className:`flex-1 mt-0 overflow-hidden`,children:(0,T.jsx)(Nm,{events:e,processedEvents:a})})]})})}function Im({open:e,onOpenChange:t,children:n}){return e?(0,T.jsxs)(`div`,{className:`fixed inset-0 z-50 flex items-center justify-center`,children:[(0,T.jsx)(`div`,{className:`absolute inset-0 bg-black/50`,onClick:()=>{t(!1)}}),(0,T.jsx)(`div`,{className:`relative z-10`,onClick:e=>{e.stopPropagation()},onMouseDown:e=>{e.stopPropagation()},onMouseUp:e=>e.stopPropagation(),children:n})]}):null}function Lm({children:e,className:t=``}){return(0,T.jsx)(`div`,{className:`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${t.includes(`w-[`)||t.includes(`w-full`)||t.includes(`max-w-`)?``:`max-w-lg w-full`} ${t}`,children:e})}function Rm({children:e,className:t=``}){return(0,T.jsx)(`div`,{className:`space-y-2 ${t}`,children:e})}function zm({children:e,className:t=``}){return(0,T.jsx)(`h2`,{className:`text-lg font-semibold ${t}`,children:e})}function Bm({children:e,className:t=``}){return(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground ${t}`,children:e})}function Vm({onClose:e}){return(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:e,className:`absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100`,children:(0,T.jsx)(Du,{className:`h-4 w-4`})})}function Hm({children:e}){return(0,T.jsx)(`div`,{className:`flex justify-end gap-2 p-4 border-t bg-muted/50`,children:e})}function Um({className:e,type:t,...n}){return(0,T.jsx)(`input`,{type:t,"data-slot":`input`,className:at(`file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm`,`focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]`,`aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive`,e),...n})}var Wm=`Label`,Gm=y.forwardRef((e,t)=>(0,T.jsx)(J.label,{...e,ref:t,onMouseDown:t=>{t.target.closest(`button, input, select, textarea`)||(e.onMouseDown?.(t),!t.defaultPrevented&&t.detail>1&&t.preventDefault())}}));Gm.displayName=Wm;var Km=Gm;function qm({className:e,...t}){return(0,T.jsx)(Km,{"data-slot":`label`,className:at(`flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50`,e),...t})}var Jm=`Switch`,[Ym,Xm]=lt(Jm),[Zm,Qm]=Ym(Jm),$m=y.forwardRef((e,t)=>{let{__scopeSwitch:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c=`on`,onCheckedChange:l,form:u,...d}=e,[f,p]=y.useState(null),m=C(t,e=>p(e)),h=y.useRef(!1),g=f?u||!!f.closest(`form`):!0,[_,v]=pt({prop:i,defaultProp:a??!1,onChange:l,caller:Jm});return(0,T.jsxs)(Zm,{scope:n,checked:_,disabled:s,children:[(0,T.jsx)(J.button,{type:`button`,role:`switch`,"aria-checked":_,"aria-required":o,"data-state":ih(_),"data-disabled":s?``:void 0,disabled:s,value:c,...d,ref:m,onClick:q(e.onClick,e=>{v(e=>!e),g&&(h.current=e.isPropagationStopped(),h.current||e.stopPropagation())})}),g&&(0,T.jsx)(rh,{control:f,bubbles:!h.current,name:r,value:c,checked:_,required:o,disabled:s,form:u,style:{transform:`translateX(-100%)`}})]})});$m.displayName=Jm;var eh=`SwitchThumb`,th=y.forwardRef((e,t)=>{let{__scopeSwitch:n,...r}=e,i=Qm(eh,n);return(0,T.jsx)(J.span,{"data-state":ih(i.checked),"data-disabled":i.disabled?``:void 0,...r,ref:t})});th.displayName=eh;var nh=`SwitchBubbleInput`,rh=y.forwardRef(({__scopeSwitch:e,control:t,checked:n,bubbles:r=!0,...i},a)=>{let o=y.useRef(null),s=C(o,a),c=Vf(n),l=Ti(t);return y.useEffect(()=>{let e=o.current;if(!e)return;let t=window.HTMLInputElement.prototype,i=Object.getOwnPropertyDescriptor(t,`checked`).set;if(c!==n&&i){let t=new Event(`click`,{bubbles:r});i.call(e,n),e.dispatchEvent(t)}},[c,n,r]),(0,T.jsx)(`input`,{type:`checkbox`,"aria-hidden":!0,defaultChecked:n,...i,tabIndex:-1,ref:s,style:{...i.style,...l,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0}})});rh.displayName=nh;function ih(e){return e?`checked`:`unchecked`}var ah=$m,oh=th,sh=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(ah,{className:at(`peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input`,e),...t,ref:n,children:(0,T.jsx)(oh,{className:at(`pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0`)})}));sh.displayName=ah.displayName,Dd();var ch=[`gpt-4.1`,`gpt-4.1-mini`,`o1`,`o1-mini`,`o3-mini`];function lh({open:e,onOpenChange:t,onBackendUrlChange:n}){let[r,i]=(0,y.useState)(`general`),{oaiMode:a,setOAIMode:o,azureDeploymentEnabled:s,setAzureDeploymentEnabled:c,authRequired:l,serverCapabilities:u,serverVersion:d,runtime:f,uiMode:p,streamingEnabled:m,setStreamingEnabled:h}=Q(),[g,_]=(0,y.useState)(()=>localStorage.getItem(`devui_backend_url`)||``),[v,b]=(0,y.useState)(g),[x,S]=(0,y.useState)(!!localStorage.getItem(`devui_auth_token`)),[C,w]=(0,y.useState)(``),E=()=>{try{new URL(v),localStorage.setItem(`devui_backend_url`,v),_(v),n?.(v),t(!1),window.location.reload()}catch{alert(`Please enter a valid URL (e.g., http://localhost:8080)`)}},D=()=>{localStorage.removeItem(`devui_backend_url`),b(``),_(``),n?.(``),window.location.reload()},O=()=>{C.trim()&&(localStorage.setItem(`devui_auth_token`,C.trim()),S(!0),w(``),window.location.reload())},k=()=>{localStorage.removeItem(`devui_auth_token`),S(!1),w(``),window.location.reload()},A=v!==g,j=!localStorage.getItem(`devui_backend_url`);return(0,T.jsx)(Im,{open:e,onOpenChange:t,children:(0,T.jsxs)(Lm,{className:`w-[600px] max-w-[90vw] flex flex-col max-h-[85vh]`,children:[(0,T.jsx)(Rm,{className:`p-6 pb-2 flex-shrink-0`,children:(0,T.jsx)(zm,{children:`Settings`})}),(0,T.jsx)(Vm,{onClose:()=>t(!1)}),(0,T.jsxs)(`div`,{className:`flex border-b px-6 flex-shrink-0`,children:[(0,T.jsxs)(`button`,{onClick:()=>i(`general`),className:`px-4 py-2 text-sm font-medium transition-colors relative ${r===`general`?`text-foreground`:`text-muted-foreground hover:text-foreground`}`,children:[`General`,r===`general`&&(0,T.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 h-0.5 bg-primary`})]}),u.openai_proxy&&(0,T.jsxs)(`button`,{onClick:()=>i(`proxy`),className:`px-4 py-2 text-sm font-medium transition-colors relative ${r===`proxy`?`text-foreground`:`text-muted-foreground hover:text-foreground`}`,children:[`OpenAI Proxy`,r===`proxy`&&(0,T.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 h-0.5 bg-primary`})]}),(0,T.jsxs)(`button`,{onClick:()=>i(`about`),className:`px-4 py-2 text-sm font-medium transition-colors relative ${r===`about`?`text-foreground`:`text-muted-foreground hover:text-foreground`}`,children:[`About`,r===`about`&&(0,T.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 h-0.5 bg-primary`})]})]}),(0,T.jsxs)(`div`,{className:`px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]`,children:[r===`general`&&(0,T.jsxs)(`div`,{className:`space-y-6 pt-4`,children:[(0,T.jsxs)(`div`,{className:`space-y-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsx)(qm,{htmlFor:`backend-url`,className:`text-sm font-medium`,children:`Backend URL`}),!j&&(0,T.jsxs)(K,{variant:`ghost`,size:`sm`,onClick:D,className:`h-7 text-xs`,title:`Reset to default`,children:[(0,T.jsx)(fu,{className:`h-3 w-3 mr-1`}),`Reset`]})]}),(0,T.jsx)(Um,{id:`backend-url`,type:`url`,value:v,onChange:e=>b(e.target.value),placeholder:`http://localhost:8080`,className:`font-mono text-sm`}),(0,T.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Default: `,(0,T.jsx)(`span`,{className:`font-mono`,children:``})]}),(0,T.jsx)(`div`,{className:`flex gap-2 pt-2 min-h-[36px]`,children:A&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(K,{onClick:E,size:`sm`,className:`flex-1`,children:`Apply & Reload`}),(0,T.jsx)(K,{onClick:()=>b(g),variant:`outline`,size:`sm`,className:`flex-1`,children:`Cancel`})]})})]}),(l||x)&&(0,T.jsxs)(`div`,{className:`space-y-3 border-t pt-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Authentication Token`}),!l&&x&&(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`(Not required by current backend)`})]}),x?(0,T.jsxs)(`div`,{className:`space-y-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Um,{type:`password`,value:`••••••••••••••••••••`,disabled:!0,className:`font-mono text-sm flex-1`}),(0,T.jsx)(K,{variant:`destructive`,size:`sm`,onClick:k,className:`flex-shrink-0`,children:`Clear`})]}),(0,T.jsx)(`p`,{className:`text-xs text-green-600 dark:text-green-400`,children:`✓ Token configured and stored locally`})]}):(0,T.jsxs)(`div`,{className:`space-y-3`,children:[(0,T.jsx)(Um,{type:`password`,value:C,onChange:e=>w(e.target.value),placeholder:`Enter bearer token`,className:`font-mono text-sm`,onKeyDown:e=>{e.key===`Enter`&&C.trim()&&O()}}),(0,T.jsx)(K,{onClick:O,size:`sm`,disabled:!C.trim(),className:`w-full`,children:`Save & Reload`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:l?`Required by backend (started with --auth flag)`:`Not required by current backend`})]})]}),u.deployment&&(0,T.jsxs)(`div`,{className:`space-y-3 border-t pt-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`space-y-0.5`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Azure Deployment`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Enable one-click deployment to Azure Container Apps`})]}),(0,T.jsx)(sh,{checked:s,onCheckedChange:c})]}),(0,T.jsxs)(`details`,{className:`group`,children:[(0,T.jsxs)(`summary`,{className:`cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1`,children:[(0,T.jsx)(Z,{className:`h-3 w-3 transition-transform group-open:rotate-90`}),`Learn more about Azure deployment`]}),(0,T.jsxs)(`div`,{className:`mt-3 space-y-3 pl-4`,children:[(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground leading-relaxed`,children:`When enabled, agents that support deployment will show a "Deploy to Azure" button. This allows you to deploy your agent to Azure Container Apps directly from DevUI.`}),(0,T.jsxs)(`div`,{className:`space-y-1.5`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium`,children:`When enabled:`}),(0,T.jsxs)(`ul`,{className:`text-xs text-muted-foreground space-y-0.5 list-disc list-inside`,children:[(0,T.jsx)(`li`,{children:`Shows "Deploy to Azure" for supported agents`}),(0,T.jsx)(`li`,{children:`Requires Azure CLI and proper authentication`}),(0,T.jsx)(`li`,{children:`Backend must have deployment capabilities enabled`})]})]}),(0,T.jsxs)(`div`,{className:`space-y-1.5`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium`,children:`When disabled:`}),(0,T.jsxs)(`ul`,{className:`text-xs text-muted-foreground space-y-0.5 list-disc list-inside`,children:[(0,T.jsx)(`li`,{children:`Shows "Deployment Guide" for all agents`}),(0,T.jsx)(`li`,{children:`Provides Docker templates and manual deployment instructions`}),(0,T.jsx)(`li`,{children:`No backend deployment capabilities required`})]})]})]})]})]}),(0,T.jsx)(`div`,{className:`space-y-3 border-t pt-6`,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`space-y-0.5`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Show Tool Calls`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Display function/tool calls and results in chat messages`})]}),(0,T.jsx)(sh,{checked:Q.getState().showToolCalls,onCheckedChange:e=>Q.getState().setShowToolCalls(e)})]})}),(0,T.jsxs)(`div`,{className:`space-y-3 border-t pt-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`space-y-0.5`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Streaming Mode`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Stream responses token-by-token as they're generated`})]}),(0,T.jsx)(sh,{checked:m,onCheckedChange:h})]}),!m&&(0,T.jsxs)(`div`,{className:`flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-500/10 p-3 rounded`,children:[(0,T.jsx)(Jl,{className:`h-3.5 w-3.5 flex-shrink-0 mt-0.5`}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`p`,{className:`font-medium`,children:`Non-streaming mode limitations:`}),(0,T.jsxs)(`ul`,{className:`mt-1 space-y-0.5 list-disc list-inside text-amber-600/80 dark:text-amber-400/80`,children:[(0,T.jsx)(`li`,{children:`Tool calls won't display in real-time`}),(0,T.jsx)(`li`,{children:`No typing indicator during generation`}),(0,T.jsx)(`li`,{children:`Response appears all at once when complete`})]})]})]})]})]}),r===`proxy`&&u.openai_proxy&&(0,T.jsxs)(`div`,{className:`space-y-6 pt-4`,children:[(0,T.jsxs)(`div`,{className:`space-y-4`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`space-y-0.5`,children:[(0,T.jsx)(qm,{className:`text-base font-medium`,children:`OpenAI Proxy Mode`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Route requests through DevUI backend to OpenAI API`})]}),(0,T.jsx)(sh,{checked:a.enabled,onCheckedChange:e=>o({...a,enabled:e})})]}),!a.enabled&&(0,T.jsx)(`div`,{className:`bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,T.jsx)(Jl,{className:`h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400`}),(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium`,children:`About OpenAI Proxy Mode`}),(0,T.jsxs)(`p`,{className:`text-xs text-muted-foreground leading-relaxed`,children:[`When enabled, your chat requests are sent to your DevUI backend`,` `,(0,T.jsxs)(`span`,{className:`font-mono font-semibold`,children:[`(`,g,`)`]}),`, which then forwards them to OpenAI's API. This keeps your`,` `,(0,T.jsx)(`span`,{className:`font-mono font-semibold`,children:`OPENAI_API_KEY`}),` `,`secure on the server instead of exposing it in the browser.`]}),(0,T.jsxs)(`div`,{className:`space-y-1.5 pt-1`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium`,children:`Requirements:`}),(0,T.jsxs)(`ul`,{className:`text-xs text-muted-foreground space-y-0.5 list-disc list-inside`,children:[(0,T.jsxs)(`li`,{children:[`Backend must have`,` `,(0,T.jsx)(`span`,{className:`font-mono`,children:`OPENAI_API_KEY`}),` `,`configured`]}),(0,T.jsx)(`li`,{children:`Backend must support OpenAI Responses API proxying (DevUI does)`})]})]}),(0,T.jsxs)(`div`,{className:`space-y-1.5 pt-1`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium`,children:`Why use this?`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Quickly test and compare OpenAI models directly through the DevUI interface without creating custom agents or exposing API keys in the browser.`})]})]})]})}),a.enabled&&(0,T.jsxs)(`div`,{className:`space-y-4 pl-4 border-l-2 border-muted`,children:[(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(qm,{className:`text-sm font-medium`,children:`Model`}),(0,T.jsx)(Um,{type:`text`,value:a.model,onChange:e=>o({...a,model:e.target.value}),placeholder:`gpt-4.1-mini`,className:`font-mono text-sm`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)`})]}),(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(qm,{className:`text-xs text-muted-foreground`,children:`Common presets`}),(0,T.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:ch.map(e=>(0,T.jsx)(K,{variant:a.model===e?`default`:`outline`,size:`sm`,onClick:()=>o({...a,model:e}),className:`text-xs h-7`,children:e},e))})]}),(0,T.jsxs)(`details`,{className:`group`,children:[(0,T.jsxs)(`summary`,{className:`cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1`,children:[(0,T.jsx)(Z,{className:`h-3 w-3 transition-transform group-open:rotate-90`}),`Advanced Parameters (optional)`]}),(0,T.jsxs)(`div`,{className:`space-y-3 mt-3 pl-4`,children:[(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(qm,{className:`text-xs`,children:`Temperature`}),(0,T.jsx)(Um,{type:`number`,step:`0.1`,min:`0`,max:`2`,value:a.temperature??``,onChange:e=>o({...a,temperature:e.target.value?parseFloat(e.target.value):void 0}),placeholder:`1.0 (default)`,className:`text-sm`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Controls randomness (0-2)`})]}),(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(qm,{className:`text-xs`,children:`Max Output Tokens`}),(0,T.jsx)(Um,{type:`number`,min:`1`,value:a.max_output_tokens??``,onChange:e=>o({...a,max_output_tokens:e.target.value?parseInt(e.target.value):void 0}),placeholder:`Auto`,className:`text-sm`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Maximum tokens in response`})]}),(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(qm,{className:`text-xs`,children:`Top P`}),(0,T.jsx)(Um,{type:`number`,step:`0.1`,min:`0`,max:`1`,value:a.top_p??``,onChange:e=>o({...a,top_p:e.target.value?parseFloat(e.target.value):void 0}),placeholder:`1.0 (default)`,className:`text-sm`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Nucleus sampling (0-1)`})]}),(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(qm,{className:`text-xs`,children:`Reasoning Effort (o-series models)`}),(0,T.jsxs)(`select`,{value:a.reasoning_effort??``,onChange:e=>o({...a,reasoning_effort:e.target.value?e.target.value:void 0}),className:`flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring`,children:[(0,T.jsx)(`option`,{value:``,children:`Auto (default)`}),(0,T.jsx)(`option`,{value:`minimal`,children:`Minimal`}),(0,T.jsx)(`option`,{value:`low`,children:`Low`}),(0,T.jsx)(`option`,{value:`medium`,children:`Medium`}),(0,T.jsx)(`option`,{value:`high`,children:`High`})]}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Constrains reasoning effort (faster/cheaper vs thorough)`})]})]})]})]})]}),a.enabled&&(0,T.jsxs)(`div`,{className:`flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded`,children:[(0,T.jsx)(Jl,{className:`h-3.5 w-3.5 flex-shrink-0 mt-0.5`}),(0,T.jsx)(`div`,{className:`space-y-1`,children:(0,T.jsxs)(`p`,{children:[`Requests route through`,` `,(0,T.jsx)(`span`,{className:`font-mono font-semibold`,children:g}),` `,`to OpenAI API. Server must have`,` `,(0,T.jsx)(`span`,{className:`font-mono font-semibold`,children:`OPENAI_API_KEY`}),` `,`configured.`]})})]})]}),r===`about`&&(0,T.jsxs)(`div`,{className:`space-y-4 pt-4`,children:[(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`DevUI is a sample app for getting started with Agent Framework.`}),(0,T.jsxs)(`div`,{className:`space-y-2 text-sm`,children:[(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Version:`}),(0,T.jsx)(`span`,{className:`font-mono`,children:d||`Unknown`})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Runtime:`}),(0,T.jsx)(`span`,{className:`font-mono capitalize`,children:f||`Unknown`})]}),(0,T.jsxs)(`div`,{className:`flex justify-between`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`UI Mode:`}),(0,T.jsx)(`span`,{className:`font-mono capitalize`,children:p||`Unknown`})]})]}),(u||l!==void 0)&&(0,T.jsxs)(`div`,{className:`space-y-2 pt-2`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wide`,children:`Capabilities`}),(0,T.jsxs)(`div`,{className:`space-y-1 text-sm`,children:[u?.instrumentation!==void 0&&(0,T.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Instrumentation:`}),(0,T.jsx)(`span`,{className:`text-xs px-2 py-0.5 rounded-full ${u.instrumentation?`bg-green-500/10 text-green-600 dark:text-green-400`:`bg-muted text-muted-foreground`}`,children:u.instrumentation?`Enabled`:`Disabled`})]}),u?.openai_proxy!==void 0&&(0,T.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`OpenAI Proxy:`}),(0,T.jsx)(`span`,{className:`text-xs px-2 py-0.5 rounded-full ${u.openai_proxy?`bg-green-500/10 text-green-600 dark:text-green-400`:`bg-muted text-muted-foreground`}`,children:u.openai_proxy?`Available`:`Not Configured`})]}),u?.deployment!==void 0&&(0,T.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Deployment:`}),(0,T.jsx)(`span`,{className:`text-xs px-2 py-0.5 rounded-full ${u.deployment?`bg-green-500/10 text-green-600 dark:text-green-400`:`bg-muted text-muted-foreground`}`,children:u.deployment?`Available`:`Disabled`})]}),l!==void 0&&(0,T.jsxs)(`div`,{className:`flex justify-between items-center`,children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Authentication:`}),(0,T.jsx)(`span`,{className:`text-xs px-2 py-0.5 rounded-full ${l?`bg-blue-500/10 text-blue-600 dark:text-blue-400`:`bg-muted text-muted-foreground`}`,children:l?`Required`:`Not Required`})]})]})]}),(0,T.jsx)(`div`,{className:`flex justify-center pt-2`,children:(0,T.jsxs)(K,{variant:`outline`,size:`sm`,onClick:()=>window.open(`https://github.com/microsoft/agent-framework`,`_blank`),className:`text-xs`,children:[(0,T.jsx)(Vl,{className:`h-3 w-3 mr-1`}),`Learn More about Agent Framework`]})})]})]})]})})}function uh(e,t){if(t.type!==`response.output_text.delta`&&t.type!==`response.refusal.delta`||!(`delta`in t)||typeof t.delta!=`string`||t.delta.length===0)return e;let n=t.type===`response.refusal.delta`?`refusal`:`text`,r=e.map(e=>({...e})),i=r.find(e=>e.itemId===t.item_id&&e.contentIndex===(t.content_index??0)&&e.type===n);return i?i.text+=t.delta:r.push({itemId:t.item_id,contentIndex:t.content_index??0,type:n,text:t.delta}),r}var dh=`devui_streaming_state_`,fh=1440*60*1e3,ph=16*1024;function mh(e){return`${dh}${e}`}function hh(e){if(e.accumulatedParts?.length){let t=[],n=ph;for(let r of[...e.accumulatedParts].reverse()){if(n<=0)break;let e=r.text.slice(-n);t.unshift({...r,text:e}),n-=e.length}let r=t.map(e=>e.text).join(``);return{...e,accumulatedParts:t,accumulatedText:r,accumulatedTextIsPreview:e.accumulatedTextIsPreview||r.lengthe+t.text.length,0)}}return e.accumulatedText===void 0||e.accumulatedText.length<=ph?e:{...e,accumulatedText:e.accumulatedText.slice(-16384),accumulatedTextIsPreview:!0}}function gh(e){let t=mh(e),n=localStorage.getItem(t);if(!n)return null;let r=JSON.parse(n);return Date.now()-r.timestamp>fh?(xh(e),null):hh(r)}function _h({conversationId:e,responseId:t,lastMessageId:n,lastSequenceNumber:r=-1,accumulatedText:i,accumulatedTextIsPreview:a=!1,accumulatedTextType:o,accumulatedParts:s}){return hh({conversationId:e,responseId:t,lastMessageId:n,lastSequenceNumber:r,timestamp:Date.now(),completed:!1,accumulatedText:i,accumulatedTextIsPreview:a,accumulatedTextType:o,accumulatedParts:s})}function vh(e,t,n,r){let i=`sequence_number`in t?t.sequence_number:void 0,a={...e,responseId:n,lastMessageId:r,timestamp:Date.now(),completed:t.type===`response.completed`||t.type===`response.failed`};if(i!==void 0&&(a.lastSequenceNumber=i),(t.type===`response.output_text.delta`||t.type===`response.refusal.delta`)&&`delta`in t&&typeof t.delta==`string`&&t.delta.length>0){let n=t.type===`response.refusal.delta`?`refusal`:`text`,r=uh(e.accumulatedParts?e.accumulatedParts.map(e=>({...e})):e.accumulatedText?[{itemId:e.lastMessageId,contentIndex:0,type:e.accumulatedTextType??`text`,text:e.accumulatedText}]:[],t);a.accumulatedParts=r,a.accumulatedText=r.map(e=>e.text).join(``),a.accumulatedTextIsPreview=e.accumulatedTextIsPreview,a.accumulatedTextType=n}return hh(a)}function yh(e){try{let t=mh(e.conversationId),n=JSON.stringify(hh(e));localStorage.setItem(t,n)}catch(t){console.error(`Failed to save streaming state:`,t);try{Sh();let t=mh(e.conversationId),n=JSON.stringify(hh(e));localStorage.setItem(t,n)}catch{console.error(`Failed to save streaming state even after cleanup`)}}}function bh(e){try{let t=gh(e);return!t||t.completed?null:t}catch(e){return console.error(`Failed to load streaming state:`,e),null}}function xh(e){try{let t=mh(e);localStorage.removeItem(t)}catch(e){console.error(`Failed to clear streaming state:`,e)}}function Sh(){try{let e=Object.keys(localStorage),t=Date.now();for(let n of e)if(n.startsWith(dh))try{let e=localStorage.getItem(n);if(e){let r=JSON.parse(e);(t-r.timestamp>fh||r.completed)&&localStorage.removeItem(n)}}catch{localStorage.removeItem(n)}}catch(e){console.error(`Failed to clear expired streaming states:`,e)}}function Ch(){Sh()}function wh(){let[e,t]=(0,y.useState)(!1),n=(0,y.useRef)(null);return{isCancelling:e,createAbortSignal:(0,y.useCallback)(()=>(n.current=new AbortController,t(!1),n.current.signal),[]),handleCancel:(0,y.useCallback)(()=>{n.current&&=(t(!0),n.current.abort(),null)},[]),resetCancelling:(0,y.useCallback)(()=>{t(!1)},[]),cleanup:(0,y.useCallback)(()=>{n.current&&=(n.current.abort(),null)},[])}}function Th(e){return e instanceof DOMException&&e.name===`AbortError`}function Eh(e={}){let{onDrop:t,disabled:n=!1}=e,[r,i]=(0,y.useState)(!1),[a,o]=(0,y.useState)([]),s=(0,y.useRef)(0),c=(0,y.useCallback)(e=>{e.preventDefault(),e.stopPropagation(),!n&&(s.current++,e.dataTransfer.items&&e.dataTransfer.items.length>0&&i(!0))},[n]),l=(0,y.useCallback)(e=>{e.preventDefault(),e.stopPropagation(),!n&&(s.current--,s.current===0&&i(!1))},[n]),u=(0,y.useCallback)(e=>{e.preventDefault(),e.stopPropagation()},[]),d=(0,y.useCallback)(e=>{if(e.preventDefault(),e.stopPropagation(),i(!1),s.current=0,n)return;let r=Array.from(e.dataTransfer.files);r.length>0&&(o(r),t?.(r))},[n,t]);return{isDragOver:r,droppedFiles:a,clearDroppedFiles:(0,y.useCallback)(()=>{o([])},[]),dragHandlers:{onDragEnter:c,onDragLeave:l,onDragOver:u,onDrop:d}}}var Dh=`modulepreload`,Oh=function(e,t){return new URL(e,t).href},kh={},Ah=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=Oh(t,n),t in kh)return;kh[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:Dh,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},jh=``,Mh=1e3,Nh=10,Ph=250,Fh=12,Ih=500,Lh=6e3;function Rh(){return localStorage.getItem(`devui_backend_url`)||jh}function zh(e,t){return t?t.aborted?Promise.reject(new DOMException(`Request aborted`,`AbortError`)):new Promise((n,r)=>{let i=setTimeout(()=>{t.removeEventListener(`abort`,a),n()},e),a=()=>{clearTimeout(i),r(new DOMException(`Request aborted`,`AbortError`))};t.addEventListener(`abort`,a,{once:!0})}):new Promise(t=>setTimeout(t,e))}function Bh(e){if(!(e.type!==`response.trace.completed`||!(`data`in e)||typeof e.data!=`object`||e.data===null))return e.data}var Vh=new class{baseUrl;authToken=null;constructor(e){this.baseUrl=e||Rh(),this.authToken=localStorage.getItem(`devui_auth_token`)}setBaseUrl(e){this.baseUrl=e}getBaseUrl(){return this.baseUrl}setAuthToken(e){this.authToken=e,e?localStorage.setItem(`devui_auth_token`,e):localStorage.removeItem(`devui_auth_token`)}getAuthToken(){return this.authToken}clearAuthToken(){this.setAuthToken(null)}async request(e,t={}){let n=`${this.baseUrl}${e}`,r={"Content-Type":`application/json`,...t.headers};this.authToken&&(r.Authorization=`Bearer ${this.authToken}`);let i=await fetch(n,{...t,headers:r});if(!i.ok){if(i.status===401)throw this.clearAuthToken(),Error(`UNAUTHORIZED`);let e=`API request failed: ${i.status} ${i.statusText}`;try{let t=await i.json();t.detail?typeof t.detail==`string`?e=t.detail:typeof t.detail==`object`&&t.detail.error?.message&&(e=t.detail.error.message):t.error?.message&&(e=t.error.message)}catch{}throw Error(e)}return i.json()}async getTraceEvents(e,t,n){if(!t)return[];if(n?.aborted)throw new DOMException(`Request aborted`,`AbortError`);let r={};this.authToken&&(r.Authorization=`Bearer ${this.authToken}`);let i=new Map,a=new AbortController,o=()=>a.abort();n?.addEventListener(`abort`,o,{once:!0});let s=setTimeout(o,Lh);try{for(let t=0;t0)for(let t of e.data){let e=Bh(t)?.span_id;e&&i.set(e,t)}}else if(t.status===401)return this.clearAuthToken(),[];else if(t.status!==404&&t.status!==503)break}catch(e){if(Th(e)||a.signal.aborted)throw e}t{if(e.type===`agent`)return{id:e.id,name:e.name,description:e.description,type:`agent`,source:e.source||`directory`,tools:(e.tools||[]).map(e=>typeof e==`string`?e:JSON.stringify(e)),has_env:!!(e.required_env_vars&&e.required_env_vars.length>0),module_path:typeof e.metadata?.module_path==`string`?e.metadata.module_path:void 0,required_env_vars:e.required_env_vars,metadata:e.metadata,deployment_supported:e.deployment_supported,deployment_reason:e.deployment_reason,instructions:e.instructions,model_id:e.model_id,chat_client_type:e.chat_client_type,context_provider:e.context_provider,middleware:e.middleware};{let t=e.executors||e.tools||[],n=e.start_executor_id||``;if(!n&&t.length>0){let e=t[0];typeof e==`string`&&(n=e)}return{id:e.id,name:e.name,description:e.description,type:`workflow`,source:e.source||`directory`,executors:t.map(e=>typeof e==`string`?e:JSON.stringify(e)),has_env:!!(e.required_env_vars&&e.required_env_vars.length>0),module_path:typeof e.metadata?.module_path==`string`?e.metadata.module_path:void 0,required_env_vars:e.required_env_vars,metadata:e.metadata,deployment_supported:e.deployment_supported,deployment_reason:e.deployment_reason,input_schema:e.input_schema||{type:`string`},input_type_name:e.input_type_name||`Input`,start_executor_id:n,tools:[]}}});return{entities:e,agents:e.filter(e=>e.type===`agent`),workflows:e.filter(e=>e.type===`workflow`)}}async getAgents(){let{agents:e}=await this.getEntities();return e}async getWorkflows(){let{workflows:e}=await this.getEntities();return e}async getAgentInfo(e){return this.request(`/v1/entities/${e}/info?type=agent`)}async getWorkflowInfo(e){return this.request(`/v1/entities/${e}/info?type=workflow`)}async reloadEntity(e){return this.request(`/v1/entities/${e}/reload`,{method:`POST`})}async createConversation(e){let{oaiMode:t}=await Ah(()=>Promise.resolve().then(()=>(Dd(),Ed)).then(e=>({oaiMode:e.useDevUIStore.getState().oaiMode})),void 0,import.meta.url),n={};t.enabled&&(n[`X-Proxy-Backend`]=`openai`);let r=await this.request(`/v1/conversations`,{method:`POST`,headers:n,body:JSON.stringify({metadata:e})});return{id:r.id,object:`conversation`,created_at:r.created_at,metadata:r.metadata}}async listConversations(e){let t=e?`/v1/conversations?agent_id=${encodeURIComponent(e)}`:`/v1/conversations`,n=await this.request(t);return{data:n.data.map(e=>({id:e.id,object:`conversation`,created_at:e.created_at,metadata:e.metadata})),has_more:n.has_more}}async getConversation(e){let t=await this.request(`/v1/conversations/${e}`);return{id:t.id,object:`conversation`,created_at:t.created_at,metadata:t.metadata}}async deleteConversation(e){try{return await this.request(`/v1/conversations/${e}`,{method:`DELETE`}),xh(e),!0}catch{return!1}}async listConversationItems(e,t){let n=new URLSearchParams;t?.limit&&n.set(`limit`,t.limit.toString()),t?.after&&n.set(`after`,t.after),t?.order&&n.set(`order`,t.order);let r=n.toString(),i=`/v1/conversations/${e}/items${r?`?${r}`:``}`;return this.request(i)}async getConversationItem(e,t){let n=`/v1/conversations/${e}/items/${t}`;return this.request(n)}async deleteConversationItem(e,t){let n=await fetch(`${this.baseUrl}/v1/conversations/${e}/items/${t}`,{method:`DELETE`});if(!n.ok)throw Error(`Failed to delete item: ${n.statusText}`)}async*streamOpenAIResponse(e,t,n,r){let{oaiMode:i}=await Ah(()=>Promise.resolve().then(()=>(Dd(),Ed)).then(e=>({oaiMode:e.useDevUIStore.getState().oaiMode})),void 0,import.meta.url);i.enabled&&(e.model=i.model,i.temperature!==void 0&&(e.temperature=i.temperature),i.max_output_tokens!==void 0&&(e.max_output_tokens=i.max_output_tokens),i.top_p!==void 0&&(e.top_p=i.top_p),i.instructions!==void 0&&(e.instructions=i.instructions),i.reasoning_effort!==void 0&&(e.reasoning={effort:i.reasoning_effort}));let a=-1,o=0,s=!1,c=r,l,u=0,d=t?bh(t):null,f=d?{...d}:null,p=(e=!1)=>{if(!t||!f)return;let n=Date.now();!e&&n-u{!t||!c||(f=vh(f??_h({conversationId:t,responseId:c,lastMessageId:l,lastSequenceNumber:a,accumulatedText:d?.accumulatedText,accumulatedTextIsPreview:d?.accumulatedTextIsPreview,accumulatedTextType:d?.accumulatedTextType,accumulatedParts:d?.accumulatedParts}),e,c,l),p(!((e.type===`response.output_text.delta`||e.type===`response.refusal.delta`)&&`delta`in e&&typeof e.delta==`string`&&e.delta.length>0)))};for(d&&(r||(c=d.responseId),a=d.lastSequenceNumber,l=d.lastMessageId,s=d.lastSequenceNumber>=0||!!d.accumulatedText);o<=Nh;)try{let r;if(c){let e=new URLSearchParams;e.set(`stream`,`true`),a>=0&&e.set(`starting_after`,a.toString());let t=`${this.baseUrl}/v1/responses/${c}?${e.toString()}`,i={Accept:`text/event-stream`};this.authToken&&(i.Authorization=`Bearer ${this.authToken}`),r=await fetch(t,{method:`GET`,headers:i,signal:n})}else{let t=`${this.baseUrl}/v1/responses`,a={"Content-Type":`application/json`,Accept:`text/event-stream`};i.enabled&&(a[`X-Proxy-Backend`]=`openai`),this.authToken&&(a.Authorization=`Bearer ${this.authToken}`),r=await fetch(t,{method:`POST`,headers:a,body:JSON.stringify(e),signal:n})}if(!r.ok){if(r.status===401)throw this.clearAuthToken(),Error(`UNAUTHORIZED`);if(r.status>=400&&r.status<500){let e=`Client error ${r.status}`;try{let t=await r.json();t.error&&t.error.message?e=t.error.message:t.detail&&(e=t.detail)}catch{}throw Error(`CLIENT_ERROR: ${e}`)}let e=`Request failed with status ${r.status}`;try{let t=await r.json();t.error&&t.error.message?e=t.error.message:t.detail&&(e=t.detail)}catch{}throw Error(e)}let o=r.body?.getReader();if(!o)throw Error(`Response body is not readable`);let p=new TextDecoder,h=``;try{for(;;){if(n?.aborted)throw new DOMException(`Request aborted`,`AbortError`);let{done:e,value:r}=await o.read();if(e){t&&(xh(t),f=null);return}let i=p.decode(r,{stream:!0});h+=i;let g=h.split(` +`);h=g.pop()||``;for(let e of g)if(e.startsWith(`data: `)){let n=e.slice(6);if(n===`[DONE]`){t&&(xh(t),f=null);return}try{let e=JSON.parse(n);if(`response`in e&&e.response&&typeof e.response==`object`&&`id`in e.response){let t=e.response.id;(!c||c!==t)&&(c=t)}else if(`id`in e&&typeof e.id==`string`&&e.id.startsWith(`resp_`)){let t=e.id;(!c||c!==t)&&(c=t)}`item_id`in e&&e.item_id&&(l=e.item_id);let r=`sequence_number`in e?e.sequence_number:void 0;if(r!==void 0)if(s&&r<=1&&a>1)t&&xh(t),d=null,f=null,u=0,yield{type:`error`,message:`Connection lost - previous response failed. Starting new response.`},a=r,s=!0,m(e),yield e;else if(r<=a)continue;else a=r,s=!0,m(e),yield e;else s=!0,m(e),yield e}catch(e){console.error(`Failed to parse OpenAI SSE event:`,e)}}}}finally{o.releaseLock()}}catch(e){let r=e instanceof Error?e.message:String(e);if(Th(e))throw t&&(xh(t),f=null),e;if(r===`UNAUTHORIZED`||r.startsWith(`CLIENT_ERROR:`))throw e;if(o++,o>Nh)throw Error(`Connection failed after ${Nh} retry attempts: ${r}`);await zh(Math.min(Mh*2**(o-1),3e4),n)}}async*streamAgentExecutionOpenAI(e,t,n,r){let i={metadata:{entity_id:e},input:t.input,stream:!0,conversation:t.conversation_id};return yield*this.streamAgentExecutionOpenAIDirect(e,i,t.conversation_id,n,r)}async*streamAgentExecutionOpenAIDirect(e,t,n,r,i){yield*this.streamOpenAIResponse(t,n,r,i)}async*streamWorkflowExecutionOpenAI(e,t,n){let r={metadata:{entity_id:e},input:JSON.stringify(t.input_data||{}),stream:!0,conversation:t.conversation_id,extra_body:t.checkpoint_id?{entity_id:e,checkpoint_id:t.checkpoint_id}:void 0};yield*this.streamOpenAIResponse(r,t.conversation_id,n)}async runAgentSync(e,t){let{oaiMode:n}=await Ah(()=>Promise.resolve().then(()=>(Dd(),Ed)).then(e=>({oaiMode:e.useDevUIStore.getState().oaiMode})),void 0,import.meta.url),r={metadata:{entity_id:e},input:t.input,stream:!1,conversation:t.conversation_id};n.enabled&&(r.model=n.model,n.temperature!==void 0&&(r.temperature=n.temperature),n.max_output_tokens!==void 0&&(r.max_output_tokens=n.max_output_tokens));let i={};return n.enabled&&(i[`X-Proxy-Backend`]=`openai`),this.request(`/v1/responses`,{method:`POST`,headers:i,body:JSON.stringify(r)})}async runWorkflowSync(e,t){let n={metadata:{entity_id:e},input:JSON.stringify(t.input_data||{}),stream:!1,conversation:t.conversation_id,extra_body:t.checkpoint_id?{entity_id:e,checkpoint_id:t.checkpoint_id}:void 0};return this.request(`/v1/responses`,{method:`POST`,body:JSON.stringify(n)})}clearStreamingState(e){xh(e)}async*streamDeployment(e){let t=await fetch(`${this.baseUrl}/v1/deployments`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({...e,stream:!0})});if(!t.ok)throw Error(`Deployment failed: ${t.statusText}`);let n=t.body?.getReader();if(!n)throw Error(`No response body`);let r=new TextDecoder,i=``;try{for(;;){let{done:e,value:t}=await n.read();if(e)break;i+=r.decode(t,{stream:!0});let a=i.split(` +`);i=a.pop()||``;for(let e of a)if(e.startsWith(`data: `)){let t=e.slice(6);if(t===`[DONE]`)return;try{yield JSON.parse(t)}catch(e){yield{type:`deploy.error`,message:`Failed to parse deployment event: ${e instanceof Error?e.message:`Unknown error`}`}}}}}catch(e){throw yield{type:`deploy.failed`,message:`Stream interrupted: ${e instanceof Error?e.message:`Unknown error`}`},e}finally{n.releaseLock()}}async listWorkflowSessions(e){let t=`/v1/conversations?entity_id=${encodeURIComponent(e)}&type=workflow_session`;return{data:(await this.request(t)).data.map(t=>({conversation_id:t.id,entity_id:t.metadata?.entity_id||e,created_at:t.created_at,metadata:{name:t.metadata?.name||`Session ${new Date(t.created_at*1e3).toLocaleString()}`,description:t.metadata?.description,type:`workflow_session`,checkpoint_summary:t.metadata?.checkpoint_summary}}))}}async createWorkflowSession(e,t){let n={entity_id:e,type:`workflow_session`,name:t?.name||`Session ${new Date().toLocaleString()}`,...t?.description&&{description:t.description}},r=await this.createConversation(n);return{conversation_id:r.id,entity_id:e,created_at:r.created_at,metadata:{name:n.name,description:n.description,type:`workflow_session`}}}async deleteWorkflowSession(e,t){if(!await this.deleteConversation(t))throw Error(`Failed to delete workflow session`)}};Dd();function Hh({open:e,onClose:t,agentName:n=`Agent`,entity:r}){let i=Q(e=>e.azureDeploymentEnabled)&&(r?.deployment_supported??!1),[a,o]=(0,y.useState)(i?`azure`:`docker`),[s,c]=(0,y.useState)(null),l=(0,y.useRef)(null),u=(0,y.useRef)(null),d=Q(e=>e.isDeploying),f=Q(e=>e.deploymentLogs),p=Q(e=>e.lastDeployment),m=Q(e=>e.startDeployment),h=Q(e=>e.addDeploymentLog),g=Q(e=>e.setDeploymentResult),_=Q(e=>e.stopDeployment),v=Q(e=>e.clearDeploymentState),b=e=>{let t=e.toLowerCase().replace(/[_\s]+/g,`-`).replace(/[^a-z0-9-]/g,``).replace(/--+/g,`-`).replace(/^[^a-z]+/,``).replace(/-$/,``);return(t.match(/^[a-z]/)?t:`app-${t}`).substring(0,31)},x=r?b(r.id):``,[S,C]=(0,y.useState)(`my-test-rg`),[w,E]=(0,y.useState)(x),[D,O]=(0,y.useState)(`eastus`),[k,A]=(0,y.useState)(null);(0,y.useEffect)(()=>{if(r){let e=b(r.id);E(e),A(j(e))}},[r?.id]),(0,y.useEffect)(()=>{u.current&&f.length>0&&(u.current.scrollTop=u.current.scrollHeight)},[f]);let j=e=>e?e.length>=32?`App name must be less than 32 characters`:/^[a-z0-9-]+$/.test(e)?/^[a-z]/.test(e)?/[a-z0-9]$/.test(e)?e.includes(`--`)?`App name cannot contain consecutive hyphens (--)`:null:`App name must end with a letter or number`:`App name must start with a lowercase letter`:`App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)`:null;(0,y.useEffect)(()=>()=>{l.current&&clearTimeout(l.current)},[]);let M=async()=>{if(!r?.id||!S||!w)return;let e=S.trim(),t=w.trim(),n=j(t);if(n){A(n);return}try{m();for await(let n of Vh.streamDeployment({entity_id:r.id,resource_group:e,app_name:t,region:D,ui_mode:`user`}))h(n.message),n.type===`deploy.completed`&&n.url&&n.auth_token?g({url:n.url,authToken:n.auth_token}):n.type===`deploy.failed`&&_()}catch(e){h(`Error: ${e instanceof Error?e.message:`Deployment failed`}`),_()}},N=async(e,t)=>{try{await navigator.clipboard.writeText(e),c(t),l.current&&clearTimeout(l.current),l.current=setTimeout(()=>{c(null),l.current=null},2e3)}catch{c(null)}},P=`# Dockerfile for ${n} FROM python:3.11-slim WORKDIR /app @@ -116,40 +116,40 @@ az acr build --registry myregistry \\ --registry-server myregistry.azurecr.io \\ --env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_COMPLETION_MODEL=gpt-4o-mini`})]}),(0,T.jsxs)(`div`,{className:`border-l-2 border-primary pl-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-1`,children:[(0,T.jsx)(`div`,{className:`w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold`,children:`5`}),(0,T.jsx)(`h5`,{className:`font-medium text-sm`,children:`Get Application URL`})]}),(0,T.jsx)(`pre`,{className:`bg-muted p-2 rounded text-xs overflow-x-auto border mt-2`,children:`az containerapp show --name ${n.toLowerCase()}-app \\ --resource-group myResourceGroup \\ - --query properties.configuration.ingress.fqdn`})]})]})]}),(0,T.jsxs)(`div`,{className:`bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3`,children:[(0,T.jsx)(`h4`,{className:`text-sm font-semibold mb-2`,children:`Learn More`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground mb-3`,children:`Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration.`}),(0,T.jsx)(K,{size:`sm`,variant:`outline`,className:`w-full`,asChild:!0,children:(0,T.jsxs)(`a`,{href:`https://learn.microsoft.com/azure/container-apps/`,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(Vl,{className:`h-3 w-3 mr-1`}),`View Azure Container Apps Documentation`]})})]})]})]})]})})})]})})}function zh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card`,className:at(`bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm`,e),...t})}function Bh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-header`,className:at(`@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6`,e),...t})}function Vh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-title`,className:at(`leading-none font-semibold`,e),...t})}function Hh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-description`,className:at(`text-muted-foreground text-sm`,e),...t})}function Uh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-content`,className:at(`px-6`,e),...t})}function Wh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-footer`,className:at(`flex items-center px-6 [.border-t]:pt-6`,e),...t})}var Gh=[{id:`foundry-weather-agent`,name:`Azure AI Weather Agent`,description:`Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication`,type:`agent`,url:`https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py`,tags:[`azure-ai`,`foundry`,`tools`],author:`Microsoft`,difficulty:`beginner`,features:[`Azure AI Agent integration`,`Azure CLI authentication`,`Mock weather tools`],requiredEnvVars:[{name:`FOUNDRY_PROJECT_ENDPOINT`,description:`Microsoft Foundry project endpoint URL`,required:!0,example:`https://your-project.api.azureml.ms`},{name:`FOUNDRY_MODEL`,description:`Name of the deployed model in Microsoft Foundry`,required:!0,example:`gpt-4o`}]},{id:`weather-agent-azure`,name:`Azure OpenAI Weather Agent`,description:`Weather agent using Azure OpenAI with API key authentication`,type:`agent`,url:`https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py`,tags:[`azure`,`openai`,`tools`],author:`Microsoft`,difficulty:`beginner`,features:[`Azure OpenAI integration`,`API key authentication`,`Function calling`,`Mock weather tools`],requiredEnvVars:[{name:`AZURE_OPENAI_API_KEY`,description:`Azure OpenAI API key`,required:!0},{name:`AZURE_OPENAI_MODEL`,description:`Name of the deployed model in Azure OpenAI`,required:!0,example:`gpt-4o`},{name:`AZURE_OPENAI_ENDPOINT`,description:`Azure OpenAI endpoint URL`,required:!0,example:`https://your-resource.openai.azure.com`}]},{id:`spam-workflow`,name:`Spam Detection Workflow`,description:`5-step workflow demonstrating email spam detection with branching logic`,type:`workflow`,url:`https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py`,tags:[`workflow`,`branching`,`multi-step`],author:`Microsoft`,difficulty:`beginner`,features:[`Sequential execution`,`Conditional branching`,`Mock spam detection`]},{id:`fanout-workflow`,name:`Complex Fan-In/Fan-Out Workflow`,description:`Advanced data processing workflow with parallel validation, transformation, and quality assurance stages`,type:`workflow`,url:`https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py`,tags:[`workflow`,`fan-out`,`fan-in`,`parallel`],author:`Microsoft`,difficulty:`advanced`,features:[`Fan-out pattern`,`Parallel execution`,`Complex state management`,`Multi-stage processing`]}];Gh.filter(e=>e.type===`agent`),Gh.filter(e=>e.type===`workflow`),Gh.filter(e=>e.difficulty===`beginner`),Gh.filter(e=>e.difficulty===`intermediate`),Gh.filter(e=>e.difficulty===`advanced`);var Kh=e=>{switch(e){case`beginner`:return`bg-green-100 text-green-700 border-green-200`;case`intermediate`:return`bg-yellow-100 text-yellow-700 border-yellow-200`;case`advanced`:return`bg-red-100 text-red-700 border-red-200`;default:return`bg-gray-100 text-gray-700 border-gray-200`}},qh=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(`div`,{ref:n,role:`alert`,className:at(`relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground`,e),...t}));qh.displayName=`Alert`;var Jh=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(`h5`,{ref:n,className:at(`mb-1 font-medium leading-none tracking-tight`,e),...t}));Jh.displayName=`AlertTitle`;var Yh=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(`div`,{ref:n,className:at(`text-sm [&_p]:leading-relaxed`,e),...t}));Yh.displayName=`AlertDescription`;function Xh({children:e,copyable:t=!1}){let[n,r]=(0,y.useState)(!1);return(0,T.jsxs)(`div`,{className:`relative`,children:[(0,T.jsx)(`pre`,{className:`bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono`,children:(0,T.jsx)(`code`,{children:e})}),t&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,className:`absolute top-2 right-2 h-6 w-6 p-0`,onClick:()=>{navigator.clipboard.writeText(e),r(!0),setTimeout(()=>r(!1),2e3)},children:n?(0,T.jsx)(Tl,{className:`h-3 w-3`}):(0,T.jsx)(Ll,{className:`h-3 w-3`})})]})}function Zh({number:e,title:t,description:n,code:r,action:i,copyable:a=!1}){return(0,T.jsxs)(`div`,{className:`flex gap-4`,children:[(0,T.jsx)(`div`,{className:`flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold`,children:e})}),(0,T.jsxs)(`div`,{className:`flex-1 space-y-2`,children:[(0,T.jsx)(`h4`,{className:`font-semibold`,children:t}),n&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:n}),r&&(0,T.jsx)(Xh,{copyable:a,children:r}),i&&(0,T.jsx)(`div`,{children:i})]})]})}function Qh({sample:e,open:t,onOpenChange:n}){let r=e.requiredEnvVars&&e.requiredEnvVars.length>0,i=r?0:-1;return(0,T.jsx)(Im,{open:t,onOpenChange:n,children:(0,T.jsxs)(Lm,{className:`max-w-3xl`,children:[(0,T.jsxs)(Rm,{className:`px-6 pt-6 pb-2`,children:[(0,T.jsxs)(zm,{children:[`Setup: `,e.name]}),(0,T.jsxs)(Bm,{children:[`Follow these steps to run this sample `,e.type,` locally`]})]}),(0,T.jsx)(`div`,{className:`px-6 pb-6`,children:(0,T.jsx)(gf,{className:`h-[500px]`,children:(0,T.jsxs)(`div`,{className:`space-y-6 pr-4`,children:[(0,T.jsx)(Zh,{number:1,title:`Download the sample file`,action:(0,T.jsx)(K,{asChild:!0,size:`sm`,children:(0,T.jsxs)(`a`,{href:e.url,download:`${e.id}.py`,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(zl,{className:`h-4 w-4 mr-2`}),`Download `,e.id,`.py`]})})}),(0,T.jsx)(Zh,{number:2,title:`Create a project folder`,description:`Create a dedicated folder for this sample and move the downloaded file there:`,code:`mkdir -p ~/my-agents/${e.id}\nmv ~/Downloads/${e.id}.py ~/my-agents/${e.id}/`,copyable:!0}),r&&(0,T.jsx)(Zh,{number:3,title:`Set up environment variables`,description:`Create a .env file in the project folder with these required variables:`,code:e.requiredEnvVars.map(e=>`${e.name}=${e.example||`your-value-here`}\n# ${e.description}`).join(` + --query properties.configuration.ingress.fqdn`})]})]})]}),(0,T.jsxs)(`div`,{className:`bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3`,children:[(0,T.jsx)(`h4`,{className:`text-sm font-semibold mb-2`,children:`Learn More`}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground mb-3`,children:`Explore Azure Container Apps documentation for advanced features like scaling, monitoring, and CI/CD integration.`}),(0,T.jsx)(K,{size:`sm`,variant:`outline`,className:`w-full`,asChild:!0,children:(0,T.jsxs)(`a`,{href:`https://learn.microsoft.com/azure/container-apps/`,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(Vl,{className:`h-3 w-3 mr-1`}),`View Azure Container Apps Documentation`]})})]})]})]})]})})})]})})}function Uh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card`,className:at(`bg-card text-card-foreground flex flex-col gap-6 rounded border py-6 shadow-sm`,e),...t})}function Wh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-header`,className:at(`@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6`,e),...t})}function Gh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-title`,className:at(`leading-none font-semibold`,e),...t})}function Kh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-description`,className:at(`text-muted-foreground text-sm`,e),...t})}function qh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-content`,className:at(`px-6`,e),...t})}function Jh({className:e,...t}){return(0,T.jsx)(`div`,{"data-slot":`card-footer`,className:at(`flex items-center px-6 [.border-t]:pt-6`,e),...t})}var Yh=[{id:`foundry-weather-agent`,name:`Azure AI Weather Agent`,description:`Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication`,type:`agent`,url:`https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/foundry_agent/agent.py`,tags:[`azure-ai`,`foundry`,`tools`],author:`Microsoft`,difficulty:`beginner`,features:[`Azure AI Agent integration`,`Azure CLI authentication`,`Mock weather tools`],requiredEnvVars:[{name:`FOUNDRY_PROJECT_ENDPOINT`,description:`Microsoft Foundry project endpoint URL`,required:!0,example:`https://your-project.api.azureml.ms`},{name:`FOUNDRY_MODEL`,description:`Name of the deployed model in Microsoft Foundry`,required:!0,example:`gpt-4o`}]},{id:`weather-agent-azure`,name:`Azure OpenAI Weather Agent`,description:`Weather agent using Azure OpenAI with API key authentication`,type:`agent`,url:`https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/weather_agent_azure/agent.py`,tags:[`azure`,`openai`,`tools`],author:`Microsoft`,difficulty:`beginner`,features:[`Azure OpenAI integration`,`API key authentication`,`Function calling`,`Mock weather tools`],requiredEnvVars:[{name:`AZURE_OPENAI_API_KEY`,description:`Azure OpenAI API key`,required:!0},{name:`AZURE_OPENAI_MODEL`,description:`Name of the deployed model in Azure OpenAI`,required:!0,example:`gpt-4o`},{name:`AZURE_OPENAI_ENDPOINT`,description:`Azure OpenAI endpoint URL`,required:!0,example:`https://your-resource.openai.azure.com`}]},{id:`spam-workflow`,name:`Spam Detection Workflow`,description:`5-step workflow demonstrating email spam detection with branching logic`,type:`workflow`,url:`https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/spam_workflow/workflow.py`,tags:[`workflow`,`branching`,`multi-step`],author:`Microsoft`,difficulty:`beginner`,features:[`Sequential execution`,`Conditional branching`,`Mock spam detection`]},{id:`fanout-workflow`,name:`Complex Fan-In/Fan-Out Workflow`,description:`Advanced data processing workflow with parallel validation, transformation, and quality assurance stages`,type:`workflow`,url:`https://raw.githubusercontent.com/microsoft/agent-framework/main/python/samples/02-agents/devui/fanout_workflow/workflow.py`,tags:[`workflow`,`fan-out`,`fan-in`,`parallel`],author:`Microsoft`,difficulty:`advanced`,features:[`Fan-out pattern`,`Parallel execution`,`Complex state management`,`Multi-stage processing`]}];Yh.filter(e=>e.type===`agent`),Yh.filter(e=>e.type===`workflow`),Yh.filter(e=>e.difficulty===`beginner`),Yh.filter(e=>e.difficulty===`intermediate`),Yh.filter(e=>e.difficulty===`advanced`);var Xh=e=>{switch(e){case`beginner`:return`bg-green-100 text-green-700 border-green-200`;case`intermediate`:return`bg-yellow-100 text-yellow-700 border-yellow-200`;case`advanced`:return`bg-red-100 text-red-700 border-red-200`;default:return`bg-gray-100 text-gray-700 border-gray-200`}},Zh=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(`div`,{ref:n,role:`alert`,className:at(`relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground`,e),...t}));Zh.displayName=`Alert`;var Qh=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(`h5`,{ref:n,className:at(`mb-1 font-medium leading-none tracking-tight`,e),...t}));Qh.displayName=`AlertTitle`;var $h=y.forwardRef(({className:e,...t},n)=>(0,T.jsx)(`div`,{ref:n,className:at(`text-sm [&_p]:leading-relaxed`,e),...t}));$h.displayName=`AlertDescription`;function eg({children:e,copyable:t=!1}){let[n,r]=(0,y.useState)(!1);return(0,T.jsxs)(`div`,{className:`relative`,children:[(0,T.jsx)(`pre`,{className:`bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono`,children:(0,T.jsx)(`code`,{children:e})}),t&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,className:`absolute top-2 right-2 h-6 w-6 p-0`,onClick:()=>{navigator.clipboard.writeText(e),r(!0),setTimeout(()=>r(!1),2e3)},children:n?(0,T.jsx)(Tl,{className:`h-3 w-3`}):(0,T.jsx)(Ll,{className:`h-3 w-3`})})]})}function tg({number:e,title:t,description:n,code:r,action:i,copyable:a=!1}){return(0,T.jsxs)(`div`,{className:`flex gap-4`,children:[(0,T.jsx)(`div`,{className:`flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold`,children:e})}),(0,T.jsxs)(`div`,{className:`flex-1 space-y-2`,children:[(0,T.jsx)(`h4`,{className:`font-semibold`,children:t}),n&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:n}),r&&(0,T.jsx)(eg,{copyable:a,children:r}),i&&(0,T.jsx)(`div`,{children:i})]})]})}function ng({sample:e,open:t,onOpenChange:n}){let r=e.requiredEnvVars&&e.requiredEnvVars.length>0,i=r?0:-1;return(0,T.jsx)(Im,{open:t,onOpenChange:n,children:(0,T.jsxs)(Lm,{className:`max-w-3xl`,children:[(0,T.jsxs)(Rm,{className:`px-6 pt-6 pb-2`,children:[(0,T.jsxs)(zm,{children:[`Setup: `,e.name]}),(0,T.jsxs)(Bm,{children:[`Follow these steps to run this sample `,e.type,` locally`]})]}),(0,T.jsx)(`div`,{className:`px-6 pb-6`,children:(0,T.jsx)(gf,{className:`h-[500px]`,children:(0,T.jsxs)(`div`,{className:`space-y-6 pr-4`,children:[(0,T.jsx)(tg,{number:1,title:`Download the sample file`,action:(0,T.jsx)(K,{asChild:!0,size:`sm`,children:(0,T.jsxs)(`a`,{href:e.url,download:`${e.id}.py`,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(zl,{className:`h-4 w-4 mr-2`}),`Download `,e.id,`.py`]})})}),(0,T.jsx)(tg,{number:2,title:`Create a project folder`,description:`Create a dedicated folder for this sample and move the downloaded file there:`,code:`mkdir -p ~/my-agents/${e.id}\nmv ~/Downloads/${e.id}.py ~/my-agents/${e.id}/`,copyable:!0}),r&&(0,T.jsx)(tg,{number:3,title:`Set up environment variables`,description:`Create a .env file in the project folder with these required variables:`,code:e.requiredEnvVars.map(e=>`${e.name}=${e.example||`your-value-here`}\n# ${e.description}`).join(` -`),copyable:!0}),(0,T.jsx)(Zh,{number:4+i,title:`Run with DevUI`,description:`Navigate to the folder and start DevUI:`,code:`cd ~/my-agents/${e.id}\ndevui .`,copyable:!0}),(0,T.jsxs)(qh,{children:[(0,T.jsx)(Zl,{className:`h-4 w-4`}),(0,T.jsx)(Jh,{children:`Alternative: Run Programmatically`}),(0,T.jsxs)(Yh,{className:`mt-2`,children:[(0,T.jsxs)(`p`,{className:`mb-2`,children:[`You can also run the `,e.type,` directly in Python:`]}),(0,T.jsx)(Xh,{copyable:!0,children:`from ${e.id} import ${e.type} +`),copyable:!0}),(0,T.jsx)(tg,{number:4+i,title:`Run with DevUI`,description:`Navigate to the folder and start DevUI:`,code:`cd ~/my-agents/${e.id}\ndevui .`,copyable:!0}),(0,T.jsxs)(Zh,{children:[(0,T.jsx)(Zl,{className:`h-4 w-4`}),(0,T.jsx)(Qh,{children:`Alternative: Run Programmatically`}),(0,T.jsxs)($h,{className:`mt-2`,children:[(0,T.jsxs)(`p`,{className:`mb-2`,children:[`You can also run the `,e.type,` directly in Python:`]}),(0,T.jsx)(eg,{copyable:!0,children:`from ${e.id} import ${e.type} import asyncio async def main(): response = await ${e.type}.run("Hello!") print(response) -asyncio.run(main())`})]})]}),(0,T.jsxs)(`div`,{className:`flex gap-2 pt-4 border-t`,children:[(0,T.jsx)(K,{variant:`outline`,size:`sm`,asChild:!0,children:(0,T.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(Vl,{className:`h-4 w-4 mr-2`}),`View Source`]})}),(0,T.jsx)(K,{variant:`outline`,size:`sm`,asChild:!0,children:(0,T.jsxs)(`a`,{href:`https://github.com/microsoft/agent-framework#readme`,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(xl,{className:`h-4 w-4 mr-2`}),`Documentation`]})})]})]})})})]})})}function $h({sample:e}){let[t,n]=(0,y.useState)(!1);return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(zh,{className:`hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full`,children:[(0,T.jsxs)(Bh,{className:`pb-3 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-start justify-between mb-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(e.type===`workflow`?Tu:Sl,{className:`h-5 w-5`}),(0,T.jsx)(ct,{variant:`secondary`,className:`text-xs`,children:e.type})]}),(0,T.jsx)(ct,{variant:`outline`,className:at(`text-xs border`,Kh(e.difficulty)),children:e.difficulty})]}),(0,T.jsx)(Vh,{className:`text-lg leading-tight`,children:e.name}),(0,T.jsx)(Hh,{className:`text-sm line-clamp-3`,children:e.description})]}),(0,T.jsx)(Uh,{className:`pt-0 flex-1 min-w-0 overflow-hidden`,children:(0,T.jsxs)(`div`,{className:`space-y-3 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`flex flex-wrap gap-1`,children:[e.tags.slice(0,3).map(e=>(0,T.jsx)(ct,{variant:`outline`,className:`text-xs`,children:e},e)),e.tags.length>3&&(0,T.jsxs)(ct,{variant:`outline`,className:`text-xs`,children:[`+`,e.tags.length-3]})]}),e.requiredEnvVars&&e.requiredEnvVars.length>0&&(0,T.jsxs)(`details`,{className:`group min-w-0 max-w-full overflow-hidden`,children:[(0,T.jsxs)(`summary`,{className:`cursor-pointer list-none p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-950/30 transition-colors flex items-center justify-between gap-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 min-w-0`,children:[(0,T.jsx)(Yl,{className:`h-3.5 w-3.5 text-amber-600 dark:text-amber-500 flex-shrink-0`}),(0,T.jsxs)(`span`,{className:`text-xs font-medium text-amber-900 dark:text-amber-100 truncate`,children:[`Requires `,e.requiredEnvVars.length,` env var`,e.requiredEnvVars.length>1?`s`:``]})]}),(0,T.jsx)(X,{className:`h-3 w-3 text-amber-600 dark:text-amber-500 flex-shrink-0 group-open:rotate-180 transition-transform`})]}),(0,T.jsx)(`div`,{className:`mt-2 p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md space-y-2 min-w-0 max-w-full overflow-hidden`,children:e.requiredEnvVars.map(e=>(0,T.jsxs)(`div`,{className:`text-xs min-w-0 max-w-full overflow-hidden`,children:[(0,T.jsx)(`div`,{className:`font-mono font-medium text-amber-900 dark:text-amber-100 break-words`,children:e.name}),(0,T.jsx)(`div`,{className:`text-amber-700 dark:text-amber-300 mt-0.5 break-words`,children:e.description}),e.example&&(0,T.jsx)(`div`,{className:`font-mono text-amber-600 dark:text-amber-400 mt-0.5 break-all`,children:e.example})]},e.name))})]}),(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Key Features:`}),(0,T.jsx)(`ul`,{className:`text-xs space-y-1`,children:e.features.slice(0,3).map(e=>(0,T.jsxs)(`li`,{className:`flex items-center gap-1`,children:[(0,T.jsx)(`div`,{className:`w-1 h-1 rounded-full bg-current opacity-50`}),(0,T.jsx)(`span`,{children:e})]},e))})]})]})}),(0,T.jsxs)(Wh,{className:`pt-3 flex-col gap-3`,children:[(0,T.jsx)(`div`,{className:`w-full flex items-center justify-between text-xs text-muted-foreground`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-1`,children:[(0,T.jsx)(wu,{className:`h-3 w-3`}),(0,T.jsx)(`span`,{children:e.author})]})}),(0,T.jsxs)(`div`,{className:`w-full flex gap-2`,children:[(0,T.jsx)(K,{asChild:!0,className:`flex-1`,size:`sm`,children:(0,T.jsxs)(`a`,{href:e.url,download:`${e.id}.py`,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(zl,{className:`h-4 w-4 mr-2`}),`Download`]})}),(0,T.jsxs)(K,{variant:`outline`,className:`flex-1`,size:`sm`,onClick:()=>n(!0),children:[(0,T.jsx)(xl,{className:`h-4 w-4 mr-2`}),`Setup Guide`]})]})]})]}),(0,T.jsx)(Qh,{sample:e,open:t,onOpenChange:n})]})}function eg({samples:e}){return(0,T.jsx)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4`,children:e.map(e=>(0,T.jsx)(`div`,{className:`min-w-0`,children:(0,T.jsx)($h,{sample:e})},e.id))})}function tg({onClose:e,variant:t=`inline`,hasExistingEntities:n=!1}){return t===`inline`?(0,T.jsx)(`div`,{className:`flex-1 overflow-auto`,children:(0,T.jsxs)(`div`,{className:`max-w-7xl mx-auto px-6 py-8`,children:[(0,T.jsx)(`div`,{className:`mb-8 p-4 bg-muted/50 border border-border rounded-lg`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,T.jsx)(Su,{className:`h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5`}),(0,T.jsxs)(`div`,{className:`flex-1`,children:[(0,T.jsx)(`h3`,{className:`font-semibold mb-1`,children:`No agents or workflows configured yet!`}),(0,T.jsxs)(`p`,{className:`text-sm text-muted-foreground mb-2`,children:[`You can configure agents or workflows by running`,` `,(0,T.jsx)(`code`,{className:`px-1.5 py-0.5 bg-background rounded text-xs`,children:`devui`}),` `,`in a directory containing them.`]}),(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Browse the sample agents and workflows below. Download them, review the code, and run them locally to get started quickly.`})]})]})}),(0,T.jsxs)(`div`,{className:`mb-6`,children:[(0,T.jsx)(`h3`,{className:`text-lg font-semibold mb-4`,children:`Sample Gallery`}),(0,T.jsx)(eg,{samples:Gh})]}),(0,T.jsx)(`div`,{className:`text-center mt-12 pt-8 border-t`,children:(0,T.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Want to create your own agents or workflows? Check out the`,` `,(0,T.jsx)(`a`,{href:`https://github.com/microsoft/agent-framework`,className:`text-primary hover:underline`,target:`_blank`,rel:`noopener noreferrer`,children:`documentation`})]})})]})}):t===`route`?(0,T.jsx)(`div`,{className:`h-full overflow-auto`,children:(0,T.jsxs)(`div`,{className:`max-w-7xl mx-auto px-6 py-8`,children:[(0,T.jsxs)(`div`,{className:`mb-8`,children:[n&&(0,T.jsx)(`div`,{className:`mb-4`,children:(0,T.jsxs)(K,{variant:`ghost`,onClick:e,className:`gap-2`,children:[(0,T.jsx)(bl,{className:`h-4 w-4`}),`Back`]})}),(0,T.jsxs)(`div`,{className:`text-center`,children:[(0,T.jsx)(`h2`,{className:`text-2xl font-semibold mb-2`,children:`Sample Gallery`}),(0,T.jsx)(`p`,{className:`text-muted-foreground max-w-2xl mx-auto`,children:`Browse sample agents and workflows to learn the Agent Framework. Download these curated examples and run them locally. Examples range from beginner to advanced.`})]})]}),(0,T.jsx)(eg,{samples:Gh}),(0,T.jsx)(`div`,{className:`text-center mt-12 pt-8 border-t`,children:(0,T.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Want to create your own agents or workflows? Check out the`,` `,(0,T.jsx)(`a`,{href:`https://github.com/microsoft/agent-framework`,className:`text-primary hover:underline`,target:`_blank`,rel:`noopener noreferrer`,children:`documentation`})]})})]})}):(0,T.jsx)(eg,{samples:Gh})}function ng({className:e,...t}){return(0,T.jsx)(`textarea`,{"data-slot":`textarea`,className:at(`border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm`,e),...t})}function rg({onFilesSelected:e,accept:t=`image/*,.pdf,audio/*,.wav,.mp3,.m4a,.ogg`,multiple:n=!0,maxSize:r=50*1024*1024,disabled:i=!1,className:a=``}){let o=(0,y.useRef)(null),s=n=>{if(!n||n.length===0)return;let i=[],a=[];Array.from(n).forEach(e=>{if(e.size>r){a.push(`${e.name} is too large (max ${ig(r)})`);return}if(t&&!ag(e,t)){a.push(`${e.name} is not an accepted file type`);return}i.push(e)}),a.length>0&&console.warn(`File upload errors:`,a),i.length>0&&e(i)};return(0,T.jsxs)(`div`,{className:a,children:[(0,T.jsx)(`input`,{ref:o,type:`file`,accept:t,multiple:n,onChange:e=>{s(e.target.files),e.target.value=``},className:`hidden`,disabled:i}),(0,T.jsx)(K,{type:`button`,variant:`outline`,size:`icon`,onClick:()=>{o.current&&o.current.click()},disabled:i,onDrop:e=>{if(e.preventDefault(),e.stopPropagation(),i)return;let t=e.dataTransfer.files;s(t)},onDragOver:e=>{e.preventDefault(),e.stopPropagation()},className:`shrink-0 transition-colors hover:bg-muted`,title:`Upload files (images, PDFs, audio)`,children:(0,T.jsx)(Cu,{className:`h-4 w-4`})})]})}function ig(e){if(e===0)return`0 Bytes`;let t=1024,n=[`Bytes`,`KB`,`MB`,`GB`],r=Math.floor(Math.log(e)/Math.log(t));return parseFloat((e/t**r).toFixed(2))+` `+n[r]}function ag(e,t){return t.split(`,`).map(e=>e.trim()).some(t=>{if(t.startsWith(`.`))return e.name.toLowerCase().endsWith(t.toLowerCase());if(t.includes(`/*`)){let[n]=t.split(`/`);return e.type.startsWith(n+`/`)}else return e.type===t})}function og({attachments:e,onRemoveAttachment:t,className:n=``}){return e.length===0?null:(0,T.jsx)(`div`,{className:`flex flex-wrap gap-2 p-2 bg-muted rounded-lg ${n}`,children:e.map(e=>(0,T.jsx)(sg,{attachment:e,onRemove:()=>t(e.id)},e.id))})}function sg({attachment:e,onRemove:t}){let[n,r]=(0,y.useState)(!1);return(0,T.jsxs)(`div`,{className:`relative w-16 h-16 rounded border overflow-hidden group cursor-pointer`,onMouseEnter:()=>r(!0),onMouseLeave:()=>r(!1),title:e.file.name,children:[(()=>{switch(e.type){case`image`:return e.preview?(0,T.jsx)(`img`,{src:e.preview,alt:e.file.name,className:`w-full h-full object-cover`}):(0,T.jsx)(`div`,{className:`flex items-center justify-center w-full h-full bg-gray-200`,children:(0,T.jsx)(ql,{className:`h-6 w-6 text-gray-400`})});case`pdf`:return(0,T.jsxs)(`div`,{className:`flex flex-col items-center justify-center w-full h-full bg-red-50`,children:[(0,T.jsx)(Hl,{className:`h-6 w-6 text-red-500 mb-1`}),(0,T.jsx)(`span`,{className:`text-xs text-red-600`,children:`PDF`})]});case`audio`:return(0,T.jsxs)(`div`,{className:`flex flex-col items-center justify-center w-full h-full bg-purple-50`,children:[(0,T.jsx)(iu,{className:`h-6 w-6 text-purple-500 mb-1`}),(0,T.jsx)(`span`,{className:`text-xs text-purple-600`,children:`AUDIO`})]});default:return(0,T.jsxs)(`div`,{className:`flex flex-col items-center justify-center w-full h-full bg-gray-100`,children:[(0,T.jsx)(Hl,{className:`h-6 w-6 text-gray-500 mb-1`}),(0,T.jsx)(`span`,{className:`text-xs text-gray-600`,children:`FILE`})]})}})(),(0,T.jsx)(`div`,{className:`absolute inset-0 bg-black/60 flex items-center justify-center transition-all duration-200 ease-in-out ${n?`opacity-100 backdrop-blur-sm`:`opacity-0 pointer-events-none`}`,onClick:t,children:(0,T.jsx)(`div`,{className:`transition-all duration-200 ease-in-out ${n?`scale-100 opacity-100`:`scale-75 opacity-0`}`,children:(0,T.jsx)(xu,{className:`h-5 w-5 text-white drop-shadow-lg`})})}),(0,T.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity duration-200`,children:e.file.name})]})}function cg({onSubmit:e,isSubmitting:t=!1,isStreaming:n=!1,onCancel:r,isCancelling:i=!1,placeholder:a,showFileUpload:o=!0,maxAttachments:s=10,className:c=``,disabled:l=!1,entityName:u=`assistant`,externalFiles:d,onExternalFilesProcessed:f}){let[p,m]=(0,y.useState)(``),[h,g]=(0,y.useState)([]),[_,v]=(0,y.useState)(!1),[b,x]=(0,y.useState)(null),S=(0,y.useRef)(null);(0,y.useEffect)(()=>{d&&d.length>0&&(D(d),f?.())},[d,f]);let C=e=>e.type.startsWith(`image/`)?`image`:e.type===`application/pdf`?`pdf`:e.type.startsWith(`audio/`)?`audio`:`other`,w=e=>new Promise((t,n)=>{let r=new FileReader;r.onload=()=>t(r.result),r.onerror=n,r.readAsDataURL(e)}),E=e=>{let t=e.trim(),n=t.split(` -`);if(/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(t))return`.json`;if(/^<\?xml|^1)return`.tsv`;if(n.length>2){let e=n.filter(e=>e.includes(`,`)),t=n.filter(e=>e.includes(`;`));if(e.length>n.length*.5&&e.reduce((e,t)=>e+(t.match(/,/g)||[]).length,0)/e.length>=2||t.length>n.length*.5&&t.reduce((e,t)=>e+(t.match(/;/g)||[]).length,0)/t.length>=2)return`.csv`}return`.txt`},D=(0,y.useCallback)(async e=>{if(h.length+e.length>s){console.warn(`Cannot add more than ${s} attachments`);return}let t=[];for(let n of e){let e={id:`${Date.now()}-${Math.random()}`,file:n,type:C(n)};if(n.type.startsWith(`image/`))try{e.preview=await w(n)}catch(e){console.error(`Failed to generate preview:`,e)}t.push(e)}g(e=>[...e,...t])},[h.length,s]),O=e=>{g(t=>t.filter(t=>t.id!==e))},k=e=>{e.preventDefault(),e.stopPropagation(),v(!0)},A=e=>{e.preventDefault(),e.stopPropagation(),v(!1)},j=async e=>{e.preventDefault(),e.stopPropagation(),v(!1);let t=Array.from(e.dataTransfer.files);t.length>0&&await D(t)},M=async e=>{let t=Array.from(e.clipboardData.items),n=[],r=!1;for(let i of t)if(i.type.startsWith(`image/`)){e.preventDefault();let t=i.getAsFile();if(t){let e=Date.now();n.push(new File([t],`screenshot-${e}.png`,{type:t.type}))}}else i.type===`text/plain`&&!r&&(r=!0,e.preventDefault(),await new Promise(e=>{i.getAsString(t=>{let r=(t.match(/\n/g)||[]).length;if(t.length>1e4||r>50||/^\s*[{[][\s\S]*[}\]]\s*$/.test(t)||/^<\?xml|^{e.selectionStart=e.selectionEnd=n+t.length,e.focus()},0)}}e()})}));n.length>0&&(await D(n),x(n.length===1?n[0].name.includes(`screenshot`)?`Screenshot added as attachment`:`Large text converted to file`:`${n.length} files added`),setTimeout(()=>x(null),3e3))},N=async n=>{if(n.preventDefault(),!p.trim()&&h.length===0||t||l)return;let r=p.trim(),i=[];r&&i.push({text:r,type:`input_text`});for(let e of h){let t=await w(e.file);if(e.file.type.startsWith(`image/`))i.push({detail:`auto`,type:`input_image`,image_url:t});else if(e.file.type===`text/plain`&&(e.file.name.includes(`pasted-text-`)||e.file.name.endsWith(`.txt`)||e.file.name.endsWith(`.csv`)||e.file.name.endsWith(`.json`)||e.file.name.endsWith(`.html`)||e.file.name.endsWith(`.md`)||e.file.name.endsWith(`.tsv`))){let t=await e.file.text();i.push({text:t,type:`input_text`})}else{let n=t.split(`,`)[1];i.push({type:`input_file`,file_data:n,file_url:t,filename:e.file.name})}}await e(i),m(``),g([])},P=!l&&!t&&!n&&(p.trim()||h.length>0);return(0,T.jsxs)(`div`,{className:`relative ${c}`,onDragOver:k,onDragLeave:A,onDrop:j,children:[_&&(0,T.jsx)(`div`,{className:`absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10`,children:(0,T.jsxs)(`div`,{className:`text-center`,children:[(0,T.jsx)(`div`,{className:`text-blue-600 dark:text-blue-400 text-sm font-medium mb-1`,children:`Drop files here`}),(0,T.jsx)(`div`,{className:`text-blue-500 dark:text-blue-500 text-xs`,children:`Images, PDFs, and other files`})]})}),h.length>0&&(0,T.jsx)(`div`,{className:`mb-3`,children:(0,T.jsx)(og,{attachments:h,onRemoveAttachment:O})}),b&&(0,T.jsxs)(`div`,{className:`absolute bottom-24 left-1/2 -translate-x-1/2 z-20 +asyncio.run(main())`})]})]}),(0,T.jsxs)(`div`,{className:`flex gap-2 pt-4 border-t`,children:[(0,T.jsx)(K,{variant:`outline`,size:`sm`,asChild:!0,children:(0,T.jsxs)(`a`,{href:e.url,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(Vl,{className:`h-4 w-4 mr-2`}),`View Source`]})}),(0,T.jsx)(K,{variant:`outline`,size:`sm`,asChild:!0,children:(0,T.jsxs)(`a`,{href:`https://github.com/microsoft/agent-framework#readme`,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(xl,{className:`h-4 w-4 mr-2`}),`Documentation`]})})]})]})})})]})})}function rg({sample:e}){let[t,n]=(0,y.useState)(!1);return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(Uh,{className:`hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full`,children:[(0,T.jsxs)(Wh,{className:`pb-3 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-start justify-between mb-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(e.type===`workflow`?Tu:Sl,{className:`h-5 w-5`}),(0,T.jsx)(ct,{variant:`secondary`,className:`text-xs`,children:e.type})]}),(0,T.jsx)(ct,{variant:`outline`,className:at(`text-xs border`,Xh(e.difficulty)),children:e.difficulty})]}),(0,T.jsx)(Gh,{className:`text-lg leading-tight`,children:e.name}),(0,T.jsx)(Kh,{className:`text-sm line-clamp-3`,children:e.description})]}),(0,T.jsx)(qh,{className:`pt-0 flex-1 min-w-0 overflow-hidden`,children:(0,T.jsxs)(`div`,{className:`space-y-3 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`flex flex-wrap gap-1`,children:[e.tags.slice(0,3).map(e=>(0,T.jsx)(ct,{variant:`outline`,className:`text-xs`,children:e},e)),e.tags.length>3&&(0,T.jsxs)(ct,{variant:`outline`,className:`text-xs`,children:[`+`,e.tags.length-3]})]}),e.requiredEnvVars&&e.requiredEnvVars.length>0&&(0,T.jsxs)(`details`,{className:`group min-w-0 max-w-full overflow-hidden`,children:[(0,T.jsxs)(`summary`,{className:`cursor-pointer list-none p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md hover:bg-amber-100 dark:hover:bg-amber-950/30 transition-colors flex items-center justify-between gap-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 min-w-0`,children:[(0,T.jsx)(Yl,{className:`h-3.5 w-3.5 text-amber-600 dark:text-amber-500 flex-shrink-0`}),(0,T.jsxs)(`span`,{className:`text-xs font-medium text-amber-900 dark:text-amber-100 truncate`,children:[`Requires `,e.requiredEnvVars.length,` env var`,e.requiredEnvVars.length>1?`s`:``]})]}),(0,T.jsx)(X,{className:`h-3 w-3 text-amber-600 dark:text-amber-500 flex-shrink-0 group-open:rotate-180 transition-transform`})]}),(0,T.jsx)(`div`,{className:`mt-2 p-2 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-md space-y-2 min-w-0 max-w-full overflow-hidden`,children:e.requiredEnvVars.map(e=>(0,T.jsxs)(`div`,{className:`text-xs min-w-0 max-w-full overflow-hidden`,children:[(0,T.jsx)(`div`,{className:`font-mono font-medium text-amber-900 dark:text-amber-100 break-words`,children:e.name}),(0,T.jsx)(`div`,{className:`text-amber-700 dark:text-amber-300 mt-0.5 break-words`,children:e.description}),e.example&&(0,T.jsx)(`div`,{className:`font-mono text-amber-600 dark:text-amber-400 mt-0.5 break-all`,children:e.example})]},e.name))})]}),(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Key Features:`}),(0,T.jsx)(`ul`,{className:`text-xs space-y-1`,children:e.features.slice(0,3).map(e=>(0,T.jsxs)(`li`,{className:`flex items-center gap-1`,children:[(0,T.jsx)(`div`,{className:`w-1 h-1 rounded-full bg-current opacity-50`}),(0,T.jsx)(`span`,{children:e})]},e))})]})]})}),(0,T.jsxs)(Jh,{className:`pt-3 flex-col gap-3`,children:[(0,T.jsx)(`div`,{className:`w-full flex items-center justify-between text-xs text-muted-foreground`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-1`,children:[(0,T.jsx)(wu,{className:`h-3 w-3`}),(0,T.jsx)(`span`,{children:e.author})]})}),(0,T.jsxs)(`div`,{className:`w-full flex gap-2`,children:[(0,T.jsx)(K,{asChild:!0,className:`flex-1`,size:`sm`,children:(0,T.jsxs)(`a`,{href:e.url,download:`${e.id}.py`,target:`_blank`,rel:`noopener noreferrer`,children:[(0,T.jsx)(zl,{className:`h-4 w-4 mr-2`}),`Download`]})}),(0,T.jsxs)(K,{variant:`outline`,className:`flex-1`,size:`sm`,onClick:()=>n(!0),children:[(0,T.jsx)(xl,{className:`h-4 w-4 mr-2`}),`Setup Guide`]})]})]})]}),(0,T.jsx)(ng,{sample:e,open:t,onOpenChange:n})]})}function ig({samples:e}){return(0,T.jsx)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4`,children:e.map(e=>(0,T.jsx)(`div`,{className:`min-w-0`,children:(0,T.jsx)(rg,{sample:e})},e.id))})}function ag({onClose:e,variant:t=`inline`,hasExistingEntities:n=!1}){return t===`inline`?(0,T.jsx)(`div`,{className:`flex-1 overflow-auto`,children:(0,T.jsxs)(`div`,{className:`max-w-7xl mx-auto px-6 py-8`,children:[(0,T.jsx)(`div`,{className:`mb-8 p-4 bg-muted/50 border border-border rounded-lg`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,T.jsx)(Su,{className:`h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5`}),(0,T.jsxs)(`div`,{className:`flex-1`,children:[(0,T.jsx)(`h3`,{className:`font-semibold mb-1`,children:`No agents or workflows configured yet!`}),(0,T.jsxs)(`p`,{className:`text-sm text-muted-foreground mb-2`,children:[`You can configure agents or workflows by running`,` `,(0,T.jsx)(`code`,{className:`px-1.5 py-0.5 bg-background rounded text-xs`,children:`devui`}),` `,`in a directory containing them.`]}),(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Browse the sample agents and workflows below. Download them, review the code, and run them locally to get started quickly.`})]})]})}),(0,T.jsxs)(`div`,{className:`mb-6`,children:[(0,T.jsx)(`h3`,{className:`text-lg font-semibold mb-4`,children:`Sample Gallery`}),(0,T.jsx)(ig,{samples:Yh})]}),(0,T.jsx)(`div`,{className:`text-center mt-12 pt-8 border-t`,children:(0,T.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Want to create your own agents or workflows? Check out the`,` `,(0,T.jsx)(`a`,{href:`https://github.com/microsoft/agent-framework`,className:`text-primary hover:underline`,target:`_blank`,rel:`noopener noreferrer`,children:`documentation`})]})})]})}):t===`route`?(0,T.jsx)(`div`,{className:`h-full overflow-auto`,children:(0,T.jsxs)(`div`,{className:`max-w-7xl mx-auto px-6 py-8`,children:[(0,T.jsxs)(`div`,{className:`mb-8`,children:[n&&(0,T.jsx)(`div`,{className:`mb-4`,children:(0,T.jsxs)(K,{variant:`ghost`,onClick:e,className:`gap-2`,children:[(0,T.jsx)(bl,{className:`h-4 w-4`}),`Back`]})}),(0,T.jsxs)(`div`,{className:`text-center`,children:[(0,T.jsx)(`h2`,{className:`text-2xl font-semibold mb-2`,children:`Sample Gallery`}),(0,T.jsx)(`p`,{className:`text-muted-foreground max-w-2xl mx-auto`,children:`Browse sample agents and workflows to learn the Agent Framework. Download these curated examples and run them locally. Examples range from beginner to advanced.`})]})]}),(0,T.jsx)(ig,{samples:Yh}),(0,T.jsx)(`div`,{className:`text-center mt-12 pt-8 border-t`,children:(0,T.jsxs)(`p`,{className:`text-sm text-muted-foreground`,children:[`Want to create your own agents or workflows? Check out the`,` `,(0,T.jsx)(`a`,{href:`https://github.com/microsoft/agent-framework`,className:`text-primary hover:underline`,target:`_blank`,rel:`noopener noreferrer`,children:`documentation`})]})})]})}):(0,T.jsx)(ig,{samples:Yh})}function og({className:e,...t}){return(0,T.jsx)(`textarea`,{"data-slot":`textarea`,className:at(`border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm`,e),...t})}function sg({onFilesSelected:e,accept:t=`image/*,.pdf,audio/*,.wav,.mp3,.m4a,.ogg`,multiple:n=!0,maxSize:r=50*1024*1024,disabled:i=!1,className:a=``}){let o=(0,y.useRef)(null),s=n=>{if(!n||n.length===0)return;let i=[],a=[];Array.from(n).forEach(e=>{if(e.size>r){a.push(`${e.name} is too large (max ${cg(r)})`);return}if(t&&!lg(e,t)){a.push(`${e.name} is not an accepted file type`);return}i.push(e)}),a.length>0&&console.warn(`File upload errors:`,a),i.length>0&&e(i)};return(0,T.jsxs)(`div`,{className:a,children:[(0,T.jsx)(`input`,{ref:o,type:`file`,accept:t,multiple:n,onChange:e=>{s(e.target.files),e.target.value=``},className:`hidden`,disabled:i}),(0,T.jsx)(K,{type:`button`,variant:`outline`,size:`icon`,onClick:()=>{o.current&&o.current.click()},disabled:i,onDrop:e=>{if(e.preventDefault(),e.stopPropagation(),i)return;let t=e.dataTransfer.files;s(t)},onDragOver:e=>{e.preventDefault(),e.stopPropagation()},className:`shrink-0 transition-colors hover:bg-muted`,title:`Upload files (images, PDFs, audio)`,children:(0,T.jsx)(Cu,{className:`h-4 w-4`})})]})}function cg(e){if(e===0)return`0 Bytes`;let t=1024,n=[`Bytes`,`KB`,`MB`,`GB`],r=Math.floor(Math.log(e)/Math.log(t));return parseFloat((e/t**r).toFixed(2))+` `+n[r]}function lg(e,t){return t.split(`,`).map(e=>e.trim()).some(t=>{if(t.startsWith(`.`))return e.name.toLowerCase().endsWith(t.toLowerCase());if(t.includes(`/*`)){let[n]=t.split(`/`);return e.type.startsWith(n+`/`)}else return e.type===t})}function ug({attachments:e,onRemoveAttachment:t,className:n=``}){return e.length===0?null:(0,T.jsx)(`div`,{className:`flex flex-wrap gap-2 p-2 bg-muted rounded-lg ${n}`,children:e.map(e=>(0,T.jsx)(dg,{attachment:e,onRemove:()=>t(e.id)},e.id))})}function dg({attachment:e,onRemove:t}){let[n,r]=(0,y.useState)(!1);return(0,T.jsxs)(`div`,{className:`relative w-16 h-16 rounded border overflow-hidden group cursor-pointer`,onMouseEnter:()=>r(!0),onMouseLeave:()=>r(!1),title:e.file.name,children:[(()=>{switch(e.type){case`image`:return e.preview?(0,T.jsx)(`img`,{src:e.preview,alt:e.file.name,className:`w-full h-full object-cover`}):(0,T.jsx)(`div`,{className:`flex items-center justify-center w-full h-full bg-gray-200`,children:(0,T.jsx)(ql,{className:`h-6 w-6 text-gray-400`})});case`pdf`:return(0,T.jsxs)(`div`,{className:`flex flex-col items-center justify-center w-full h-full bg-red-50`,children:[(0,T.jsx)(Hl,{className:`h-6 w-6 text-red-500 mb-1`}),(0,T.jsx)(`span`,{className:`text-xs text-red-600`,children:`PDF`})]});case`audio`:return(0,T.jsxs)(`div`,{className:`flex flex-col items-center justify-center w-full h-full bg-purple-50`,children:[(0,T.jsx)(iu,{className:`h-6 w-6 text-purple-500 mb-1`}),(0,T.jsx)(`span`,{className:`text-xs text-purple-600`,children:`AUDIO`})]});default:return(0,T.jsxs)(`div`,{className:`flex flex-col items-center justify-center w-full h-full bg-gray-100`,children:[(0,T.jsx)(Hl,{className:`h-6 w-6 text-gray-500 mb-1`}),(0,T.jsx)(`span`,{className:`text-xs text-gray-600`,children:`FILE`})]})}})(),(0,T.jsx)(`div`,{className:`absolute inset-0 bg-black/60 flex items-center justify-center transition-all duration-200 ease-in-out ${n?`opacity-100 backdrop-blur-sm`:`opacity-0 pointer-events-none`}`,onClick:t,children:(0,T.jsx)(`div`,{className:`transition-all duration-200 ease-in-out ${n?`scale-100 opacity-100`:`scale-75 opacity-0`}`,children:(0,T.jsx)(xu,{className:`h-5 w-5 text-white drop-shadow-lg`})})}),(0,T.jsx)(`div`,{className:`absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white text-xs p-1 truncate opacity-0 group-hover:opacity-100 transition-opacity duration-200`,children:e.file.name})]})}function fg({onSubmit:e,isSubmitting:t=!1,isStreaming:n=!1,onCancel:r,isCancelling:i=!1,placeholder:a,showFileUpload:o=!0,maxAttachments:s=10,className:c=``,disabled:l=!1,entityName:u=`assistant`,externalFiles:d,onExternalFilesProcessed:f}){let[p,m]=(0,y.useState)(``),[h,g]=(0,y.useState)([]),[_,v]=(0,y.useState)(!1),[b,x]=(0,y.useState)(null),S=(0,y.useRef)(null);(0,y.useEffect)(()=>{d&&d.length>0&&(D(d),f?.())},[d,f]);let C=e=>e.type.startsWith(`image/`)?`image`:e.type===`application/pdf`?`pdf`:e.type.startsWith(`audio/`)?`audio`:`other`,w=e=>new Promise((t,n)=>{let r=new FileReader;r.onload=()=>t(r.result),r.onerror=n,r.readAsDataURL(e)}),E=e=>{let t=e.trim(),n=t.split(` +`);if(/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(t))return`.json`;if(/^<\?xml|^1)return`.tsv`;if(n.length>2){let e=n.filter(e=>e.includes(`,`)),t=n.filter(e=>e.includes(`;`));if(e.length>n.length*.5&&e.reduce((e,t)=>e+(t.match(/,/g)||[]).length,0)/e.length>=2||t.length>n.length*.5&&t.reduce((e,t)=>e+(t.match(/;/g)||[]).length,0)/t.length>=2)return`.csv`}return`.txt`},D=(0,y.useCallback)(async e=>{if(h.length+e.length>s){console.warn(`Cannot add more than ${s} attachments`);return}let t=[];for(let n of e){let e={id:`${Date.now()}-${Math.random()}`,file:n,type:C(n)};if(n.type.startsWith(`image/`))try{e.preview=await w(n)}catch(e){console.error(`Failed to generate preview:`,e)}t.push(e)}g(e=>[...e,...t])},[h.length,s]),O=e=>{g(t=>t.filter(t=>t.id!==e))},k=e=>{e.preventDefault(),e.stopPropagation(),v(!0)},A=e=>{e.preventDefault(),e.stopPropagation(),v(!1)},j=async e=>{e.preventDefault(),e.stopPropagation(),v(!1);let t=Array.from(e.dataTransfer.files);t.length>0&&await D(t)},M=async e=>{let t=Array.from(e.clipboardData.items),n=[],r=!1;for(let i of t)if(i.type.startsWith(`image/`)){e.preventDefault();let t=i.getAsFile();if(t){let e=Date.now();n.push(new File([t],`screenshot-${e}.png`,{type:t.type}))}}else i.type===`text/plain`&&!r&&(r=!0,e.preventDefault(),await new Promise(e=>{i.getAsString(t=>{let r=(t.match(/\n/g)||[]).length;if(t.length>1e4||r>50||/^\s*[{[][\s\S]*[}\]]\s*$/.test(t)||/^<\?xml|^{e.selectionStart=e.selectionEnd=n+t.length,e.focus()},0)}}e()})}));n.length>0&&(await D(n),x(n.length===1?n[0].name.includes(`screenshot`)?`Screenshot added as attachment`:`Large text converted to file`:`${n.length} files added`),setTimeout(()=>x(null),3e3))},N=async n=>{if(n.preventDefault(),!p.trim()&&h.length===0||t||l)return;let r=p.trim(),i=[];r&&i.push({text:r,type:`input_text`});for(let e of h){let t=await w(e.file);if(e.file.type.startsWith(`image/`))i.push({detail:`auto`,type:`input_image`,image_url:t});else if(e.file.type===`text/plain`&&(e.file.name.includes(`pasted-text-`)||e.file.name.endsWith(`.txt`)||e.file.name.endsWith(`.csv`)||e.file.name.endsWith(`.json`)||e.file.name.endsWith(`.html`)||e.file.name.endsWith(`.md`)||e.file.name.endsWith(`.tsv`))){let t=await e.file.text();i.push({text:t,type:`input_text`})}else{let n=t.split(`,`)[1];i.push({type:`input_file`,file_data:n,file_url:t,filename:e.file.name})}}await e(i),m(``),g([])},P=!l&&!t&&!n&&(p.trim()||h.length>0);return(0,T.jsxs)(`div`,{className:`relative ${c}`,onDragOver:k,onDragLeave:A,onDrop:j,children:[_&&(0,T.jsx)(`div`,{className:`absolute inset-2 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg bg-blue-50/80 dark:bg-blue-950/40 backdrop-blur-sm flex items-center justify-center transition-all duration-200 ease-in-out z-10`,children:(0,T.jsxs)(`div`,{className:`text-center`,children:[(0,T.jsx)(`div`,{className:`text-blue-600 dark:text-blue-400 text-sm font-medium mb-1`,children:`Drop files here`}),(0,T.jsx)(`div`,{className:`text-blue-500 dark:text-blue-500 text-xs`,children:`Images, PDFs, and other files`})]})}),h.length>0&&(0,T.jsx)(`div`,{className:`mb-3`,children:(0,T.jsx)(ug,{attachments:h,onRemoveAttachment:O})}),b&&(0,T.jsxs)(`div`,{className:`absolute bottom-24 left-1/2 -translate-x-1/2 z-20 bg-blue-500 text-white px-4 py-2 rounded-full text-sm animate-in slide-in-from-bottom-2 fade-in duration-200 - flex items-center gap-2 shadow-lg`,children:[b.includes(`screenshot`)?(0,T.jsx)(su,{className:`h-3 w-3`}):(0,T.jsx)(Hl,{className:`h-3 w-3`}),b]}),(0,T.jsxs)(`form`,{onSubmit:N,className:`flex gap-2 items-end`,children:[(0,T.jsx)(ng,{ref:S,value:p,onChange:e=>m(e.target.value),onPaste:M,onKeyDown:e=>{e.key===`Enter`&&!e.shiftKey&&(e.preventDefault(),N(e))},placeholder:a||`Message ${u}... (Shift+Enter for new line)`,disabled:l||t||n,className:`flex-1 min-h-[40px] max-h-[200px] resize-none`,style:{fieldSizing:`content`}}),o&&(0,T.jsx)(rg,{onFilesSelected:D,disabled:l||t||n}),n&&r?(0,T.jsx)(K,{type:`button`,size:`icon`,onClick:r,disabled:i,className:`shrink-0 h-10 transition-all`,title:`Stop generating`,"aria-label":`Stop generating response`,children:i?(0,T.jsx)(Fu,{size:`sm`}):(0,T.jsx)(yu,{className:`h-4 w-4 fill-current`})}):(0,T.jsx)(K,{type:`submit`,size:`icon`,disabled:!P,className:`shrink-0 h-10 transition-all`,title:`Send message`,"aria-label":`Send message`,children:t?(0,T.jsx)(Fu,{size:`sm`}):(0,T.jsx)(mu,{className:`h-4 w-4`})})]})]})}function lg({code:e,language:t}){let[n,r]=(0,y.useState)(!1),i=(0,y.useRef)(null);return(0,y.useEffect)(()=>()=>{i.current&&clearTimeout(i.current)},[]),(0,T.jsxs)(`div`,{className:`relative group`,children:[(0,T.jsx)(`pre`,{className:`my-3 p-3 bg-foreground/5 dark:bg-foreground/10 rounded overflow-x-auto border border-foreground/10`,children:(0,T.jsxs)(`code`,{className:`text-xs font-mono block whitespace-pre-wrap break-words`,children:[t&&(0,T.jsx)(`span`,{className:`opacity-60 text-[10px] mb-1 block uppercase`,children:t}),e]})}),(0,T.jsx)(`button`,{onClick:async()=>{try{await navigator.clipboard.writeText(e),r(!0),i.current&&clearTimeout(i.current),i.current=setTimeout(()=>{r(!1),i.current=null},2e3)}catch(e){console.error(`Failed to copy code:`,e)}},className:`absolute top-2 right-2 p-1.5 rounded-md border shadow-sm + flex items-center gap-2 shadow-lg`,children:[b.includes(`screenshot`)?(0,T.jsx)(su,{className:`h-3 w-3`}):(0,T.jsx)(Hl,{className:`h-3 w-3`}),b]}),(0,T.jsxs)(`form`,{onSubmit:N,className:`flex gap-2 items-end`,children:[(0,T.jsx)(og,{ref:S,value:p,onChange:e=>m(e.target.value),onPaste:M,onKeyDown:e=>{e.key===`Enter`&&!e.shiftKey&&(e.preventDefault(),N(e))},placeholder:a||`Message ${u}... (Shift+Enter for new line)`,disabled:l||t||n,className:`flex-1 min-h-[40px] max-h-[200px] resize-none`,style:{fieldSizing:`content`}}),o&&(0,T.jsx)(sg,{onFilesSelected:D,disabled:l||t||n}),n&&r?(0,T.jsx)(K,{type:`button`,size:`icon`,onClick:r,disabled:i,className:`shrink-0 h-10 transition-all`,title:`Stop generating`,"aria-label":`Stop generating response`,children:i?(0,T.jsx)(Fu,{size:`sm`}):(0,T.jsx)(yu,{className:`h-4 w-4 fill-current`})}):(0,T.jsx)(K,{type:`submit`,size:`icon`,disabled:!P,className:`shrink-0 h-10 transition-all`,title:`Send message`,"aria-label":`Send message`,children:t?(0,T.jsx)(Fu,{size:`sm`}):(0,T.jsx)(mu,{className:`h-4 w-4`})})]})]})}function pg({code:e,language:t}){let[n,r]=(0,y.useState)(!1),i=(0,y.useRef)(null);return(0,y.useEffect)(()=>()=>{i.current&&clearTimeout(i.current)},[]),(0,T.jsxs)(`div`,{className:`relative group`,children:[(0,T.jsx)(`pre`,{className:`my-3 p-3 bg-foreground/5 dark:bg-foreground/10 rounded overflow-x-auto border border-foreground/10`,children:(0,T.jsxs)(`code`,{className:`text-xs font-mono block whitespace-pre-wrap break-words`,children:[t&&(0,T.jsx)(`span`,{className:`opacity-60 text-[10px] mb-1 block uppercase`,children:t}),e]})}),(0,T.jsx)(`button`,{onClick:async()=>{try{await navigator.clipboard.writeText(e),r(!0),i.current&&clearTimeout(i.current),i.current=setTimeout(()=>{r(!1),i.current=null},2e3)}catch(e){console.error(`Failed to copy code:`,e)}},className:`absolute top-2 right-2 p-1.5 rounded-md border shadow-sm bg-background hover:bg-accent text-muted-foreground hover:text-foreground transition-all duration-200 - opacity-0 group-hover:opacity-100`,title:n?`Copied!`:`Copy code`,children:n?(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,width:`14`,height:`14`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,className:`text-green-600 dark:text-green-400`,children:(0,T.jsx)(`polyline`,{points:`20 6 9 17 4 12`})}):(0,T.jsxs)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,width:`14`,height:`14`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,T.jsx)(`rect`,{x:`9`,y:`9`,width:`13`,height:`13`,rx:`2`,ry:`2`}),(0,T.jsx)(`path`,{d:`M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1`})]})})]})}function ug({content:e,className:t=``}){let n=e.split(` -`),r=[],i=0;for(;i(0,T.jsx)(`li`,{className:`text-sm break-words`,children:dg(e)},t))},r.length));continue}if(e.match(/^[\s]*\d+\.\s+/)){let e=[];for(;i(0,T.jsx)(`li`,{className:`text-sm break-words`,children:dg(e)},t))},r.length));continue}if(e.trim().startsWith(`|`)&&e.trim().endsWith(`|`)){let e=[];for(;i=2){let t=e[0].split(`|`).slice(1,-1).map(e=>e.trim());if(e[1].match(/^\|[\s\-:|]+\|$/)){let n=e.slice(2).map(e=>e.split(`|`).slice(1,-1).map(e=>e.trim()));r.push((0,T.jsx)(`div`,{className:`my-3 overflow-x-auto`,children:(0,T.jsxs)(`table`,{className:`min-w-full border border-foreground/10 text-sm`,children:[(0,T.jsx)(`thead`,{className:`bg-foreground/5`,children:(0,T.jsx)(`tr`,{children:t.map((e,t)=>(0,T.jsx)(`th`,{className:`border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words`,children:dg(e)},t))})}),(0,T.jsx)(`tbody`,{children:n.map((e,t)=>(0,T.jsx)(`tr`,{className:`border-b border-foreground/5 last:border-b-0`,children:e.map((e,t)=>(0,T.jsx)(`td`,{className:`px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words`,children:dg(e)},t))},t))})]})},r.length));continue}}for(let t of e)r.push((0,T.jsx)(`p`,{className:`my-1`,children:dg(t)},r.length));continue}if(e.trim().startsWith(`>`)){let e=[];for(;i`);)e.push(n[i].replace(/^>\s?/,``)),i++;r.push((0,T.jsx)(`blockquote`,{className:`my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words`,children:e.map((e,t)=>(0,T.jsx)(`div`,{className:`break-words`,children:dg(e)},t))},r.length));continue}if(e.match(/^[\s]*[-*_]{3,}[\s]*$/)){r.push((0,T.jsx)(`hr`,{className:`my-4 border-t border-border`},r.length)),i++;continue}if(e.trim()===``){r.push((0,T.jsx)(`div`,{className:`h-2`},r.length)),i++;continue}r.push((0,T.jsx)(`p`,{className:`my-1 break-words`,children:dg(e)},r.length)),i++}return(0,T.jsx)(`div`,{className:`markdown-content break-words ${t}`,children:r})}function dg(e){let t=[],n=e,r=0;for(;n.length>0;){let e=n.match(/`([^`]+)`/);if(e&&e.index!==void 0){e.index>0&&t.push((0,T.jsx)(`span`,{children:fg(n.slice(0,e.index))},r++)),t.push((0,T.jsx)(`code`,{className:`px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20`,children:e[1]},r++)),n=n.slice(e.index+e[0].length);continue}t.push((0,T.jsx)(`span`,{children:fg(n)},r++));break}return t}function fg(e){let t=[],n=e,r=0;for(;n.length>0;){let e=[{regex:/\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/,component:`strong-link`},{regex:/__\[([^\]]+)\]\(([^)]+)\)__/,component:`strong-link`},{regex:/\*\[([^\]]+)\]\(([^)]+)\)\*/,component:`em-link`},{regex:/_\[([^\]]+)\]\(([^)]+)\)_/,component:`em-link`},{regex:/\[([^\]]+)\]\(([^)]+)\)/,component:`link`},{regex:/\*\*(.+?)\*\*/,component:`strong`},{regex:/__(.+?)__/,component:`strong`},{regex:/\*(.+?)\*/,component:`em`},{regex:/_(.+?)_/,component:`em`}],i=!1;for(let a of e){let e=n.match(a.regex);if(e&&e.index!==void 0){if(e.index>0&&t.push(n.slice(0,e.index)),a.component===`strong`)t.push((0,T.jsx)(`strong`,{className:`font-semibold`,children:e[1]},r++));else if(a.component===`em`)t.push((0,T.jsx)(`em`,{className:`italic`,children:e[1]},r++));else if(a.component===`strong-link`){let n=e[1],i=e[2],a=fg(n);t.push((0,T.jsx)(`strong`,{className:`font-semibold`,children:(0,T.jsx)(`a`,{href:i,target:`_blank`,rel:`noopener noreferrer`,className:`text-primary hover:underline break-words`,children:a})},r++))}else if(a.component===`em-link`){let n=e[1],i=e[2],a=fg(n);t.push((0,T.jsx)(`em`,{className:`italic`,children:(0,T.jsx)(`a`,{href:i,target:`_blank`,rel:`noopener noreferrer`,className:`text-primary hover:underline break-words`,children:a})},r++))}else if(a.component===`link`){let n=e[1],i=e[2],a=fg(n);t.push((0,T.jsx)(`a`,{href:i,target:`_blank`,rel:`noopener noreferrer`,className:`text-primary hover:underline break-words`,children:a},r++))}n=n.slice(e.index+e[0].length),i=!0;break}}if(!i){n.length>0&&t.push(n);break}}return t}function pg({content:e,className:t,isStreaming:n}){if(e.type!==`text`&&e.type!==`input_text`&&e.type!==`output_text`&&e.type!==`refusal`)return null;let r=e.type===`refusal`?e.refusal:e.text;return(0,T.jsxs)(`div`,{className:`break-words ${t||``}`,children:[(0,T.jsx)(ug,{content:r}),n&&r.length>0&&(0,T.jsx)(`span`,{className:`ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current`})]})}function mg({content:e,className:t}){let[n,r]=(0,y.useState)(!1),[i,a]=(0,y.useState)(!1);if(e.type!==`input_image`&&e.type!==`output_image`)return null;let o=e.image_url;return n?(0,T.jsx)(`div`,{className:`my-2 p-3 border rounded-lg bg-muted ${t||``}`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-sm text-muted-foreground`,children:[(0,T.jsx)(Hl,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{children:`Image could not be loaded`})]})}):(0,T.jsxs)(`div`,{className:`my-2 ${t||``}`,children:[(0,T.jsx)(`img`,{src:o,alt:`Uploaded image`,className:`rounded-lg border max-w-full transition-all cursor-pointer ${i?`max-h-none`:`max-h-64`}`,onClick:()=>a(!i),onError:()=>r(!0)}),i&&(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground mt-1`,children:`Click to collapse`})]})}function hg(e,t){let[n,r]=(0,y.useState)(null);return(0,y.useEffect)(()=>{if(!e){r(null);return}try{let n;if(e.startsWith(`data:`)){let t=e.split(`,`);if(t.length!==2){r(null);return}n=t[1]}else n=e;let i=atob(n),a=new Uint8Array(i.length);for(let e=0;e{URL.revokeObjectURL(s)}}catch(e){console.error(`Failed to convert base64 to blob URL:`,e),r(null)}},[e,t]),n}function gg({content:e,className:t}){let[n,r]=(0,y.useState)(!0),i=e.type===`input_file`||e.type===`output_file`,a=i?e.file_url||e.file_data:void 0,o=i?e.filename||`file`:void 0,s=o?.toLowerCase().endsWith(`.pdf`)||a?.includes(`application/pdf`),c=o?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/),l=hg(i&&s?e.file_data||e.file_url:void 0,`application/pdf`);if(!i)return null;let u=l||a;return s&&a?(0,T.jsxs)(`div`,{className:`my-2 ${t||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-2 px-1`,children:[(0,T.jsx)(Hl,{className:`h-4 w-4 text-red-500`}),(0,T.jsx)(`span`,{className:`text-sm font-medium truncate flex-1`,children:o}),(0,T.jsx)(`button`,{onClick:()=>r(!n),className:`text-xs text-muted-foreground hover:text-foreground flex items-center gap-1`,children:n?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(X,{className:`h-3 w-3`}),`Collapse`]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Z,{className:`h-3 w-3`}),`Expand`]})})]}),n&&(0,T.jsxs)(`div`,{className:`border rounded-lg p-6 bg-muted/50 flex flex-col items-center justify-center gap-4`,children:[(0,T.jsx)(Hl,{className:`h-16 w-16 text-red-400`}),(0,T.jsxs)(`div`,{className:`text-center`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium mb-1`,children:o}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`PDF Document`})]}),(0,T.jsxs)(`div`,{className:`flex gap-3`,children:[(0,T.jsx)(`button`,{onClick:()=>{u&&window.open(u,`_blank`)},className:`text-sm bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 px-4 py-2 rounded-md transition-colors`,children:`Open in new tab`}),(0,T.jsxs)(`a`,{href:u||a,download:o,className:`text-sm text-foreground hover:bg-accent flex items-center gap-2 px-4 py-2 border rounded-md transition-colors`,children:[(0,T.jsx)(zl,{className:`h-4 w-4`}),`Download`]})]})]})]}):c&&a?(0,T.jsxs)(`div`,{className:`my-2 p-3 border rounded-lg ${t||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-2`,children:[(0,T.jsx)(iu,{className:`h-4 w-4 text-muted-foreground`}),(0,T.jsx)(`span`,{className:`text-sm font-medium`,children:o})]}),(0,T.jsxs)(`audio`,{controls:!0,className:`w-full`,children:[(0,T.jsx)(`source`,{src:a}),`Your browser does not support audio playback.`]})]}):(0,T.jsx)(`div`,{className:`my-2 p-3 border rounded-lg bg-muted ${t||``}`,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Hl,{className:`h-4 w-4 text-muted-foreground`}),(0,T.jsx)(`span`,{className:`text-sm`,children:o})]}),a&&(0,T.jsxs)(`a`,{href:a,download:o,className:`text-xs text-primary hover:underline flex items-center gap-1`,children:[(0,T.jsx)(zl,{className:`h-3 w-3`}),`Download`]})]})})}function _g({content:e,className:t}){let[n,r]=(0,y.useState)(!1);if(e.type!==`output_data`)return null;let i=e.data,a=e.mime_type,o=e.description,s=i;try{let e=JSON.parse(i);s=JSON.stringify(e,null,2)}catch{}return(0,T.jsxs)(`div`,{className:`my-2 p-3 border rounded-lg bg-muted ${t||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 cursor-pointer`,onClick:()=>r(!n),children:[(0,T.jsx)(Hl,{className:`h-4 w-4 text-muted-foreground`}),(0,T.jsx)(`span`,{className:`text-sm font-medium`,children:o||`Data Output`}),(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground ml-auto`,children:a}),n?(0,T.jsx)(X,{className:`h-4 w-4 text-muted-foreground`}):(0,T.jsx)(Z,{className:`h-4 w-4 text-muted-foreground`})]}),n&&(0,T.jsx)(`pre`,{className:`mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono`,children:s})]})}function vg({content:e,className:t}){let[n,r]=(0,y.useState)(!1);if(e.type!==`function_approval_request`)return null;let{status:i,function_call:a}=e,o={pending:{icon:Nl,label:`Awaiting approval`,iconClass:`text-amber-600 dark:text-amber-400`},approved:{icon:Tl,label:`Approved`,iconClass:`text-green-600 dark:text-green-400`},rejected:{icon:Du,label:`Rejected`,iconClass:`text-red-600 dark:text-red-400`}}[i],s=o.icon,c;try{c=typeof a.arguments==`string`?JSON.parse(a.arguments):a.arguments}catch{c=a.arguments}return(0,T.jsxs)(`div`,{className:t,children:[(0,T.jsxs)(`button`,{onClick:()=>r(!n),className:`flex items-center gap-2 px-2 py-1 text-xs rounded hover:bg-muted/50 transition-colors w-fit`,children:[(0,T.jsx)(s,{className:`h-3 w-3 ${o.iconClass}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground font-mono`,children:a.name}),(0,T.jsx)(`span`,{className:`text-xs ${o.iconClass}`,children:o.label}),n?(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`▼`}):(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`▶`})]}),n&&(0,T.jsx)(`div`,{className:`ml-5 mt-1 text-xs font-mono text-muted-foreground border-l-2 border-muted pl-3`,children:(0,T.jsx)(`pre`,{className:`whitespace-pre-wrap break-all`,children:JSON.stringify(c,null,2)})})]})}function yg({content:e,className:t,isStreaming:n}){switch(e.type){case`text`:case`input_text`:case`output_text`:case`refusal`:return(0,T.jsx)(pg,{content:e,className:t,isStreaming:n});case`input_image`:case`output_image`:return(0,T.jsx)(mg,{content:e,className:t});case`input_file`:case`output_file`:return(0,T.jsx)(gg,{content:e,className:t});case`output_data`:return(0,T.jsx)(_g,{content:e,className:t});case`function_approval_request`:return(0,T.jsx)(vg,{content:e,className:t});default:return null}}function bg({name:e,arguments:t,className:n}){let[r,i]=(0,y.useState)(!1),a;try{a=typeof t==`string`?JSON.parse(t):t}catch{a=t}return(0,T.jsxs)(`div`,{className:`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${n||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 cursor-pointer`,onClick:()=>i(!r),children:[(0,T.jsx)(Fl,{className:`h-4 w-4 text-blue-600 dark:text-blue-400`}),(0,T.jsxs)(`span`,{className:`text-sm font-medium text-blue-800 dark:text-blue-300`,children:[`Function Call: `,e]}),r?(0,T.jsx)(X,{className:`h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto`}):(0,T.jsx)(Z,{className:`h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto`})]}),r&&(0,T.jsxs)(`div`,{className:`mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border`,children:[(0,T.jsx)(`div`,{className:`text-blue-600 dark:text-blue-400 mb-1`,children:`Arguments:`}),(0,T.jsx)(`pre`,{className:`whitespace-pre-wrap`,children:JSON.stringify(a,null,2)})]})]})}function xg({output:e,call_id:t,className:n}){let[r,i]=(0,y.useState)(!1),a;try{a=typeof e==`string`?JSON.parse(e):e}catch{a=e}return(0,T.jsxs)(`div`,{className:`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${n||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 cursor-pointer`,onClick:()=>i(!r),children:[(0,T.jsx)(Fl,{className:`h-4 w-4 text-green-600 dark:text-green-400`}),(0,T.jsx)(`span`,{className:`text-sm font-medium text-green-800 dark:text-green-300`,children:`Function Result`}),r?(0,T.jsx)(X,{className:`h-4 w-4 text-green-600 dark:text-green-400 ml-auto`}):(0,T.jsx)(Z,{className:`h-4 w-4 text-green-600 dark:text-green-400 ml-auto`})]}),r&&(0,T.jsxs)(`div`,{className:`mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border`,children:[(0,T.jsx)(`div`,{className:`text-green-600 dark:text-green-400 mb-1`,children:`Output:`}),(0,T.jsx)(`pre`,{className:`whitespace-pre-wrap`,children:JSON.stringify(a,null,2)}),(0,T.jsxs)(`div`,{className:`text-gray-500 text-[10px] mt-2`,children:[`Call ID: `,t]})]})]})}function Sg({item:e,className:t}){if(e.type===`message`){let n=e.status===`in_progress`,r=e.content.length>0;return(0,T.jsxs)(`div`,{className:t,children:[e.content.map((e,t)=>(0,T.jsx)(yg,{content:e,className:t>0?`mt-2`:``,isStreaming:n},t)),n&&!r&&(0,T.jsx)(`div`,{className:`flex items-center space-x-1`,children:(0,T.jsxs)(`div`,{className:`flex space-x-1`,children:[(0,T.jsx)(`div`,{className:`h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]`}),(0,T.jsx)(`div`,{className:`h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]`}),(0,T.jsx)(`div`,{className:`h-2 w-2 animate-bounce rounded-full bg-current`})]})})]})}return e.type===`function_call`?(0,T.jsx)(bg,{name:e.name,arguments:e.arguments,className:t}):e.type===`function_call_output`?(0,T.jsx)(xg,{output:e.output,call_id:e.call_id,className:t}):null}var Cg=[` `,`Enter`,`ArrowUp`,`ArrowDown`],wg=[` `,`Enter`],Tg=`Select`,[Eg,Dg,Og]=vt(Tg),[kg,Ag]=lt(Tg,[Og,Oi]),jg=Oi(),[Mg,Ng]=kg(Tg),[Pg,Fg]=kg(Tg),Ig=e=>{let{__scopeSelect:t,children:n,open:r,defaultOpen:i,onOpenChange:a,value:o,defaultValue:s,onValueChange:c,dir:l,name:u,autoComplete:d,disabled:f,required:p,form:m}=e,h=jg(t),[g,_]=y.useState(null),[v,b]=y.useState(null),[x,S]=y.useState(!1),C=bt(l),[w,E]=pt({prop:r,defaultProp:i??!1,onChange:a,caller:Tg}),[D,O]=pt({prop:o,defaultProp:s,onChange:c,caller:Tg}),k=y.useRef(null),A=g?m||!!g.closest(`form`):!0,[j,M]=y.useState(new Set),N=Array.from(j).map(e=>e.props.value).join(`;`);return(0,T.jsx)(Wi,{...h,children:(0,T.jsxs)(Mg,{required:p,scope:t,trigger:g,onTriggerChange:_,valueNode:v,onValueNodeChange:b,valueNodeHasChildren:x,onValueNodeHasChildrenChange:S,contentId:rn(),value:D,onValueChange:O,open:w,onOpenChange:E,dir:C,triggerPointerDownPosRef:k,disabled:f,children:[(0,T.jsx)(Eg.Provider,{scope:t,children:(0,T.jsx)(Pg,{scope:e.__scopeSelect,onNativeOptionAdd:y.useCallback(e=>{M(t=>new Set(t).add(e))},[]),onNativeOptionRemove:y.useCallback(e=>{M(t=>{let n=new Set(t);return n.delete(e),n})},[]),children:n})}),A?(0,T.jsxs)(j_,{"aria-hidden":!0,required:p,tabIndex:-1,name:u,autoComplete:d,value:D,onChange:e=>O(e.target.value),disabled:f,form:m,children:[D===void 0?(0,T.jsx)(`option`,{value:``}):null,Array.from(j)]},N):null]})})};Ig.displayName=Tg;var Lg=`SelectTrigger`,Rg=y.forwardRef((e,t)=>{let{__scopeSelect:n,disabled:r=!1,...i}=e,a=jg(n),o=Ng(Lg,n),s=o.disabled||r,c=C(t,o.onTriggerChange),l=Dg(n),u=y.useRef(`touch`),[d,f,p]=N_(e=>{let t=l().filter(e=>!e.disabled),n=P_(t,e,t.find(e=>e.value===o.value));n!==void 0&&o.onValueChange(n.value)}),m=e=>{s||(o.onOpenChange(!0),p()),e&&(o.triggerPointerDownPosRef.current={x:Math.round(e.pageX),y:Math.round(e.pageY)})};return(0,T.jsx)(Gi,{asChild:!0,...a,children:(0,T.jsx)(J.button,{type:`button`,role:`combobox`,"aria-controls":o.contentId,"aria-expanded":o.open,"aria-required":o.required,"aria-autocomplete":`none`,dir:o.dir,"data-state":o.open?`open`:`closed`,disabled:s,"data-disabled":s?``:void 0,"data-placeholder":M_(o.value)?``:void 0,...i,ref:c,onClick:q(i.onClick,e=>{e.currentTarget.focus(),u.current!==`mouse`&&m(e)}),onPointerDown:q(i.onPointerDown,e=>{u.current=e.pointerType;let t=e.target;t.hasPointerCapture(e.pointerId)&&t.releasePointerCapture(e.pointerId),e.button===0&&e.ctrlKey===!1&&e.pointerType===`mouse`&&(m(e),e.preventDefault())}),onKeyDown:q(i.onKeyDown,e=>{let t=d.current!==``;!(e.ctrlKey||e.altKey||e.metaKey)&&e.key.length===1&&f(e.key),!(t&&e.key===` `)&&Cg.includes(e.key)&&(m(),e.preventDefault())})})})});Rg.displayName=Lg;var zg=`SelectValue`,Bg=y.forwardRef((e,t)=>{let{__scopeSelect:n,className:r,style:i,children:a,placeholder:o=``,...s}=e,c=Ng(zg,n),{onValueNodeHasChildrenChange:l}=c,u=a!==void 0,d=C(t,c.onValueNodeChange);return dt(()=>{l(u)},[l,u]),(0,T.jsx)(J.span,{...s,ref:d,style:{pointerEvents:`none`},children:M_(c.value)?(0,T.jsx)(T.Fragment,{children:o}):a})});Bg.displayName=zg;var Vg=`SelectIcon`,Hg=y.forwardRef((e,t)=>{let{__scopeSelect:n,children:r,...i}=e;return(0,T.jsx)(J.span,{"aria-hidden":!0,...i,ref:t,children:r||`▼`})});Hg.displayName=Vg;var Ug=`SelectPortal`,Wg=e=>(0,T.jsx)(Yi,{asChild:!0,...e});Wg.displayName=Ug;var Gg=`SelectContent`,Kg=y.forwardRef((e,t)=>{let n=Ng(Gg,e.__scopeSelect),[r,i]=y.useState();if(dt(()=>{i(new DocumentFragment)},[]),!n.open){let t=r;return t?gt.createPortal((0,T.jsx)(Jg,{scope:e.__scopeSelect,children:(0,T.jsx)(Eg.Slot,{scope:e.__scopeSelect,children:(0,T.jsx)(`div`,{children:e.children})})}),t):null}return(0,T.jsx)(Qg,{...e,ref:t})});Kg.displayName=Gg;var qg=10,[Jg,Yg]=kg(Gg),Xg=`SelectContentImpl`,Zg=E(`SelectContent.RemoveScroll`),Qg=y.forwardRef((e,t)=>{let{__scopeSelect:n,position:r=`item-aligned`,onCloseAutoFocus:i,onEscapeKeyDown:a,onPointerDownOutside:o,side:s,sideOffset:c,align:l,alignOffset:u,arrowPadding:d,collisionBoundary:f,collisionPadding:p,sticky:m,hideWhenDetached:h,avoidCollisions:g,..._}=e,v=Ng(Gg,n),[b,x]=y.useState(null),[S,w]=y.useState(null),E=C(t,e=>x(e)),[D,O]=y.useState(null),[k,A]=y.useState(null),j=Dg(n),[M,N]=y.useState(!1),P=y.useRef(!1);y.useEffect(()=>{if(b)return Aa(b)},[b]),Lt();let ee=y.useCallback(e=>{let[t,...n]=j().map(e=>e.ref.current),[r]=n.slice(-1),i=document.activeElement;for(let n of e)if(n===i||(n?.scrollIntoView({block:`nearest`}),n===t&&S&&(S.scrollTop=0),n===r&&S&&(S.scrollTop=S.scrollHeight),n?.focus(),document.activeElement!==i))return},[j,S]),F=y.useCallback(()=>ee([D,b]),[ee,D,b]);y.useEffect(()=>{M&&F()},[M,F]);let{onOpenChange:I,triggerPointerDownPosRef:L}=v;y.useEffect(()=>{if(b){let e={x:0,y:0},t=t=>{e={x:Math.abs(Math.round(t.pageX)-(L.current?.x??0)),y:Math.abs(Math.round(t.pageY)-(L.current?.y??0))}},n=n=>{e.x<=10&&e.y<=10?n.preventDefault():b.contains(n.target)||I(!1),document.removeEventListener(`pointermove`,t),L.current=null};return L.current!==null&&(document.addEventListener(`pointermove`,t),document.addEventListener(`pointerup`,n,{capture:!0,once:!0})),()=>{document.removeEventListener(`pointermove`,t),document.removeEventListener(`pointerup`,n,{capture:!0})}}},[b,I,L]),y.useEffect(()=>{let e=()=>I(!1);return window.addEventListener(`blur`,e),window.addEventListener(`resize`,e),()=>{window.removeEventListener(`blur`,e),window.removeEventListener(`resize`,e)}},[I]);let[R,z]=N_(e=>{let t=j().filter(e=>!e.disabled),n=P_(t,e,t.find(e=>e.ref.current===document.activeElement));n&&setTimeout(()=>n.ref.current.focus())}),B=y.useCallback((e,t,n)=>{let r=!P.current&&!n;(v.value!==void 0&&v.value===t||r)&&(O(e),r&&(P.current=!0))},[v.value]),V=y.useCallback(()=>b?.focus(),[b]),H=y.useCallback((e,t,n)=>{let r=!P.current&&!n;(v.value!==void 0&&v.value===t||r)&&A(e)},[v.value]),U=r===`popper`?n_:e_,te=U===n_?{side:s,sideOffset:c,align:l,alignOffset:u,arrowPadding:d,collisionBoundary:f,collisionPadding:p,sticky:m,hideWhenDetached:h,avoidCollisions:g}:{};return(0,T.jsx)(Jg,{scope:n,content:b,viewport:S,onViewportChange:w,itemRefCallback:B,selectedItem:D,onItemLeave:V,itemTextRefCallback:H,focusSelectedItem:F,selectedItemText:k,position:r,isPositioned:M,searchRef:R,children:(0,T.jsx)(Bo,{as:Zg,allowPinchZoom:!0,children:(0,T.jsx)(Ut,{asChild:!0,trapped:v.open,onMountAutoFocus:e=>{e.preventDefault()},onUnmountAutoFocus:q(i,e=>{v.trigger?.focus({preventScroll:!0}),e.preventDefault()}),children:(0,T.jsx)(kt,{asChild:!0,disableOutsidePointerEvents:!0,onEscapeKeyDown:a,onPointerDownOutside:o,onFocusOutside:e=>e.preventDefault(),onDismiss:()=>v.onOpenChange(!1),children:(0,T.jsx)(U,{role:`listbox`,id:v.contentId,"data-state":v.open?`open`:`closed`,dir:v.dir,onContextMenu:e=>e.preventDefault(),..._,...te,onPlaced:()=>N(!0),ref:E,style:{display:`flex`,flexDirection:`column`,outline:`none`,..._.style},onKeyDown:q(_.onKeyDown,e=>{let t=e.ctrlKey||e.altKey||e.metaKey;if(e.key===`Tab`&&e.preventDefault(),!t&&e.key.length===1&&z(e.key),[`ArrowUp`,`ArrowDown`,`Home`,`End`].includes(e.key)){let t=j().filter(e=>!e.disabled).map(e=>e.ref.current);if([`ArrowUp`,`End`].includes(e.key)&&(t=t.slice().reverse()),[`ArrowUp`,`ArrowDown`].includes(e.key)){let n=e.target,r=t.indexOf(n);t=t.slice(r+1)}setTimeout(()=>ee(t)),e.preventDefault()}})})})})})})});Qg.displayName=Xg;var $g=`SelectItemAlignedPosition`,e_=y.forwardRef((e,t)=>{let{__scopeSelect:n,onPlaced:r,...i}=e,a=Ng(Gg,n),o=Yg(Gg,n),[s,c]=y.useState(null),[l,u]=y.useState(null),d=C(t,e=>u(e)),f=Dg(n),p=y.useRef(!1),m=y.useRef(!0),{viewport:h,selectedItem:g,selectedItemText:_,focusSelectedItem:v}=o,b=y.useCallback(()=>{if(a.trigger&&a.valueNode&&s&&l&&h&&g&&_){let e=a.trigger.getBoundingClientRect(),t=l.getBoundingClientRect(),n=a.valueNode.getBoundingClientRect(),i=_.getBoundingClientRect();if(a.dir!==`rtl`){let r=i.left-t.left,a=n.left-r,o=e.left-a,c=e.width+o,l=Math.max(c,t.width),u=window.innerWidth-qg,d=kd(a,[qg,Math.max(qg,u-l)]);s.style.minWidth=c+`px`,s.style.left=d+`px`}else{let r=t.right-i.right,a=window.innerWidth-n.right-r,o=window.innerWidth-e.right-a,c=e.width+o,l=Math.max(c,t.width),u=window.innerWidth-qg,d=kd(a,[qg,Math.max(qg,u-l)]);s.style.minWidth=c+`px`,s.style.right=d+`px`}let o=f(),c=window.innerHeight-qg*2,u=h.scrollHeight,d=window.getComputedStyle(l),m=parseInt(d.borderTopWidth,10),v=parseInt(d.paddingTop,10),y=parseInt(d.borderBottomWidth,10),b=parseInt(d.paddingBottom,10),x=m+v+u+b+y,S=Math.min(g.offsetHeight*5,x),C=window.getComputedStyle(h),w=parseInt(C.paddingTop,10),T=parseInt(C.paddingBottom,10),E=e.top+e.height/2-qg,D=c-E,O=g.offsetHeight/2,k=g.offsetTop+O,A=m+v+k,j=x-A;if(A<=E){let e=o.length>0&&g===o[o.length-1].ref.current;s.style.bottom=`0px`;let t=l.clientHeight-h.offsetTop-h.offsetHeight,n=A+Math.max(D,O+(e?T:0)+t+y);s.style.height=n+`px`}else{let e=o.length>0&&g===o[0].ref.current;s.style.top=`0px`;let t=Math.max(E,m+h.offsetTop+(e?w:0)+O)+j;s.style.height=t+`px`,h.scrollTop=A-E+h.offsetTop}s.style.margin=`${qg}px 0`,s.style.minHeight=S+`px`,s.style.maxHeight=c+`px`,r?.(),requestAnimationFrame(()=>p.current=!0)}},[f,a.trigger,a.valueNode,s,l,h,g,_,a.dir,r]);dt(()=>b(),[b]);let[x,S]=y.useState();return dt(()=>{l&&S(window.getComputedStyle(l).zIndex)},[l]),(0,T.jsx)(r_,{scope:n,contentWrapper:s,shouldExpandOnScrollRef:p,onScrollButtonChange:y.useCallback(e=>{e&&m.current===!0&&(b(),v?.(),m.current=!1)},[b,v]),children:(0,T.jsx)(`div`,{ref:c,style:{display:`flex`,flexDirection:`column`,position:`fixed`,zIndex:x},children:(0,T.jsx)(J.div,{...i,ref:d,style:{boxSizing:`border-box`,maxHeight:`100%`,...i.style}})})})});e_.displayName=$g;var t_=`SelectPopperPosition`,n_=y.forwardRef((e,t)=>{let{__scopeSelect:n,align:r=`start`,collisionPadding:i=qg,...a}=e,o=jg(n);return(0,T.jsx)(Ki,{...o,...a,ref:t,align:r,collisionPadding:i,style:{boxSizing:`border-box`,...a.style,"--radix-select-content-transform-origin":`var(--radix-popper-transform-origin)`,"--radix-select-content-available-width":`var(--radix-popper-available-width)`,"--radix-select-content-available-height":`var(--radix-popper-available-height)`,"--radix-select-trigger-width":`var(--radix-popper-anchor-width)`,"--radix-select-trigger-height":`var(--radix-popper-anchor-height)`}})});n_.displayName=t_;var[r_,i_]=kg(Gg,{}),a_=`SelectViewport`,o_=y.forwardRef((e,t)=>{let{__scopeSelect:n,nonce:r,...i}=e,a=Yg(a_,n),o=i_(a_,n),s=C(t,a.onViewportChange),c=y.useRef(0);return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`style`,{dangerouslySetInnerHTML:{__html:`[data-radix-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-select-viewport]::-webkit-scrollbar{display:none}`},nonce:r}),(0,T.jsx)(Eg.Slot,{scope:n,children:(0,T.jsx)(J.div,{"data-radix-select-viewport":``,role:`presentation`,...i,ref:s,style:{position:`relative`,flex:1,overflow:`hidden auto`,...i.style},onScroll:q(i.onScroll,e=>{let t=e.currentTarget,{contentWrapper:n,shouldExpandOnScrollRef:r}=o;if(r?.current&&n){let e=Math.abs(c.current-t.scrollTop);if(e>0){let r=window.innerHeight-qg*2,i=parseFloat(n.style.minHeight),a=parseFloat(n.style.height),o=Math.max(i,a);if(o0?s:0,n.style.justifyContent=`flex-end`)}}}c.current=t.scrollTop})})})]})});o_.displayName=a_;var s_=`SelectGroup`,[c_,l_]=kg(s_),u_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=rn();return(0,T.jsx)(c_,{scope:n,id:i,children:(0,T.jsx)(J.div,{role:`group`,"aria-labelledby":i,...r,ref:t})})});u_.displayName=s_;var d_=`SelectLabel`,f_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=l_(d_,n);return(0,T.jsx)(J.div,{id:i.id,...r,ref:t})});f_.displayName=d_;var p_=`SelectItem`,[m_,h_]=kg(p_),g_=y.forwardRef((e,t)=>{let{__scopeSelect:n,value:r,disabled:i=!1,textValue:a,...o}=e,s=Ng(p_,n),c=Yg(p_,n),l=s.value===r,[u,d]=y.useState(a??``),[f,p]=y.useState(!1),m=C(t,e=>c.itemRefCallback?.(e,r,i)),h=rn(),g=y.useRef(`touch`),_=()=>{i||(s.onValueChange(r),s.onOpenChange(!1))};if(r===``)throw Error(`A must have a value prop that is not an empty string. This is because the Select value can be set to an empty string to clear the selection and show the placeholder.`);return(0,T.jsx)(m_,{scope:n,value:r,disabled:i,textId:h,isSelected:l,onItemTextChange:y.useCallback(e=>{d(t=>t||(e?.textContent??``).trim())},[]),children:(0,T.jsx)(Eg.ItemSlot,{scope:n,value:r,disabled:i,textValue:u,children:(0,T.jsx)(J.div,{role:`option`,"aria-labelledby":h,"data-highlighted":f?``:void 0,"aria-selected":l&&f,"data-state":l?`checked`:`unchecked`,"aria-disabled":i||void 0,"data-disabled":i?``:void 0,tabIndex:i?void 0:-1,...o,ref:m,onFocus:q(o.onFocus,()=>p(!0)),onBlur:q(o.onBlur,()=>p(!1)),onClick:q(o.onClick,()=>{g.current!==`mouse`&&_()}),onPointerUp:q(o.onPointerUp,()=>{g.current===`mouse`&&_()}),onPointerDown:q(o.onPointerDown,e=>{g.current=e.pointerType}),onPointerMove:q(o.onPointerMove,e=>{g.current=e.pointerType,i?c.onItemLeave?.():g.current===`mouse`&&e.currentTarget.focus({preventScroll:!0})}),onPointerLeave:q(o.onPointerLeave,e=>{e.currentTarget===document.activeElement&&c.onItemLeave?.()}),onKeyDown:q(o.onKeyDown,e=>{c.searchRef?.current!==``&&e.key===` `||(wg.includes(e.key)&&_(),e.key===` `&&e.preventDefault())})})})})});g_.displayName=p_;var __=`SelectItemText`,v_=y.forwardRef((e,t)=>{let{__scopeSelect:n,className:r,style:i,...a}=e,o=Ng(__,n),s=Yg(__,n),c=h_(__,n),l=Fg(__,n),[u,d]=y.useState(null),f=C(t,e=>d(e),c.onItemTextChange,e=>s.itemTextRefCallback?.(e,c.value,c.disabled)),p=u?.textContent,m=y.useMemo(()=>(0,T.jsx)(`option`,{value:c.value,disabled:c.disabled,children:p},c.value),[c.disabled,c.value,p]),{onNativeOptionAdd:h,onNativeOptionRemove:g}=l;return dt(()=>(h(m),()=>g(m)),[h,g,m]),(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(J.span,{id:c.textId,...a,ref:f}),c.isSelected&&o.valueNode&&!o.valueNodeHasChildren?gt.createPortal(a.children,o.valueNode):null]})});v_.displayName=__;var y_=`SelectItemIndicator`,b_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e;return h_(y_,n).isSelected?(0,T.jsx)(J.span,{"aria-hidden":!0,...r,ref:t}):null});b_.displayName=y_;var x_=`SelectScrollUpButton`,S_=y.forwardRef((e,t)=>{let n=Yg(x_,e.__scopeSelect),r=i_(x_,e.__scopeSelect),[i,a]=y.useState(!1),o=C(t,r.onScrollButtonChange);return dt(()=>{if(n.viewport&&n.isPositioned){let e=function(){a(t.scrollTop>0)},t=n.viewport;return e(),t.addEventListener(`scroll`,e),()=>t.removeEventListener(`scroll`,e)}},[n.viewport,n.isPositioned]),i?(0,T.jsx)(T_,{...e,ref:o,onAutoScroll:()=>{let{viewport:e,selectedItem:t}=n;e&&t&&(e.scrollTop-=t.offsetHeight)}}):null});S_.displayName=x_;var C_=`SelectScrollDownButton`,w_=y.forwardRef((e,t)=>{let n=Yg(C_,e.__scopeSelect),r=i_(C_,e.__scopeSelect),[i,a]=y.useState(!1),o=C(t,r.onScrollButtonChange);return dt(()=>{if(n.viewport&&n.isPositioned){let e=function(){let e=t.scrollHeight-t.clientHeight;a(Math.ceil(t.scrollTop)t.removeEventListener(`scroll`,e)}},[n.viewport,n.isPositioned]),i?(0,T.jsx)(T_,{...e,ref:o,onAutoScroll:()=>{let{viewport:e,selectedItem:t}=n;e&&t&&(e.scrollTop+=t.offsetHeight)}}):null});w_.displayName=C_;var T_=y.forwardRef((e,t)=>{let{__scopeSelect:n,onAutoScroll:r,...i}=e,a=Yg(`SelectScrollButton`,n),o=y.useRef(null),s=Dg(n),c=y.useCallback(()=>{o.current!==null&&(window.clearInterval(o.current),o.current=null)},[]);return y.useEffect(()=>()=>c(),[c]),dt(()=>{s().find(e=>e.ref.current===document.activeElement)?.ref.current?.scrollIntoView({block:`nearest`})},[s]),(0,T.jsx)(J.div,{"aria-hidden":!0,...i,ref:t,style:{flexShrink:0,...i.style},onPointerDown:q(i.onPointerDown,()=>{o.current===null&&(o.current=window.setInterval(r,50))}),onPointerMove:q(i.onPointerMove,()=>{a.onItemLeave?.(),o.current===null&&(o.current=window.setInterval(r,50))}),onPointerLeave:q(i.onPointerLeave,()=>{c()})})}),E_=`SelectSeparator`,D_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e;return(0,T.jsx)(J.div,{"aria-hidden":!0,...r,ref:t})});D_.displayName=E_;var O_=`SelectArrow`,k_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=jg(n),a=Ng(O_,n),o=Yg(O_,n);return a.open&&o.position===`popper`?(0,T.jsx)(qi,{...i,...r,ref:t}):null});k_.displayName=O_;var A_=`SelectBubbleInput`,j_=y.forwardRef(({__scopeSelect:e,value:t,...n},r)=>{let i=y.useRef(null),a=C(r,i),o=Vf(t);return y.useEffect(()=>{let e=i.current;if(!e)return;let n=window.HTMLSelectElement.prototype,r=Object.getOwnPropertyDescriptor(n,`value`).set;if(o!==t&&r){let n=new Event(`change`,{bubbles:!0});r.call(e,t),e.dispatchEvent(n)}},[o,t]),(0,T.jsx)(J.select,{...n,style:{...ap,...n.style},ref:a,defaultValue:t})});j_.displayName=A_;function M_(e){return e===``||e===void 0}function N_(e){let t=xt(e),n=y.useRef(``),r=y.useRef(0),i=y.useCallback(e=>{let i=n.current+e;t(i),(function e(t){n.current=t,window.clearTimeout(r.current),t!==``&&(r.current=window.setTimeout(()=>e(``),1e3))})(i)},[t]),a=y.useCallback(()=>{n.current=``,window.clearTimeout(r.current)},[]);return y.useEffect(()=>()=>window.clearTimeout(r.current),[]),[n,i,a]}function P_(e,t,n){let r=t.length>1&&Array.from(t).every(e=>e===t[0])?t[0]:t,i=n?e.indexOf(n):-1,a=F_(e,Math.max(i,0));r.length===1&&(a=a.filter(e=>e!==n));let o=a.find(e=>e.textValue.toLowerCase().startsWith(r.toLowerCase()));return o===n?void 0:o}function F_(e,t){return e.map((n,r)=>e[(t+r)%e.length])}var I_=Ig,L_=Rg,R_=Bg,z_=Hg,B_=Wg,V_=Kg,H_=o_,U_=g_,W_=v_,G_=b_,K_=S_,q_=w_;function J_({...e}){return(0,T.jsx)(I_,{"data-slot":`select`,...e})}function Y_({...e}){return(0,T.jsx)(R_,{"data-slot":`select-value`,...e})}function X_({className:e,size:t=`default`,children:n,...r}){return(0,T.jsxs)(L_,{"data-slot":`select-trigger`,"data-size":t,className:at(`border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,e),...r,children:[n,(0,T.jsx)(z_,{asChild:!0,children:(0,T.jsx)(X,{className:`size-4 opacity-50`})})]})}function Z_({className:e,children:t,position:n=`popper`,...r}){return(0,T.jsx)(B_,{children:(0,T.jsxs)(V_,{"data-slot":`select-content`,className:at(`bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md`,n===`popper`&&`data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1`,e),position:n,...r,children:[(0,T.jsx)($_,{}),(0,T.jsx)(H_,{className:at(`p-1`,n===`popper`&&`h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1`),children:t}),(0,T.jsx)(ev,{})]})})}function Q_({className:e,children:t,...n}){return(0,T.jsxs)(U_,{"data-slot":`select-item`,className:at(`focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2`,e),...n,children:[(0,T.jsx)(`span`,{className:`absolute right-2 flex size-3.5 items-center justify-center`,children:(0,T.jsx)(G_,{children:(0,T.jsx)(Tl,{className:`size-4`})})}),(0,T.jsx)(W_,{children:t})]})}function $_({className:e,...t}){return(0,T.jsx)(K_,{"data-slot":`select-scroll-up-button`,className:at(`flex cursor-default items-center justify-center py-1`,e),...t,children:(0,T.jsx)(Dl,{className:`size-4`})})}function ev({className:e,...t}){return(0,T.jsx)(q_,{"data-slot":`select-scroll-down-button`,className:at(`flex cursor-default items-center justify-center py-1`,e),...t,children:(0,T.jsx)(X,{className:`size-4`})})}function tv({title:e,icon:t,children:n,className:r=``}){return(0,T.jsxs)(`div`,{className:`border rounded-lg p-4 bg-card ${r}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-3`,children:[t,(0,T.jsx)(`h3`,{className:`text-sm font-semibold text-foreground`,children:e})]}),(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:n})]})}function nv({agent:e,open:t,onOpenChange:n}){let r=e.source===`directory`?(0,T.jsx)(Ul,{className:`h-4 w-4 text-muted-foreground`}):e.source===`in_memory`?(0,T.jsx)(Rl,{className:`h-4 w-4 text-muted-foreground`}):(0,T.jsx)(Wl,{className:`h-4 w-4 text-muted-foreground`}),i=e.source===`directory`?`Local`:e.source===`in_memory`?`In-Memory`:`Gallery`;return(0,T.jsx)(Im,{open:t,onOpenChange:n,children:(0,T.jsxs)(Lm,{className:`max-w-4xl max-h-[90vh] flex flex-col`,children:[(0,T.jsxs)(Rm,{className:`px-6 pt-6 flex-shrink-0`,children:[(0,T.jsx)(zm,{children:`Agent Details`}),(0,T.jsx)(Vm,{onClose:()=>n(!1)})]}),(0,T.jsxs)(`div`,{className:`px-6 pb-6 overflow-y-auto flex-1`,children:[(0,T.jsxs)(`div`,{className:`mb-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-3 mb-2`,children:[(0,T.jsx)(Sl,{className:`h-6 w-6 text-primary`}),(0,T.jsx)(`h2`,{className:`text-xl font-semibold text-foreground`,children:e.name||e.id})]}),e.description&&(0,T.jsx)(`p`,{className:`text-muted-foreground`,children:e.description})]}),(0,T.jsx)(`div`,{className:`h-px bg-border mb-6`}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-4 mb-4`,children:[(e.model_id||e.chat_client_type)&&(0,T.jsx)(tv,{title:`Model & Client`,icon:(0,T.jsx)(Sl,{className:`h-4 w-4 text-muted-foreground`}),children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[e.model_id&&(0,T.jsx)(`div`,{className:`font-mono text-foreground`,children:e.model_id}),e.chat_client_type&&(0,T.jsxs)(`div`,{className:`text-xs`,children:[`(`,e.chat_client_type,`)`]})]})}),(0,T.jsx)(tv,{title:`Source`,icon:r,children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-foreground`,children:i}),e.module_path&&(0,T.jsx)(`div`,{className:`font-mono text-xs break-all`,children:e.module_path})]})}),(0,T.jsx)(tv,{title:`Environment`,icon:e.has_env?(0,T.jsx)(Ml,{className:`h-4 w-4 text-orange-500`}):(0,T.jsx)(kl,{className:`h-4 w-4 text-green-500`}),className:`md:col-span-2`,children:(0,T.jsx)(`div`,{className:e.has_env?`text-orange-600 dark:text-orange-400`:`text-green-600 dark:text-green-400`,children:e.has_env?`Requires environment variables`:`No environment variables required`})})]}),e.instructions&&(0,T.jsx)(tv,{title:`Instructions`,icon:(0,T.jsx)(Hl,{className:`h-4 w-4 text-muted-foreground`}),className:`mb-4`,children:(0,T.jsx)(`div`,{className:`text-sm text-foreground leading-relaxed whitespace-pre-wrap`,children:e.instructions})}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-4`,children:[e.tools&&e.tools.length>0&&(0,T.jsx)(tv,{title:`Tools (${e.tools.length})`,icon:(0,T.jsx)(au,{className:`h-4 w-4 text-muted-foreground`}),children:(0,T.jsx)(`ul`,{className:`space-y-1`,children:e.tools.map((e,t)=>(0,T.jsxs)(`li`,{className:`font-mono text-xs text-foreground`,children:[`• `,e]},t))})}),e.middleware&&e.middleware.length>0&&(0,T.jsx)(tv,{title:`Middlewares (${e.middleware.length})`,icon:(0,T.jsx)(au,{className:`h-4 w-4 text-muted-foreground`}),children:(0,T.jsx)(`ul`,{className:`space-y-1`,children:e.middleware.map((e,t)=>(0,T.jsxs)(`li`,{className:`font-mono text-xs text-foreground`,children:[`• `,e]},t))})}),e.context_provider&&(0,T.jsx)(tv,{title:`Context Provider`,icon:(0,T.jsx)(Rl,{className:`h-4 w-4 text-muted-foreground`}),className:!e.middleware||e.middleware.length===0?`md:col-start-2`:``,children:(0,T.jsx)(`div`,{className:`font-mono text-xs text-foreground`,children:e.context_provider})})]})]})]})})}Dd();var rv=50,iv=`[Earlier streaming content omitted after refresh] + opacity-0 group-hover:opacity-100`,title:n?`Copied!`:`Copy code`,children:n?(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,width:`14`,height:`14`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,className:`text-green-600 dark:text-green-400`,children:(0,T.jsx)(`polyline`,{points:`20 6 9 17 4 12`})}):(0,T.jsxs)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,width:`14`,height:`14`,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,T.jsx)(`rect`,{x:`9`,y:`9`,width:`13`,height:`13`,rx:`2`,ry:`2`}),(0,T.jsx)(`path`,{d:`M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1`})]})})]})}function mg({content:e,className:t=``}){let n=e.split(` +`),r=[],i=0;for(;i(0,T.jsx)(`li`,{className:`text-sm break-words`,children:hg(e)},t))},r.length));continue}if(e.match(/^[\s]*\d+\.\s+/)){let e=[];for(;i(0,T.jsx)(`li`,{className:`text-sm break-words`,children:hg(e)},t))},r.length));continue}if(e.trim().startsWith(`|`)&&e.trim().endsWith(`|`)){let e=[];for(;i=2){let t=e[0].split(`|`).slice(1,-1).map(e=>e.trim());if(e[1].match(/^\|[\s\-:|]+\|$/)){let n=e.slice(2).map(e=>e.split(`|`).slice(1,-1).map(e=>e.trim()));r.push((0,T.jsx)(`div`,{className:`my-3 overflow-x-auto`,children:(0,T.jsxs)(`table`,{className:`min-w-full border border-foreground/10 text-sm`,children:[(0,T.jsx)(`thead`,{className:`bg-foreground/5`,children:(0,T.jsx)(`tr`,{children:t.map((e,t)=>(0,T.jsx)(`th`,{className:`border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words`,children:hg(e)},t))})}),(0,T.jsx)(`tbody`,{children:n.map((e,t)=>(0,T.jsx)(`tr`,{className:`border-b border-foreground/5 last:border-b-0`,children:e.map((e,t)=>(0,T.jsx)(`td`,{className:`px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words`,children:hg(e)},t))},t))})]})},r.length));continue}}for(let t of e)r.push((0,T.jsx)(`p`,{className:`my-1`,children:hg(t)},r.length));continue}if(e.trim().startsWith(`>`)){let e=[];for(;i`);)e.push(n[i].replace(/^>\s?/,``)),i++;r.push((0,T.jsx)(`blockquote`,{className:`my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words`,children:e.map((e,t)=>(0,T.jsx)(`div`,{className:`break-words`,children:hg(e)},t))},r.length));continue}if(e.match(/^[\s]*[-*_]{3,}[\s]*$/)){r.push((0,T.jsx)(`hr`,{className:`my-4 border-t border-border`},r.length)),i++;continue}if(e.trim()===``){r.push((0,T.jsx)(`div`,{className:`h-2`},r.length)),i++;continue}r.push((0,T.jsx)(`p`,{className:`my-1 break-words`,children:hg(e)},r.length)),i++}return(0,T.jsx)(`div`,{className:`markdown-content break-words ${t}`,children:r})}function hg(e){let t=[],n=e,r=0;for(;n.length>0;){let e=n.match(/`([^`]+)`/);if(e&&e.index!==void 0){e.index>0&&t.push((0,T.jsx)(`span`,{children:gg(n.slice(0,e.index))},r++)),t.push((0,T.jsx)(`code`,{className:`px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20`,children:e[1]},r++)),n=n.slice(e.index+e[0].length);continue}t.push((0,T.jsx)(`span`,{children:gg(n)},r++));break}return t}function gg(e){let t=[],n=e,r=0;for(;n.length>0;){let e=[{regex:/\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/,component:`strong-link`},{regex:/__\[([^\]]+)\]\(([^)]+)\)__/,component:`strong-link`},{regex:/\*\[([^\]]+)\]\(([^)]+)\)\*/,component:`em-link`},{regex:/_\[([^\]]+)\]\(([^)]+)\)_/,component:`em-link`},{regex:/\[([^\]]+)\]\(([^)]+)\)/,component:`link`},{regex:/\*\*(.+?)\*\*/,component:`strong`},{regex:/__(.+?)__/,component:`strong`},{regex:/\*(.+?)\*/,component:`em`},{regex:/_(.+?)_/,component:`em`}],i=!1;for(let a of e){let e=n.match(a.regex);if(e&&e.index!==void 0){if(e.index>0&&t.push(n.slice(0,e.index)),a.component===`strong`)t.push((0,T.jsx)(`strong`,{className:`font-semibold`,children:e[1]},r++));else if(a.component===`em`)t.push((0,T.jsx)(`em`,{className:`italic`,children:e[1]},r++));else if(a.component===`strong-link`){let n=e[1],i=e[2],a=gg(n);t.push((0,T.jsx)(`strong`,{className:`font-semibold`,children:(0,T.jsx)(`a`,{href:i,target:`_blank`,rel:`noopener noreferrer`,className:`text-primary hover:underline break-words`,children:a})},r++))}else if(a.component===`em-link`){let n=e[1],i=e[2],a=gg(n);t.push((0,T.jsx)(`em`,{className:`italic`,children:(0,T.jsx)(`a`,{href:i,target:`_blank`,rel:`noopener noreferrer`,className:`text-primary hover:underline break-words`,children:a})},r++))}else if(a.component===`link`){let n=e[1],i=e[2],a=gg(n);t.push((0,T.jsx)(`a`,{href:i,target:`_blank`,rel:`noopener noreferrer`,className:`text-primary hover:underline break-words`,children:a},r++))}n=n.slice(e.index+e[0].length),i=!0;break}}if(!i){n.length>0&&t.push(n);break}}return t}function _g({content:e,className:t,isStreaming:n}){if(e.type!==`text`&&e.type!==`input_text`&&e.type!==`output_text`&&e.type!==`refusal`)return null;let r=e.type===`refusal`?e.refusal:e.text;return(0,T.jsxs)(`div`,{className:`break-words ${t||``}`,children:[(0,T.jsx)(mg,{content:r}),n&&r.length>0&&(0,T.jsx)(`span`,{className:`ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current`})]})}function vg({content:e,className:t}){let[n,r]=(0,y.useState)(!1),[i,a]=(0,y.useState)(!1);if(e.type!==`input_image`&&e.type!==`output_image`)return null;let o=e.image_url;return n?(0,T.jsx)(`div`,{className:`my-2 p-3 border rounded-lg bg-muted ${t||``}`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-sm text-muted-foreground`,children:[(0,T.jsx)(Hl,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{children:`Image could not be loaded`})]})}):(0,T.jsxs)(`div`,{className:`my-2 ${t||``}`,children:[(0,T.jsx)(`img`,{src:o,alt:`Uploaded image`,className:`rounded-lg border max-w-full transition-all cursor-pointer ${i?`max-h-none`:`max-h-64`}`,onClick:()=>a(!i),onError:()=>r(!0)}),i&&(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground mt-1`,children:`Click to collapse`})]})}function yg(e,t){let[n,r]=(0,y.useState)(null);return(0,y.useEffect)(()=>{if(!e){r(null);return}try{let n;if(e.startsWith(`data:`)){let t=e.split(`,`);if(t.length!==2){r(null);return}n=t[1]}else n=e;let i=atob(n),a=new Uint8Array(i.length);for(let e=0;e{URL.revokeObjectURL(s)}}catch(e){console.error(`Failed to convert base64 to blob URL:`,e),r(null)}},[e,t]),n}function bg({content:e,className:t}){let[n,r]=(0,y.useState)(!0),i=e.type===`input_file`||e.type===`output_file`,a=i?e.file_url||e.file_data:void 0,o=i?e.filename||`file`:void 0,s=o?.toLowerCase().endsWith(`.pdf`)||a?.includes(`application/pdf`),c=o?.toLowerCase().match(/\.(mp3|wav|m4a|ogg|flac|aac)$/),l=yg(i&&s?e.file_data||e.file_url:void 0,`application/pdf`);if(!i)return null;let u=l||a;return s&&a?(0,T.jsxs)(`div`,{className:`my-2 ${t||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-2 px-1`,children:[(0,T.jsx)(Hl,{className:`h-4 w-4 text-red-500`}),(0,T.jsx)(`span`,{className:`text-sm font-medium truncate flex-1`,children:o}),(0,T.jsx)(`button`,{onClick:()=>r(!n),className:`text-xs text-muted-foreground hover:text-foreground flex items-center gap-1`,children:n?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(X,{className:`h-3 w-3`}),`Collapse`]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Z,{className:`h-3 w-3`}),`Expand`]})})]}),n&&(0,T.jsxs)(`div`,{className:`border rounded-lg p-6 bg-muted/50 flex flex-col items-center justify-center gap-4`,children:[(0,T.jsx)(Hl,{className:`h-16 w-16 text-red-400`}),(0,T.jsxs)(`div`,{className:`text-center`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium mb-1`,children:o}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`PDF Document`})]}),(0,T.jsxs)(`div`,{className:`flex gap-3`,children:[(0,T.jsx)(`button`,{onClick:()=>{u&&window.open(u,`_blank`)},className:`text-sm bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2 px-4 py-2 rounded-md transition-colors`,children:`Open in new tab`}),(0,T.jsxs)(`a`,{href:u||a,download:o,className:`text-sm text-foreground hover:bg-accent flex items-center gap-2 px-4 py-2 border rounded-md transition-colors`,children:[(0,T.jsx)(zl,{className:`h-4 w-4`}),`Download`]})]})]})]}):c&&a?(0,T.jsxs)(`div`,{className:`my-2 p-3 border rounded-lg ${t||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-2`,children:[(0,T.jsx)(iu,{className:`h-4 w-4 text-muted-foreground`}),(0,T.jsx)(`span`,{className:`text-sm font-medium`,children:o})]}),(0,T.jsxs)(`audio`,{controls:!0,className:`w-full`,children:[(0,T.jsx)(`source`,{src:a}),`Your browser does not support audio playback.`]})]}):(0,T.jsx)(`div`,{className:`my-2 p-3 border rounded-lg bg-muted ${t||``}`,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Hl,{className:`h-4 w-4 text-muted-foreground`}),(0,T.jsx)(`span`,{className:`text-sm`,children:o})]}),a&&(0,T.jsxs)(`a`,{href:a,download:o,className:`text-xs text-primary hover:underline flex items-center gap-1`,children:[(0,T.jsx)(zl,{className:`h-3 w-3`}),`Download`]})]})})}function xg({content:e,className:t}){let[n,r]=(0,y.useState)(!1);if(e.type!==`output_data`)return null;let i=e.data,a=e.mime_type,o=e.description,s=i;try{let e=JSON.parse(i);s=JSON.stringify(e,null,2)}catch{}return(0,T.jsxs)(`div`,{className:`my-2 p-3 border rounded-lg bg-muted ${t||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 cursor-pointer`,onClick:()=>r(!n),children:[(0,T.jsx)(Hl,{className:`h-4 w-4 text-muted-foreground`}),(0,T.jsx)(`span`,{className:`text-sm font-medium`,children:o||`Data Output`}),(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground ml-auto`,children:a}),n?(0,T.jsx)(X,{className:`h-4 w-4 text-muted-foreground`}):(0,T.jsx)(Z,{className:`h-4 w-4 text-muted-foreground`})]}),n&&(0,T.jsx)(`pre`,{className:`mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono`,children:s})]})}function Sg({content:e,className:t}){let[n,r]=(0,y.useState)(!1);if(e.type!==`function_approval_request`)return null;let{status:i,function_call:a}=e,o={pending:{icon:Nl,label:`Awaiting approval`,iconClass:`text-amber-600 dark:text-amber-400`},approved:{icon:Tl,label:`Approved`,iconClass:`text-green-600 dark:text-green-400`},rejected:{icon:Du,label:`Rejected`,iconClass:`text-red-600 dark:text-red-400`}}[i],s=o.icon,c;try{c=typeof a.arguments==`string`?JSON.parse(a.arguments):a.arguments}catch{c=a.arguments}return(0,T.jsxs)(`div`,{className:t,children:[(0,T.jsxs)(`button`,{onClick:()=>r(!n),className:`flex items-center gap-2 px-2 py-1 text-xs rounded hover:bg-muted/50 transition-colors w-fit`,children:[(0,T.jsx)(s,{className:`h-3 w-3 ${o.iconClass}`}),(0,T.jsx)(`span`,{className:`text-muted-foreground font-mono`,children:a.name}),(0,T.jsx)(`span`,{className:`text-xs ${o.iconClass}`,children:o.label}),n?(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`▼`}):(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:`▶`})]}),n&&(0,T.jsx)(`div`,{className:`ml-5 mt-1 text-xs font-mono text-muted-foreground border-l-2 border-muted pl-3`,children:(0,T.jsx)(`pre`,{className:`whitespace-pre-wrap break-all`,children:JSON.stringify(c,null,2)})})]})}function Cg({content:e,className:t,isStreaming:n}){switch(e.type){case`text`:case`input_text`:case`output_text`:case`refusal`:return(0,T.jsx)(_g,{content:e,className:t,isStreaming:n});case`input_image`:case`output_image`:return(0,T.jsx)(vg,{content:e,className:t});case`input_file`:case`output_file`:return(0,T.jsx)(bg,{content:e,className:t});case`output_data`:return(0,T.jsx)(xg,{content:e,className:t});case`function_approval_request`:return(0,T.jsx)(Sg,{content:e,className:t});default:return null}}function wg({name:e,arguments:t,className:n}){let[r,i]=(0,y.useState)(!1),a;try{a=typeof t==`string`?JSON.parse(t):t}catch{a=t}return(0,T.jsxs)(`div`,{className:`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${n||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 cursor-pointer`,onClick:()=>i(!r),children:[(0,T.jsx)(Fl,{className:`h-4 w-4 text-blue-600 dark:text-blue-400`}),(0,T.jsxs)(`span`,{className:`text-sm font-medium text-blue-800 dark:text-blue-300`,children:[`Function Call: `,e]}),r?(0,T.jsx)(X,{className:`h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto`}):(0,T.jsx)(Z,{className:`h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto`})]}),r&&(0,T.jsxs)(`div`,{className:`mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border`,children:[(0,T.jsx)(`div`,{className:`text-blue-600 dark:text-blue-400 mb-1`,children:`Arguments:`}),(0,T.jsx)(`pre`,{className:`whitespace-pre-wrap`,children:JSON.stringify(a,null,2)})]})]})}function Tg({output:e,call_id:t,className:n}){let[r,i]=(0,y.useState)(!1),a;try{a=typeof e==`string`?JSON.parse(e):e}catch{a=e}return(0,T.jsxs)(`div`,{className:`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${n||``}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 cursor-pointer`,onClick:()=>i(!r),children:[(0,T.jsx)(Fl,{className:`h-4 w-4 text-green-600 dark:text-green-400`}),(0,T.jsx)(`span`,{className:`text-sm font-medium text-green-800 dark:text-green-300`,children:`Function Result`}),r?(0,T.jsx)(X,{className:`h-4 w-4 text-green-600 dark:text-green-400 ml-auto`}):(0,T.jsx)(Z,{className:`h-4 w-4 text-green-600 dark:text-green-400 ml-auto`})]}),r&&(0,T.jsxs)(`div`,{className:`mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border`,children:[(0,T.jsx)(`div`,{className:`text-green-600 dark:text-green-400 mb-1`,children:`Output:`}),(0,T.jsx)(`pre`,{className:`whitespace-pre-wrap`,children:JSON.stringify(a,null,2)}),(0,T.jsxs)(`div`,{className:`text-gray-500 text-[10px] mt-2`,children:[`Call ID: `,t]})]})]})}function Eg({item:e,className:t}){if(e.type===`message`){let n=e.status===`in_progress`,r=e.content.length>0;return(0,T.jsxs)(`div`,{className:t,children:[e.content.map((e,t)=>(0,T.jsx)(Cg,{content:e,className:t>0?`mt-2`:``,isStreaming:n},t)),n&&!r&&(0,T.jsx)(`div`,{className:`flex items-center space-x-1`,children:(0,T.jsxs)(`div`,{className:`flex space-x-1`,children:[(0,T.jsx)(`div`,{className:`h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]`}),(0,T.jsx)(`div`,{className:`h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]`}),(0,T.jsx)(`div`,{className:`h-2 w-2 animate-bounce rounded-full bg-current`})]})})]})}return e.type===`function_call`?(0,T.jsx)(wg,{name:e.name,arguments:e.arguments,className:t}):e.type===`function_call_output`?(0,T.jsx)(Tg,{output:e.output,call_id:e.call_id,className:t}):null}var Dg=[` `,`Enter`,`ArrowUp`,`ArrowDown`],Og=[` `,`Enter`],kg=`Select`,[Ag,jg,Mg]=vt(kg),[Ng,Pg]=lt(kg,[Mg,Oi]),Fg=Oi(),[Ig,Lg]=Ng(kg),[Rg,zg]=Ng(kg),Bg=e=>{let{__scopeSelect:t,children:n,open:r,defaultOpen:i,onOpenChange:a,value:o,defaultValue:s,onValueChange:c,dir:l,name:u,autoComplete:d,disabled:f,required:p,form:m}=e,h=Fg(t),[g,_]=y.useState(null),[v,b]=y.useState(null),[x,S]=y.useState(!1),C=bt(l),[w,E]=pt({prop:r,defaultProp:i??!1,onChange:a,caller:kg}),[D,O]=pt({prop:o,defaultProp:s,onChange:c,caller:kg}),k=y.useRef(null),A=g?m||!!g.closest(`form`):!0,[j,M]=y.useState(new Set),N=Array.from(j).map(e=>e.props.value).join(`;`);return(0,T.jsx)(Wi,{...h,children:(0,T.jsxs)(Ig,{required:p,scope:t,trigger:g,onTriggerChange:_,valueNode:v,onValueNodeChange:b,valueNodeHasChildren:x,onValueNodeHasChildrenChange:S,contentId:rn(),value:D,onValueChange:O,open:w,onOpenChange:E,dir:C,triggerPointerDownPosRef:k,disabled:f,children:[(0,T.jsx)(Ag.Provider,{scope:t,children:(0,T.jsx)(Rg,{scope:e.__scopeSelect,onNativeOptionAdd:y.useCallback(e=>{M(t=>new Set(t).add(e))},[]),onNativeOptionRemove:y.useCallback(e=>{M(t=>{let n=new Set(t);return n.delete(e),n})},[]),children:n})}),A?(0,T.jsxs)(F_,{"aria-hidden":!0,required:p,tabIndex:-1,name:u,autoComplete:d,value:D,onChange:e=>O(e.target.value),disabled:f,form:m,children:[D===void 0?(0,T.jsx)(`option`,{value:``}):null,Array.from(j)]},N):null]})})};Bg.displayName=kg;var Vg=`SelectTrigger`,Hg=y.forwardRef((e,t)=>{let{__scopeSelect:n,disabled:r=!1,...i}=e,a=Fg(n),o=Lg(Vg,n),s=o.disabled||r,c=C(t,o.onTriggerChange),l=jg(n),u=y.useRef(`touch`),[d,f,p]=L_(e=>{let t=l().filter(e=>!e.disabled),n=R_(t,e,t.find(e=>e.value===o.value));n!==void 0&&o.onValueChange(n.value)}),m=e=>{s||(o.onOpenChange(!0),p()),e&&(o.triggerPointerDownPosRef.current={x:Math.round(e.pageX),y:Math.round(e.pageY)})};return(0,T.jsx)(Gi,{asChild:!0,...a,children:(0,T.jsx)(J.button,{type:`button`,role:`combobox`,"aria-controls":o.contentId,"aria-expanded":o.open,"aria-required":o.required,"aria-autocomplete":`none`,dir:o.dir,"data-state":o.open?`open`:`closed`,disabled:s,"data-disabled":s?``:void 0,"data-placeholder":I_(o.value)?``:void 0,...i,ref:c,onClick:q(i.onClick,e=>{e.currentTarget.focus(),u.current!==`mouse`&&m(e)}),onPointerDown:q(i.onPointerDown,e=>{u.current=e.pointerType;let t=e.target;t.hasPointerCapture(e.pointerId)&&t.releasePointerCapture(e.pointerId),e.button===0&&e.ctrlKey===!1&&e.pointerType===`mouse`&&(m(e),e.preventDefault())}),onKeyDown:q(i.onKeyDown,e=>{let t=d.current!==``;!(e.ctrlKey||e.altKey||e.metaKey)&&e.key.length===1&&f(e.key),!(t&&e.key===` `)&&Dg.includes(e.key)&&(m(),e.preventDefault())})})})});Hg.displayName=Vg;var Ug=`SelectValue`,Wg=y.forwardRef((e,t)=>{let{__scopeSelect:n,className:r,style:i,children:a,placeholder:o=``,...s}=e,c=Lg(Ug,n),{onValueNodeHasChildrenChange:l}=c,u=a!==void 0,d=C(t,c.onValueNodeChange);return dt(()=>{l(u)},[l,u]),(0,T.jsx)(J.span,{...s,ref:d,style:{pointerEvents:`none`},children:I_(c.value)?(0,T.jsx)(T.Fragment,{children:o}):a})});Wg.displayName=Ug;var Gg=`SelectIcon`,Kg=y.forwardRef((e,t)=>{let{__scopeSelect:n,children:r,...i}=e;return(0,T.jsx)(J.span,{"aria-hidden":!0,...i,ref:t,children:r||`▼`})});Kg.displayName=Gg;var qg=`SelectPortal`,Jg=e=>(0,T.jsx)(Yi,{asChild:!0,...e});Jg.displayName=qg;var Yg=`SelectContent`,Xg=y.forwardRef((e,t)=>{let n=Lg(Yg,e.__scopeSelect),[r,i]=y.useState();if(dt(()=>{i(new DocumentFragment)},[]),!n.open){let t=r;return t?gt.createPortal((0,T.jsx)(Qg,{scope:e.__scopeSelect,children:(0,T.jsx)(Ag.Slot,{scope:e.__scopeSelect,children:(0,T.jsx)(`div`,{children:e.children})})}),t):null}return(0,T.jsx)(n_,{...e,ref:t})});Xg.displayName=Yg;var Zg=10,[Qg,$g]=Ng(Yg),e_=`SelectContentImpl`,t_=E(`SelectContent.RemoveScroll`),n_=y.forwardRef((e,t)=>{let{__scopeSelect:n,position:r=`item-aligned`,onCloseAutoFocus:i,onEscapeKeyDown:a,onPointerDownOutside:o,side:s,sideOffset:c,align:l,alignOffset:u,arrowPadding:d,collisionBoundary:f,collisionPadding:p,sticky:m,hideWhenDetached:h,avoidCollisions:g,..._}=e,v=Lg(Yg,n),[b,x]=y.useState(null),[S,w]=y.useState(null),E=C(t,e=>x(e)),[D,O]=y.useState(null),[k,A]=y.useState(null),j=jg(n),[M,N]=y.useState(!1),P=y.useRef(!1);y.useEffect(()=>{if(b)return Aa(b)},[b]),Lt();let ee=y.useCallback(e=>{let[t,...n]=j().map(e=>e.ref.current),[r]=n.slice(-1),i=document.activeElement;for(let n of e)if(n===i||(n?.scrollIntoView({block:`nearest`}),n===t&&S&&(S.scrollTop=0),n===r&&S&&(S.scrollTop=S.scrollHeight),n?.focus(),document.activeElement!==i))return},[j,S]),F=y.useCallback(()=>ee([D,b]),[ee,D,b]);y.useEffect(()=>{M&&F()},[M,F]);let{onOpenChange:I,triggerPointerDownPosRef:L}=v;y.useEffect(()=>{if(b){let e={x:0,y:0},t=t=>{e={x:Math.abs(Math.round(t.pageX)-(L.current?.x??0)),y:Math.abs(Math.round(t.pageY)-(L.current?.y??0))}},n=n=>{e.x<=10&&e.y<=10?n.preventDefault():b.contains(n.target)||I(!1),document.removeEventListener(`pointermove`,t),L.current=null};return L.current!==null&&(document.addEventListener(`pointermove`,t),document.addEventListener(`pointerup`,n,{capture:!0,once:!0})),()=>{document.removeEventListener(`pointermove`,t),document.removeEventListener(`pointerup`,n,{capture:!0})}}},[b,I,L]),y.useEffect(()=>{let e=()=>I(!1);return window.addEventListener(`blur`,e),window.addEventListener(`resize`,e),()=>{window.removeEventListener(`blur`,e),window.removeEventListener(`resize`,e)}},[I]);let[R,z]=L_(e=>{let t=j().filter(e=>!e.disabled),n=R_(t,e,t.find(e=>e.ref.current===document.activeElement));n&&setTimeout(()=>n.ref.current.focus())}),B=y.useCallback((e,t,n)=>{let r=!P.current&&!n;(v.value!==void 0&&v.value===t||r)&&(O(e),r&&(P.current=!0))},[v.value]),V=y.useCallback(()=>b?.focus(),[b]),H=y.useCallback((e,t,n)=>{let r=!P.current&&!n;(v.value!==void 0&&v.value===t||r)&&A(e)},[v.value]),U=r===`popper`?o_:i_,te=U===o_?{side:s,sideOffset:c,align:l,alignOffset:u,arrowPadding:d,collisionBoundary:f,collisionPadding:p,sticky:m,hideWhenDetached:h,avoidCollisions:g}:{};return(0,T.jsx)(Qg,{scope:n,content:b,viewport:S,onViewportChange:w,itemRefCallback:B,selectedItem:D,onItemLeave:V,itemTextRefCallback:H,focusSelectedItem:F,selectedItemText:k,position:r,isPositioned:M,searchRef:R,children:(0,T.jsx)(Bo,{as:t_,allowPinchZoom:!0,children:(0,T.jsx)(Ut,{asChild:!0,trapped:v.open,onMountAutoFocus:e=>{e.preventDefault()},onUnmountAutoFocus:q(i,e=>{v.trigger?.focus({preventScroll:!0}),e.preventDefault()}),children:(0,T.jsx)(kt,{asChild:!0,disableOutsidePointerEvents:!0,onEscapeKeyDown:a,onPointerDownOutside:o,onFocusOutside:e=>e.preventDefault(),onDismiss:()=>v.onOpenChange(!1),children:(0,T.jsx)(U,{role:`listbox`,id:v.contentId,"data-state":v.open?`open`:`closed`,dir:v.dir,onContextMenu:e=>e.preventDefault(),..._,...te,onPlaced:()=>N(!0),ref:E,style:{display:`flex`,flexDirection:`column`,outline:`none`,..._.style},onKeyDown:q(_.onKeyDown,e=>{let t=e.ctrlKey||e.altKey||e.metaKey;if(e.key===`Tab`&&e.preventDefault(),!t&&e.key.length===1&&z(e.key),[`ArrowUp`,`ArrowDown`,`Home`,`End`].includes(e.key)){let t=j().filter(e=>!e.disabled).map(e=>e.ref.current);if([`ArrowUp`,`End`].includes(e.key)&&(t=t.slice().reverse()),[`ArrowUp`,`ArrowDown`].includes(e.key)){let n=e.target,r=t.indexOf(n);t=t.slice(r+1)}setTimeout(()=>ee(t)),e.preventDefault()}})})})})})})});n_.displayName=e_;var r_=`SelectItemAlignedPosition`,i_=y.forwardRef((e,t)=>{let{__scopeSelect:n,onPlaced:r,...i}=e,a=Lg(Yg,n),o=$g(Yg,n),[s,c]=y.useState(null),[l,u]=y.useState(null),d=C(t,e=>u(e)),f=jg(n),p=y.useRef(!1),m=y.useRef(!0),{viewport:h,selectedItem:g,selectedItemText:_,focusSelectedItem:v}=o,b=y.useCallback(()=>{if(a.trigger&&a.valueNode&&s&&l&&h&&g&&_){let e=a.trigger.getBoundingClientRect(),t=l.getBoundingClientRect(),n=a.valueNode.getBoundingClientRect(),i=_.getBoundingClientRect();if(a.dir!==`rtl`){let r=i.left-t.left,a=n.left-r,o=e.left-a,c=e.width+o,l=Math.max(c,t.width),u=window.innerWidth-Zg,d=kd(a,[Zg,Math.max(Zg,u-l)]);s.style.minWidth=c+`px`,s.style.left=d+`px`}else{let r=t.right-i.right,a=window.innerWidth-n.right-r,o=window.innerWidth-e.right-a,c=e.width+o,l=Math.max(c,t.width),u=window.innerWidth-Zg,d=kd(a,[Zg,Math.max(Zg,u-l)]);s.style.minWidth=c+`px`,s.style.right=d+`px`}let o=f(),c=window.innerHeight-Zg*2,u=h.scrollHeight,d=window.getComputedStyle(l),m=parseInt(d.borderTopWidth,10),v=parseInt(d.paddingTop,10),y=parseInt(d.borderBottomWidth,10),b=parseInt(d.paddingBottom,10),x=m+v+u+b+y,S=Math.min(g.offsetHeight*5,x),C=window.getComputedStyle(h),w=parseInt(C.paddingTop,10),T=parseInt(C.paddingBottom,10),E=e.top+e.height/2-Zg,D=c-E,O=g.offsetHeight/2,k=g.offsetTop+O,A=m+v+k,j=x-A;if(A<=E){let e=o.length>0&&g===o[o.length-1].ref.current;s.style.bottom=`0px`;let t=l.clientHeight-h.offsetTop-h.offsetHeight,n=A+Math.max(D,O+(e?T:0)+t+y);s.style.height=n+`px`}else{let e=o.length>0&&g===o[0].ref.current;s.style.top=`0px`;let t=Math.max(E,m+h.offsetTop+(e?w:0)+O)+j;s.style.height=t+`px`,h.scrollTop=A-E+h.offsetTop}s.style.margin=`${Zg}px 0`,s.style.minHeight=S+`px`,s.style.maxHeight=c+`px`,r?.(),requestAnimationFrame(()=>p.current=!0)}},[f,a.trigger,a.valueNode,s,l,h,g,_,a.dir,r]);dt(()=>b(),[b]);let[x,S]=y.useState();return dt(()=>{l&&S(window.getComputedStyle(l).zIndex)},[l]),(0,T.jsx)(s_,{scope:n,contentWrapper:s,shouldExpandOnScrollRef:p,onScrollButtonChange:y.useCallback(e=>{e&&m.current===!0&&(b(),v?.(),m.current=!1)},[b,v]),children:(0,T.jsx)(`div`,{ref:c,style:{display:`flex`,flexDirection:`column`,position:`fixed`,zIndex:x},children:(0,T.jsx)(J.div,{...i,ref:d,style:{boxSizing:`border-box`,maxHeight:`100%`,...i.style}})})})});i_.displayName=r_;var a_=`SelectPopperPosition`,o_=y.forwardRef((e,t)=>{let{__scopeSelect:n,align:r=`start`,collisionPadding:i=Zg,...a}=e,o=Fg(n);return(0,T.jsx)(Ki,{...o,...a,ref:t,align:r,collisionPadding:i,style:{boxSizing:`border-box`,...a.style,"--radix-select-content-transform-origin":`var(--radix-popper-transform-origin)`,"--radix-select-content-available-width":`var(--radix-popper-available-width)`,"--radix-select-content-available-height":`var(--radix-popper-available-height)`,"--radix-select-trigger-width":`var(--radix-popper-anchor-width)`,"--radix-select-trigger-height":`var(--radix-popper-anchor-height)`}})});o_.displayName=a_;var[s_,c_]=Ng(Yg,{}),l_=`SelectViewport`,u_=y.forwardRef((e,t)=>{let{__scopeSelect:n,nonce:r,...i}=e,a=$g(l_,n),o=c_(l_,n),s=C(t,a.onViewportChange),c=y.useRef(0);return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`style`,{dangerouslySetInnerHTML:{__html:`[data-radix-select-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-select-viewport]::-webkit-scrollbar{display:none}`},nonce:r}),(0,T.jsx)(Ag.Slot,{scope:n,children:(0,T.jsx)(J.div,{"data-radix-select-viewport":``,role:`presentation`,...i,ref:s,style:{position:`relative`,flex:1,overflow:`hidden auto`,...i.style},onScroll:q(i.onScroll,e=>{let t=e.currentTarget,{contentWrapper:n,shouldExpandOnScrollRef:r}=o;if(r?.current&&n){let e=Math.abs(c.current-t.scrollTop);if(e>0){let r=window.innerHeight-Zg*2,i=parseFloat(n.style.minHeight),a=parseFloat(n.style.height),o=Math.max(i,a);if(o0?s:0,n.style.justifyContent=`flex-end`)}}}c.current=t.scrollTop})})})]})});u_.displayName=l_;var d_=`SelectGroup`,[f_,p_]=Ng(d_),m_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=rn();return(0,T.jsx)(f_,{scope:n,id:i,children:(0,T.jsx)(J.div,{role:`group`,"aria-labelledby":i,...r,ref:t})})});m_.displayName=d_;var h_=`SelectLabel`,g_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=p_(h_,n);return(0,T.jsx)(J.div,{id:i.id,...r,ref:t})});g_.displayName=h_;var __=`SelectItem`,[v_,y_]=Ng(__),b_=y.forwardRef((e,t)=>{let{__scopeSelect:n,value:r,disabled:i=!1,textValue:a,...o}=e,s=Lg(__,n),c=$g(__,n),l=s.value===r,[u,d]=y.useState(a??``),[f,p]=y.useState(!1),m=C(t,e=>c.itemRefCallback?.(e,r,i)),h=rn(),g=y.useRef(`touch`),_=()=>{i||(s.onValueChange(r),s.onOpenChange(!1))};if(r===``)throw Error(`A must have a value prop that is not an empty string. This is because the Select value can be set to an empty string to clear the selection and show the placeholder.`);return(0,T.jsx)(v_,{scope:n,value:r,disabled:i,textId:h,isSelected:l,onItemTextChange:y.useCallback(e=>{d(t=>t||(e?.textContent??``).trim())},[]),children:(0,T.jsx)(Ag.ItemSlot,{scope:n,value:r,disabled:i,textValue:u,children:(0,T.jsx)(J.div,{role:`option`,"aria-labelledby":h,"data-highlighted":f?``:void 0,"aria-selected":l&&f,"data-state":l?`checked`:`unchecked`,"aria-disabled":i||void 0,"data-disabled":i?``:void 0,tabIndex:i?void 0:-1,...o,ref:m,onFocus:q(o.onFocus,()=>p(!0)),onBlur:q(o.onBlur,()=>p(!1)),onClick:q(o.onClick,()=>{g.current!==`mouse`&&_()}),onPointerUp:q(o.onPointerUp,()=>{g.current===`mouse`&&_()}),onPointerDown:q(o.onPointerDown,e=>{g.current=e.pointerType}),onPointerMove:q(o.onPointerMove,e=>{g.current=e.pointerType,i?c.onItemLeave?.():g.current===`mouse`&&e.currentTarget.focus({preventScroll:!0})}),onPointerLeave:q(o.onPointerLeave,e=>{e.currentTarget===document.activeElement&&c.onItemLeave?.()}),onKeyDown:q(o.onKeyDown,e=>{c.searchRef?.current!==``&&e.key===` `||(Og.includes(e.key)&&_(),e.key===` `&&e.preventDefault())})})})})});b_.displayName=__;var x_=`SelectItemText`,S_=y.forwardRef((e,t)=>{let{__scopeSelect:n,className:r,style:i,...a}=e,o=Lg(x_,n),s=$g(x_,n),c=y_(x_,n),l=zg(x_,n),[u,d]=y.useState(null),f=C(t,e=>d(e),c.onItemTextChange,e=>s.itemTextRefCallback?.(e,c.value,c.disabled)),p=u?.textContent,m=y.useMemo(()=>(0,T.jsx)(`option`,{value:c.value,disabled:c.disabled,children:p},c.value),[c.disabled,c.value,p]),{onNativeOptionAdd:h,onNativeOptionRemove:g}=l;return dt(()=>(h(m),()=>g(m)),[h,g,m]),(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(J.span,{id:c.textId,...a,ref:f}),c.isSelected&&o.valueNode&&!o.valueNodeHasChildren?gt.createPortal(a.children,o.valueNode):null]})});S_.displayName=x_;var C_=`SelectItemIndicator`,w_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e;return y_(C_,n).isSelected?(0,T.jsx)(J.span,{"aria-hidden":!0,...r,ref:t}):null});w_.displayName=C_;var T_=`SelectScrollUpButton`,E_=y.forwardRef((e,t)=>{let n=$g(T_,e.__scopeSelect),r=c_(T_,e.__scopeSelect),[i,a]=y.useState(!1),o=C(t,r.onScrollButtonChange);return dt(()=>{if(n.viewport&&n.isPositioned){let e=function(){a(t.scrollTop>0)},t=n.viewport;return e(),t.addEventListener(`scroll`,e),()=>t.removeEventListener(`scroll`,e)}},[n.viewport,n.isPositioned]),i?(0,T.jsx)(k_,{...e,ref:o,onAutoScroll:()=>{let{viewport:e,selectedItem:t}=n;e&&t&&(e.scrollTop-=t.offsetHeight)}}):null});E_.displayName=T_;var D_=`SelectScrollDownButton`,O_=y.forwardRef((e,t)=>{let n=$g(D_,e.__scopeSelect),r=c_(D_,e.__scopeSelect),[i,a]=y.useState(!1),o=C(t,r.onScrollButtonChange);return dt(()=>{if(n.viewport&&n.isPositioned){let e=function(){let e=t.scrollHeight-t.clientHeight;a(Math.ceil(t.scrollTop)t.removeEventListener(`scroll`,e)}},[n.viewport,n.isPositioned]),i?(0,T.jsx)(k_,{...e,ref:o,onAutoScroll:()=>{let{viewport:e,selectedItem:t}=n;e&&t&&(e.scrollTop+=t.offsetHeight)}}):null});O_.displayName=D_;var k_=y.forwardRef((e,t)=>{let{__scopeSelect:n,onAutoScroll:r,...i}=e,a=$g(`SelectScrollButton`,n),o=y.useRef(null),s=jg(n),c=y.useCallback(()=>{o.current!==null&&(window.clearInterval(o.current),o.current=null)},[]);return y.useEffect(()=>()=>c(),[c]),dt(()=>{s().find(e=>e.ref.current===document.activeElement)?.ref.current?.scrollIntoView({block:`nearest`})},[s]),(0,T.jsx)(J.div,{"aria-hidden":!0,...i,ref:t,style:{flexShrink:0,...i.style},onPointerDown:q(i.onPointerDown,()=>{o.current===null&&(o.current=window.setInterval(r,50))}),onPointerMove:q(i.onPointerMove,()=>{a.onItemLeave?.(),o.current===null&&(o.current=window.setInterval(r,50))}),onPointerLeave:q(i.onPointerLeave,()=>{c()})})}),A_=`SelectSeparator`,j_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e;return(0,T.jsx)(J.div,{"aria-hidden":!0,...r,ref:t})});j_.displayName=A_;var M_=`SelectArrow`,N_=y.forwardRef((e,t)=>{let{__scopeSelect:n,...r}=e,i=Fg(n),a=Lg(M_,n),o=$g(M_,n);return a.open&&o.position===`popper`?(0,T.jsx)(qi,{...i,...r,ref:t}):null});N_.displayName=M_;var P_=`SelectBubbleInput`,F_=y.forwardRef(({__scopeSelect:e,value:t,...n},r)=>{let i=y.useRef(null),a=C(r,i),o=Vf(t);return y.useEffect(()=>{let e=i.current;if(!e)return;let n=window.HTMLSelectElement.prototype,r=Object.getOwnPropertyDescriptor(n,`value`).set;if(o!==t&&r){let n=new Event(`change`,{bubbles:!0});r.call(e,t),e.dispatchEvent(n)}},[o,t]),(0,T.jsx)(J.select,{...n,style:{...ap,...n.style},ref:a,defaultValue:t})});F_.displayName=P_;function I_(e){return e===``||e===void 0}function L_(e){let t=xt(e),n=y.useRef(``),r=y.useRef(0),i=y.useCallback(e=>{let i=n.current+e;t(i),(function e(t){n.current=t,window.clearTimeout(r.current),t!==``&&(r.current=window.setTimeout(()=>e(``),1e3))})(i)},[t]),a=y.useCallback(()=>{n.current=``,window.clearTimeout(r.current)},[]);return y.useEffect(()=>()=>window.clearTimeout(r.current),[]),[n,i,a]}function R_(e,t,n){let r=t.length>1&&Array.from(t).every(e=>e===t[0])?t[0]:t,i=n?e.indexOf(n):-1,a=z_(e,Math.max(i,0));r.length===1&&(a=a.filter(e=>e!==n));let o=a.find(e=>e.textValue.toLowerCase().startsWith(r.toLowerCase()));return o===n?void 0:o}function z_(e,t){return e.map((n,r)=>e[(t+r)%e.length])}var B_=Bg,V_=Hg,H_=Wg,U_=Kg,W_=Jg,G_=Xg,K_=u_,q_=b_,J_=S_,Y_=w_,X_=E_,Z_=O_;function Q_({...e}){return(0,T.jsx)(B_,{"data-slot":`select`,...e})}function $_({...e}){return(0,T.jsx)(H_,{"data-slot":`select-value`,...e})}function ev({className:e,size:t=`default`,children:n,...r}){return(0,T.jsxs)(V_,{"data-slot":`select-trigger`,"data-size":t,className:at(`border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,e),...r,children:[n,(0,T.jsx)(U_,{asChild:!0,children:(0,T.jsx)(X,{className:`size-4 opacity-50`})})]})}function tv({className:e,children:t,position:n=`popper`,...r}){return(0,T.jsx)(W_,{children:(0,T.jsxs)(G_,{"data-slot":`select-content`,className:at(`bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md`,n===`popper`&&`data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1`,e),position:n,...r,children:[(0,T.jsx)(rv,{}),(0,T.jsx)(K_,{className:at(`p-1`,n===`popper`&&`h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1`),children:t}),(0,T.jsx)(iv,{})]})})}function nv({className:e,children:t,...n}){return(0,T.jsxs)(q_,{"data-slot":`select-item`,className:at(`focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2`,e),...n,children:[(0,T.jsx)(`span`,{className:`absolute right-2 flex size-3.5 items-center justify-center`,children:(0,T.jsx)(Y_,{children:(0,T.jsx)(Tl,{className:`size-4`})})}),(0,T.jsx)(J_,{children:t})]})}function rv({className:e,...t}){return(0,T.jsx)(X_,{"data-slot":`select-scroll-up-button`,className:at(`flex cursor-default items-center justify-center py-1`,e),...t,children:(0,T.jsx)(Dl,{className:`size-4`})})}function iv({className:e,...t}){return(0,T.jsx)(Z_,{"data-slot":`select-scroll-down-button`,className:at(`flex cursor-default items-center justify-center py-1`,e),...t,children:(0,T.jsx)(X,{className:`size-4`})})}function av({title:e,icon:t,children:n,className:r=``}){return(0,T.jsxs)(`div`,{className:`border rounded-lg p-4 bg-card ${r}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-3`,children:[t,(0,T.jsx)(`h3`,{className:`text-sm font-semibold text-foreground`,children:e})]}),(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:n})]})}function ov({agent:e,open:t,onOpenChange:n}){let r=e.source===`directory`?(0,T.jsx)(Ul,{className:`h-4 w-4 text-muted-foreground`}):e.source===`in_memory`?(0,T.jsx)(Rl,{className:`h-4 w-4 text-muted-foreground`}):(0,T.jsx)(Wl,{className:`h-4 w-4 text-muted-foreground`}),i=e.source===`directory`?`Local`:e.source===`in_memory`?`In-Memory`:`Gallery`;return(0,T.jsx)(Im,{open:t,onOpenChange:n,children:(0,T.jsxs)(Lm,{className:`max-w-4xl max-h-[90vh] flex flex-col`,children:[(0,T.jsxs)(Rm,{className:`px-6 pt-6 flex-shrink-0`,children:[(0,T.jsx)(zm,{children:`Agent Details`}),(0,T.jsx)(Vm,{onClose:()=>n(!1)})]}),(0,T.jsxs)(`div`,{className:`px-6 pb-6 overflow-y-auto flex-1`,children:[(0,T.jsxs)(`div`,{className:`mb-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-3 mb-2`,children:[(0,T.jsx)(Sl,{className:`h-6 w-6 text-primary`}),(0,T.jsx)(`h2`,{className:`text-xl font-semibold text-foreground`,children:e.name||e.id})]}),e.description&&(0,T.jsx)(`p`,{className:`text-muted-foreground`,children:e.description})]}),(0,T.jsx)(`div`,{className:`h-px bg-border mb-6`}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-4 mb-4`,children:[(e.model_id||e.chat_client_type)&&(0,T.jsx)(av,{title:`Model & Client`,icon:(0,T.jsx)(Sl,{className:`h-4 w-4 text-muted-foreground`}),children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[e.model_id&&(0,T.jsx)(`div`,{className:`font-mono text-foreground`,children:e.model_id}),e.chat_client_type&&(0,T.jsxs)(`div`,{className:`text-xs`,children:[`(`,e.chat_client_type,`)`]})]})}),(0,T.jsx)(av,{title:`Source`,icon:r,children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-foreground`,children:i}),e.module_path&&(0,T.jsx)(`div`,{className:`font-mono text-xs break-all`,children:e.module_path})]})}),(0,T.jsx)(av,{title:`Environment`,icon:e.has_env?(0,T.jsx)(Ml,{className:`h-4 w-4 text-orange-500`}):(0,T.jsx)(kl,{className:`h-4 w-4 text-green-500`}),className:`md:col-span-2`,children:(0,T.jsx)(`div`,{className:e.has_env?`text-orange-600 dark:text-orange-400`:`text-green-600 dark:text-green-400`,children:e.has_env?`Requires environment variables`:`No environment variables required`})})]}),e.instructions&&(0,T.jsx)(av,{title:`Instructions`,icon:(0,T.jsx)(Hl,{className:`h-4 w-4 text-muted-foreground`}),className:`mb-4`,children:(0,T.jsx)(`div`,{className:`text-sm text-foreground leading-relaxed whitespace-pre-wrap`,children:e.instructions})}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-4`,children:[e.tools&&e.tools.length>0&&(0,T.jsx)(av,{title:`Tools (${e.tools.length})`,icon:(0,T.jsx)(au,{className:`h-4 w-4 text-muted-foreground`}),children:(0,T.jsx)(`ul`,{className:`space-y-1`,children:e.tools.map((e,t)=>(0,T.jsxs)(`li`,{className:`font-mono text-xs text-foreground`,children:[`• `,e]},t))})}),e.middleware&&e.middleware.length>0&&(0,T.jsx)(av,{title:`Middlewares (${e.middleware.length})`,icon:(0,T.jsx)(au,{className:`h-4 w-4 text-muted-foreground`}),children:(0,T.jsx)(`ul`,{className:`space-y-1`,children:e.middleware.map((e,t)=>(0,T.jsxs)(`li`,{className:`font-mono text-xs text-foreground`,children:[`• `,e]},t))})}),e.context_provider&&(0,T.jsx)(av,{title:`Context Provider`,icon:(0,T.jsx)(Rl,{className:`h-4 w-4 text-muted-foreground`}),className:!e.middleware||e.middleware.length===0?`md:col-start-2`:``,children:(0,T.jsx)(`div`,{className:`font-mono text-xs text-foreground`,children:e.context_provider})})]})]})]})})}Dd();var sv=50,cv=`[Earlier streaming content omitted after refresh] -`;function av(e){let t=e.accumulatedParts?.map(e=>({...e}))??(e.accumulatedText?[{itemId:e.lastMessageId,contentIndex:0,type:e.accumulatedTextType??`text`,text:e.accumulatedText}]:[]);return e.accumulatedTextIsPreview&&t.unshift({itemId:e.lastMessageId,contentIndex:-1,type:`text`,text:iv}),t}function ov(e,t){return t===`refusal`?{type:`refusal`,refusal:e}:{type:`text`,text:e}}function sv(e){return e.map(e=>ov(e.text,e.type))}function cv({item:e,toolCalls:t=[],toolResults:n=[]}){let[r,i]=(0,y.useState)(!1),[a,o]=(0,y.useState)(!1),[s,c]=(0,y.useState)(!1),l=Q(e=>e.showToolCalls),u=()=>e.type===`message`?e.content.filter(e=>e.type===`text`||e.type===`output_text`||e.type===`refusal`).map(e=>`refusal`in e?e.refusal:e.text).join(` -`):``,d=async()=>{let e=u();if(e)try{await navigator.clipboard.writeText(e),o(!0),setTimeout(()=>o(!1),2e3)}catch(e){console.error(`Failed to copy:`,e)}};if(e.type===`message`){let o=e.role===`user`,f=e.status===`incomplete`,p=o?wu:f?Ol:Sl,m=u();return(0,T.jsxs)(`div`,{className:`flex gap-3 ${o?`flex-row-reverse`:``}`,onMouseEnter:()=>i(!0),onMouseLeave:()=>i(!1),children:[(0,T.jsx)(`div`,{className:`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${o?`bg-primary text-primary-foreground`:f?`bg-orange-100 dark:bg-orange-900 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800`:`bg-muted`}`,children:(0,T.jsx)(p,{className:`h-4 w-4`})}),(0,T.jsxs)(`div`,{className:`flex flex-col space-y-1 ${o?`items-end`:`items-start`} max-w-[80%]`,children:[(0,T.jsxs)(`div`,{className:`relative group`,children:[(0,T.jsxs)(`div`,{className:`rounded px-3 py-2 text-sm ${o?`bg-primary text-primary-foreground`:f?`bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800`:`bg-muted`}`,children:[f&&(0,T.jsxs)(`div`,{className:`flex items-start gap-2 mb-2`,children:[(0,T.jsx)(Ol,{className:`h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`Unable to process request`})]}),(0,T.jsx)(`div`,{className:f?`text-xs leading-relaxed break-all`:``,children:(0,T.jsx)(Sg,{item:e})})]}),m&&r&&(0,T.jsx)(`button`,{onClick:d,className:`absolute top-1 right-1 +`;function lv(e){let t=e.accumulatedParts?.map(e=>({...e}))??(e.accumulatedText?[{itemId:e.lastMessageId,contentIndex:0,type:e.accumulatedTextType??`text`,text:e.accumulatedText}]:[]);return e.accumulatedTextIsPreview&&t.unshift({itemId:e.lastMessageId,contentIndex:-1,type:`text`,text:cv}),t}function uv(e,t){return t===`refusal`?{type:`refusal`,refusal:e}:{type:`text`,text:e}}function dv(e){return e.map(e=>uv(e.text,e.type))}function fv({item:e,toolCalls:t=[],toolResults:n=[]}){let[r,i]=(0,y.useState)(!1),[a,o]=(0,y.useState)(!1),[s,c]=(0,y.useState)(!1),l=Q(e=>e.showToolCalls),u=()=>e.type===`message`?e.content.filter(e=>e.type===`text`||e.type===`output_text`||e.type===`refusal`).map(e=>`refusal`in e?e.refusal:e.text).join(` +`):``,d=async()=>{let e=u();if(e)try{await navigator.clipboard.writeText(e),o(!0),setTimeout(()=>o(!1),2e3)}catch(e){console.error(`Failed to copy:`,e)}};if(e.type===`message`){let o=e.role===`user`,f=e.status===`incomplete`,p=o?wu:f?Ol:Sl,m=u();return(0,T.jsxs)(`div`,{className:`flex gap-3 ${o?`flex-row-reverse`:``}`,onMouseEnter:()=>i(!0),onMouseLeave:()=>i(!1),children:[(0,T.jsx)(`div`,{className:`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${o?`bg-primary text-primary-foreground`:f?`bg-orange-100 dark:bg-orange-900 text-orange-600 dark:text-orange-400 border-orange-200 dark:border-orange-800`:`bg-muted`}`,children:(0,T.jsx)(p,{className:`h-4 w-4`})}),(0,T.jsxs)(`div`,{className:`flex flex-col space-y-1 ${o?`items-end`:`items-start`} max-w-[80%]`,children:[(0,T.jsxs)(`div`,{className:`relative group`,children:[(0,T.jsxs)(`div`,{className:`rounded px-3 py-2 text-sm ${o?`bg-primary text-primary-foreground`:f?`bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800`:`bg-muted`}`,children:[f&&(0,T.jsxs)(`div`,{className:`flex items-start gap-2 mb-2`,children:[(0,T.jsx)(Ol,{className:`h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`Unable to process request`})]}),(0,T.jsx)(`div`,{className:f?`text-xs leading-relaxed break-all`:``,children:(0,T.jsx)(Eg,{item:e})})]}),m&&r&&(0,T.jsx)(`button`,{onClick:d,className:`absolute top-1 right-1 p-1.5 rounded-md border shadow-sm bg-background hover:bg-accent text-muted-foreground hover:text-foreground transition-all duration-200 ease-in-out - opacity-0 group-hover:opacity-100`,title:a?`Copied!`:`Copy message`,children:a?(0,T.jsx)(wl,{className:`h-3.5 w-3.5 text-green-600 dark:text-green-400`}):(0,T.jsx)(Ll,{className:`h-3.5 w-3.5`})})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground font-mono`,children:[(0,T.jsx)(`span`,{children:e.created_at?new Date(e.created_at*1e3).toLocaleTimeString():new Date().toLocaleTimeString()}),!o&&e.usage&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{children:`•`}),(0,T.jsxs)(`span`,{className:`flex items-center gap-1`,children:[(0,T.jsxs)(`span`,{className:`text-blue-600 dark:text-blue-400`,children:[`↑`,e.usage.input_tokens]}),(0,T.jsxs)(`span`,{className:`text-green-600 dark:text-green-400`,children:[`↓`,e.usage.output_tokens]}),(0,T.jsxs)(`span`,{children:[`(`,e.usage.total_tokens,` tokens)`]})]})]}),!o&&l&&t.length>0&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{children:`•`}),(0,T.jsxs)(`button`,{onClick:()=>c(!s),className:`flex items-center gap-1 hover:text-foreground transition-colors`,title:`${t.length} tool call${t.length>1?`s`:``} - click to ${s?`hide`:`show`} details`,children:[(0,T.jsx)(Eu,{className:`h-3 w-3`}),(0,T.jsx)(`span`,{children:t.length})]})]})]}),!o&&s&&t.length>0&&(0,T.jsx)(`div`,{className:`mt-2 ml-0 p-3 bg-muted/30 rounded-md border border-muted`,children:(0,T.jsx)(`div`,{className:`space-y-2`,children:t.map(e=>{let t=n.find(t=>t.call_id===e.call_id);return(0,T.jsx)(`div`,{className:`text-xs`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,T.jsx)(Eu,{className:`h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0`}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`font-mono text-muted-foreground`,children:[(0,T.jsx)(`span`,{className:`text-blue-600 dark:text-blue-400`,children:e.name}),(0,T.jsx)(`span`,{className:`text-muted-foreground/60 ml-1`,children:e.arguments&&(0,T.jsxs)(`span`,{className:`break-all`,children:[`(`,e.arguments,`)`]})})]}),t&&t.output&&(0,T.jsx)(`div`,{className:`mt-1 pl-5 border-l-2 border-green-600/20`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-1`,children:[(0,T.jsx)(Tl,{className:`h-3 w-3 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0`}),(0,T.jsx)(`pre`,{className:`font-mono text-muted-foreground whitespace-pre-wrap break-all`,children:t.output.substring(0,200)+(t.output.length>200?`...`:``)})]})}),e.status===`incomplete`&&(0,T.jsx)(`div`,{className:`mt-1 pl-5 border-l-2 border-orange-600/20`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-1`,children:[(0,T.jsx)(Du,{className:`h-3 w-3 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`font-mono text-orange-600 dark:text-orange-400`,children:`Failed`})]})})]})]})},e.id)})})})]})]})}return e.type===`function_call`||e.type,null}function lv({selectedAgent:e,onDebugEvent:t}){let n=Q(e=>e.currentConversation),r=Q(e=>e.availableConversations),i=Q(e=>e.chatItems),a=Q(e=>e.isStreaming),o=Q(e=>e.isSubmitting),s=Q(e=>e.loadingConversations),c=Q(e=>e.uiMode),l=Q(e=>e.conversationUsage),u=Q(e=>e.pendingApprovals),d=Q(e=>e.oaiMode),f=Q(e=>e.streamingEnabled),p=Q(e=>e.setCurrentConversation),m=Q(e=>e.setAvailableConversations),h=Q(e=>e.setChatItems),g=Q(e=>e.setIsStreaming),_=Q(e=>e.setIsSubmitting),v=Q(e=>e.setLoadingConversations),b=Q(e=>e.updateConversationUsage),x=Q(e=>e.setPendingApprovals),[S,C]=(0,y.useState)(!1),[w,E]=(0,y.useState)(null),[D,O]=(0,y.useState)(!1),[k,A]=(0,y.useState)(!1),{isCancelling:j,createAbortSignal:M,handleCancel:N,resetCancelling:P}=wh(),{isDragOver:ee,droppedFiles:F,clearDroppedFiles:I,dragHandlers:L}=Eh({disabled:o||a}),R=(0,y.useRef)(null),z=(0,y.useRef)(null),B=(0,y.useRef)(null),V=(0,y.useRef)(!1),H=(0,y.useRef)(``),U=(0,y.useRef)([]),te=(0,y.useRef)(0),ne=(0,y.useCallback)((e,t=`in_progress`,n=!1)=>{let r=performance.now();if(!n&&r-te.current{if(n.id!==e||n.type!==`message`)return n;let r=n.content.filter(e=>e.type!==`text`&&e.type!==`output_text`&&e.type!==`refusal`),i=sv(U.current),o=n.content.filter(e=>e.type===`text`||e.type===`output_text`||e.type===`refusal`);return JSON.stringify(o)===JSON.stringify(i)&&n.status===t?n:(a=!0,{...n,content:[...r,...i],status:t})});a?(te.current=r,h(o)):n&&(te.current=r)},[h]);(0,y.useEffect)(()=>{if(!z.current)return;let e=R.current?.querySelector(`[data-radix-scroll-area-viewport]`),t=!1;if(e){let{scrollTop:n,scrollHeight:r,clientHeight:i}=e,a=r-n-i<100;t=V.current||a}else t=!0;t&&z.current.scrollIntoView({behavior:a?`instant`:`smooth`}),V.current&&!a&&(V.current=!1)},[i,a]),(0,y.useEffect)(()=>{},[a,o]),(0,y.useEffect)(()=>{let n=async(e,n,r)=>{let i=bh(n.id);if(!i||!i.responseId){g(!1);return}try{let a={model:r.id,input:[],stream:!0,conversation:n.id},o=Lh.streamAgentExecutionOpenAIDirect(r.id,a,n.id,void 0,i.responseId);te.current=0;for await(let n of o){if(t(n),n.type===`response.completed`){let e=n.response?.usage;e&&(B.current={input_tokens:e.input_tokens,output_tokens:e.output_tokens,total_tokens:e.total_tokens});continue}if(n.type===`response.failed`){let t=n.response?.error,r=t?typeof t==`object`&&`message`in t?t.message:JSON.stringify(t):`Request failed`;if(H.current){ne(e.id,`incomplete`,!0),g(!1);return}let i=Q.getState().chatItems;h(i.map(t=>t.id===e.id&&t.type===`message`?{...t,content:[{type:`text`,text:H.current||r}],status:`incomplete`}:t)),g(!1);return}if(n.type===`response.function_approval.requested`){let t=n;ne(e.id,`in_progress`,!0),x([...Q.getState().pendingApprovals,{request_id:t.request_id,function_call:t.function_call}]);continue}if(n.type===`response.function_approval.responded`){let e=n;x(Q.getState().pendingApprovals.filter(t=>t.request_id!==e.request_id));continue}if(n.type===`error`){let t=n.message||`An error occurred`;if(H.current){ne(e.id,`incomplete`,!0),g(!1);return}let r=Q.getState().chatItems;h(r.map(n=>n.id===e.id&&n.type===`message`?{...n,content:[{type:`text`,text:H.current||t}],status:`incomplete`}:n)),g(!1);return}(n.type===`response.output_text.delta`||n.type===`response.refusal.delta`)&&`delta`in n&&n.delta&&(U.current=uh(U.current,n),H.current=U.current.map(e=>e.text).join(``),ne(e.id))}let s=B.current;ne(e.id,`in_progress`,!0);let c=Q.getState().chatItems;h(c.map(t=>t.id===e.id&&t.type===`message`?{...t,status:`completed`,usage:s||void 0}:t)),g(!1),s&&b(s.total_tokens),B.current=null}catch(t){let n=Q.getState().chatItems;h(n.map(n=>n.id===e.id&&n.type===`message`?{...n,content:[{type:`text`,text:`Error resuming stream: ${t instanceof Error?t.message:`Unknown error`}`}],status:`incomplete`}:n)),g(!1)}};h([]),g(!1),p(void 0),H.current=``,U.current=[],te.current=0,(async()=>{if(e){v(!0);try{try{let{data:r}=await Lh.listConversations(e.id);if(m(r),r.length>0){let i=r[0];p(i);try{let r=[],a=!0,o,s=[];for(;a;){let e=await Lh.listConversationItems(i.id,{order:`asc`,after:o});r=r.concat(e.data),a=e.has_more,e.metadata?.traces&&e.metadata.traces.length>0&&(s=e.metadata.traces),a&&e.data.length>0&&(o=e.data[e.data.length-1].id)}if(h(r),g(!1),s.length>0){t(`clear`);for(let e of s)t({type:`response.trace.completed`,data:e,sequence_number:0})}let c=bh(i.id);if(c&&!c.completed){let t=av(c);U.current=t,H.current=t.map(e=>e.text).join(``);let a={id:c.lastMessageId||`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:sv(t),status:`in_progress`};h([...r,a]),g(!0),setTimeout(()=>{n(a,i,e)},100)}setTimeout(()=>{z.current?.scrollIntoView({behavior:`smooth`})},100)}catch{console.debug(`No items found for conversation ${i.id}, starting fresh`),h([]),g(!1)}return}}catch{}let r=`devui_convs_${e.id}`,i=localStorage.getItem(r);if(i)try{let e=JSON.parse(i);if(e.length>0)try{await Lh.listConversationItems(e[0].id),m(e),p(e[0]),h([]),g(!1);return}catch{console.debug(`Cached conversation ${e[0].id} no longer exists, clearing cache`),localStorage.removeItem(r)}}catch{localStorage.removeItem(r)}let a=await Lh.createConversation({agent_id:e.id});p(a),m([a]),h([]),g(!1),E(null),localStorage.setItem(r,JSON.stringify([a]))}catch(e){m([]),h([]),g(!1),E({message:e instanceof Error?e.message:`Failed to create conversation`,type:`conversation_creation_error`})}finally{v(!1)}}})()},[e,t,ne,h,g,v,m,p,x,b]);let re=(0,y.useCallback)(async()=>{if(e)try{let n=await Lh.createConversation({agent_id:e.id});p(n),m([n,...Q.getState().availableConversations]),h([]),g(!1),E(null),Q.setState({conversationUsage:{total_tokens:0,message_count:0}}),H.current=``,U.current=[],t(`clear`);let i=`devui_convs_${e.id}`,a=[n,...r];localStorage.setItem(i,JSON.stringify(a))}catch(e){E({message:e instanceof Error?e.message:`Failed to create conversation`,type:`conversation_creation_error`})}},[e,t,p,m,h,g]),ie=(0,y.useCallback)(async(e,i)=>{if(i&&(i.preventDefault(),i.stopPropagation()),confirm(`Delete this conversation? This cannot be undone.`))try{if(await Lh.deleteConversation(e)){let i=r.filter(t=>t.id!==e);if(m(i),n?.id===e)if(i.length>0){let e=i[0];p(e),h([]),g(!1)}else p(void 0),h([]),g(!1),Q.setState({conversationUsage:{total_tokens:0,message_count:0}}),H.current=``,U.current=[];t(`clear`)}}catch{alert(`Failed to delete conversation. Please try again.`)}},[r,n,t,m,p,h,g]),ae=(0,y.useCallback)(async()=>{if(D||!e)return;O(!0);let t=Q.getState().addToast,n=Q.getState().updateAgent;try{await Lh.reloadEntity(e.id),n(await Lh.getAgentInfo(e.id)),t({message:`${e.name} has been reloaded successfully`,type:`success`})}catch(e){t({message:`Failed to reload: ${e instanceof Error?e.message:`Failed to reload entity`}`,type:`error`,duration:6e3})}finally{O(!1)}},[D,e]),oe=(0,y.useCallback)(async e=>{let n=r.find(t=>t.id===e);if(n){p(n),t(`clear`);try{let n=[],r=!0,i,a=[];for(;r;){let t=await Lh.listConversationItems(e,{order:`asc`,after:i});n=n.concat(t.data),r=t.has_more,t.metadata?.traces&&t.metadata.traces.length>0&&(a=t.metadata.traces),r&&t.data.length>0&&(i=t.data[t.data.length-1].id)}let o=n;if(h(o),g(!1),a.length>0)for(let e of a)t({type:`response.trace.completed`,data:e,sequence_number:0});Q.setState({conversationUsage:{total_tokens:0,message_count:o.length}});let s=bh(e);if(s?.accumulatedText){let e=av(s);U.current=e,H.current=e.map(e=>e.text).join(``);let t={id:`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:sv(e),status:`in_progress`};h([...o,t]),g(!0)}setTimeout(()=>{z.current?.scrollIntoView({behavior:`smooth`})},100)}catch{console.debug(`No items found for conversation ${e}, starting with empty chat`),h([]),g(!1),Q.setState({conversationUsage:{total_tokens:0,message_count:0}})}H.current=``,U.current=[]}},[r,t,p,h,g]),se=async(e,t)=>{let r=u.find(t=>t.request_id===e);if(!r)return;let i=Math.floor(Date.now()/1e3),a={id:`user-approval-${Date.now()}`,type:`message`,role:`user`,content:[{type:`function_approval_request`,request_id:e,status:t?`approved`:`rejected`,function_call:r.function_call}],status:`completed`,created_at:i},o=Q.getState().chatItems;h([...o,a]);let s={input:[{type:`message`,role:`user`,content:[{type:`function_approval_response`,request_id:e,approved:t,function_call:r.function_call}]}],conversation_id:n?.id};return x(Q.getState().pendingApprovals.filter(t=>t.request_id!==e)),s},ce=(0,y.useCallback)(async r=>{if(!e)return;let i=r.input.some(e=>e.type===`message`&&Array.isArray(e.content)&&e.content.some(e=>e.type===`function_approval_response`)),a=[];for(let e of r.input)if(e.type===`message`&&Array.isArray(e.content)){for(let t of e.content)if(t.type===`input_text`)a.push({type:`text`,text:t.text});else if(t.type===`input_image`)a.push({type:`input_image`,image_url:t.image_url||``,detail:`auto`});else if(t.type===`input_file`){let e=t;a.push({type:`input_file`,file_data:e.file_data,filename:e.filename})}}let o=Math.floor(Date.now()/1e3);if(!i&&a.length>0){let e={id:`user-${Date.now()}`,type:`message`,role:`user`,content:a,status:`completed`,created_at:o};h([...Q.getState().chatItems,e])}g(!0);let s={id:`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:[],status:`in_progress`,created_at:o};h([...Q.getState().chatItems,s]);try{let i=n;if(!i)try{i=await Lh.createConversation({agent_id:e.id}),p(i),m([i,...Q.getState().availableConversations]),E(null)}catch(e){E({message:e instanceof Error?e.message:`Failed to create conversation`,type:`conversation_creation_error`}),_(!1),g(!1);return}i?.id&&Lh.clearStreamingState(i.id);let a={input:r.input,conversation_id:i?.id};H.current=``,U.current=[],te.current=0;let o=M(),c=Lh.streamAgentExecutionOpenAI(e.id,a,o);for await(let e of c){if(t(e),e.type===`response.completed`){let t=e.response?.usage;t&&(B.current={input_tokens:t.input_tokens,output_tokens:t.output_tokens,total_tokens:t.total_tokens});continue}if(e.type===`response.failed`){let t=e.response?.error,n=`Request failed`;if(t&&(typeof t==`object`&&`message`in t?(n=t.message,`code`in t&&t.code&&(n+=` (Code: ${t.code})`)):typeof t==`string`&&(n=t)),H.current){ne(s.id,`incomplete`,!0),g(!1);return}let r=Q.getState().chatItems;h(r.map(e=>e.id===s.id&&e.type===`message`?{...e,content:[{type:`text`,text:H.current||n}],status:`incomplete`}:e)),g(!1);return}if(e.type===`response.function_approval.requested`){let t=e;ne(s.id,`in_progress`,!0),x([...Q.getState().pendingApprovals,{request_id:t.request_id,function_call:t.function_call}]);let n=Q.getState().chatItems;h(n.map(e=>e.id===s.id&&e.type===`message`?{...e,content:[...e.content,{type:`function_approval_request`,request_id:t.request_id,status:`pending`,function_call:t.function_call}],status:`in_progress`}:e));continue}if(e.type===`response.function_call_arguments.delta`){let t=e,n=Q.getState().chatItems;h(n.map(e=>e.type===`function_call`&&e.call_id===t.item_id?{...e,arguments:(e.arguments||``)+(t.delta||``)}:e));continue}if(e.type===`response.function_result.complete`){let t=e,n={id:`result-${Date.now()}`,type:`function_call_output`,call_id:t.call_id,output:t.output,status:t.status===`completed`?`completed`:`incomplete`,created_at:Math.floor(Date.now()/1e3)},r=Q.getState().chatItems;h([...r,n]);continue}if(e.type===`error`){let t=e.message||`An error occurred`;if(H.current){ne(s.id,`incomplete`,!0),g(!1);return}let n=Q.getState().chatItems;h(n.map(e=>e.id===s.id&&e.type===`message`?{...e,content:[{type:`text`,text:t}],status:`incomplete`}:e)),g(!1);return}if(e.type===`response.output_item.added`){let t=e.item;if(ne(s.id,`in_progress`,!0),t.type===`function_call`){let e=t,n={id:e.id||`call-${Date.now()}`,type:`function_call`,name:e.name,arguments:e.arguments||``,call_id:e.call_id,status:e.status||`in_progress`,created_at:Math.floor(Date.now()/1e3)},r=Q.getState().chatItems;h([...r,n]);continue}let n=Q.getState().chatItems;h(n.map(e=>{if(e.id===s.id&&e.type===`message`){let n=e.content,r=null;if(t.type===`output_image`?r={type:`output_image`,image_url:t.image_url,alt_text:t.alt_text,mime_type:t.mime_type}:t.type===`output_file`?r={type:`output_file`,filename:t.filename,file_url:t.file_url,file_data:t.file_data,mime_type:t.mime_type}:t.type===`output_data`&&(r={type:`output_data`,data:t.data,mime_type:t.mime_type,description:t.description}),r)return{...e,content:[...n,r],status:`in_progress`}}return e}));continue}(e.type===`response.output_text.delta`||e.type===`response.refusal.delta`)&&`delta`in e&&e.delta&&(U.current=uh(U.current,e),H.current=U.current.map(e=>e.text).join(``),ne(s.id))}let l=B.current;ne(s.id,`in_progress`,!0);let u=Q.getState().chatItems;h(u.map(e=>e.id===s.id&&e.type===`message`?{...e,status:`completed`,usage:l||void 0}:e)),g(!1),l&&b(l.total_tokens),B.current=null}catch(e){if(Th(e))A(!0),ne(s.id,H.current?`completed`:`incomplete`,!0);else if(H.current)ne(s.id,`incomplete`,!0);else{let t=Q.getState().chatItems;h(t.map(t=>t.id===s.id&&t.type===`message`?{...t,content:[{type:`text`,text:`Error: ${e instanceof Error?e.message:`Failed to get response`}`}],status:`incomplete`}:t))}g(!1),P()}},[e,n,t,ne,h,g,p,m,x,b,M,P]),le=(0,y.useCallback)(async r=>{if(!e)return;let i=r.input.some(e=>e.type===`message`&&Array.isArray(e.content)&&e.content.some(e=>e.type===`function_approval_response`)),a=[];for(let e of r.input)if(e.type===`message`&&Array.isArray(e.content)){for(let t of e.content)if(t.type===`input_text`)a.push({type:`text`,text:t.text});else if(t.type===`input_image`)a.push({type:`input_image`,image_url:t.image_url||``,detail:`auto`});else if(t.type===`input_file`){let e=t;a.push({type:`input_file`,file_data:e.file_data,filename:e.filename})}}let o=Math.floor(Date.now()/1e3);if(!i&&a.length>0){let e={id:`user-${Date.now()}`,type:`message`,role:`user`,content:a,status:`completed`,created_at:o};h([...Q.getState().chatItems,e])}_(!0);try{let i=n;if(!i)try{i=await Lh.createConversation({agent_id:e.id}),p(i),m([i,...Q.getState().availableConversations]),E(null)}catch(e){E({message:e instanceof Error?e.message:`Failed to create conversation`,type:`conversation_creation_error`}),_(!1);return}let a=await Lh.runAgentSync(e.id,{input:r.input,conversation_id:i?.id}),s=[],c=[],l=[];if(a.output){for(let e of a.output)if(e.type===`message`){let t=e;if(t.content)for(let e of t.content)e.type===`output_text`?s.push({type:`text`,text:e.text}):e.type===`refusal`?s.push({type:`refusal`,refusal:e.refusal}):(e.type===`output_image`||e.type===`output_file`||e.type===`output_data`)&&s.push(e)}else if(e.type===`function_call`){let t=e;c.push({id:t.id||`call-${Date.now()}`,type:`function_call`,name:t.name,arguments:t.arguments||``,call_id:t.call_id,status:t.status||`completed`,created_at:o})}else if(e.type===`function_call_output`){let t=e;l.push({id:`result-${Date.now()}`,type:`function_call_output`,call_id:t.call_id,output:t.output,status:`completed`,created_at:o})}}let u={id:`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:s,status:`completed`,created_at:o,usage:a.usage?{input_tokens:a.usage.input_tokens,output_tokens:a.usage.output_tokens,total_tokens:a.usage.total_tokens}:void 0};h([...Q.getState().chatItems,u,...c,...l]),a.usage&&b(a.usage.total_tokens),t({type:`response.completed`,response:a,sequence_number:0})}catch(e){let t=e instanceof Error?e.message:`Failed to get response`,n={id:`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:[{type:`text`,text:`Error: ${t}`}],status:`incomplete`,created_at:o},r=Q.getState().chatItems;h([...r,n])}finally{_(!1)}},[e,n,t,h,p,m,b,_]),ue=async t=>{if(!(!e||t.length===0)){V.current=!0,A(!1),_(!0);try{let e={input:[{type:`message`,role:`user`,content:t}],conversation_id:n?.id};f?await ce(e):await le(e)}finally{_(!1)}}};return(0,T.jsxs)(`div`,{className:`flex h-[calc(100vh-3.5rem)] flex-col relative`,...L,children:[ee&&(0,T.jsx)(`div`,{className:`absolute inset-0 z-50 bg-blue-50/95 dark:bg-blue-950/95 backdrop-blur-sm flex items-center justify-center border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg m-2`,children:(0,T.jsxs)(`div`,{className:`text-center p-8`,children:[(0,T.jsx)(`div`,{className:`text-blue-600 dark:text-blue-400 text-lg font-medium mb-2`,children:`Drop files here`}),(0,T.jsx)(`div`,{className:`text-blue-500/80 dark:text-blue-400/70 text-sm`,children:`Images, PDFs, audio, and other files`})]})}),(0,T.jsxs)(`div`,{className:`border-b pb-2 p-4 flex-shrink-0`,children:[(0,T.jsxs)(`div`,{className:`flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 min-w-0`,children:[(0,T.jsx)(`h2`,{className:`font-semibold text-sm truncate`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Sl,{className:`h-4 w-4 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`truncate`,children:d.enabled?`Chat with ${d.model}`:`Chat with ${e.name||e.id}`})]})}),!d.enabled&&c===`developer`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>C(!0),className:`h-6 w-6 p-0 flex-shrink-0`,title:`View agent details`,children:(0,T.jsx)(Jl,{className:`h-4 w-4`})}),e.source!==`in_memory`&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:ae,disabled:D,className:`h-6 w-6 p-0 flex-shrink-0`,title:D?`Reloading...`:`Reload entity code (hot reload)`,children:(0,T.jsx)(uu,{className:`h-4 w-4 ${D?`animate-spin`:``}`})})]})]}),(0,T.jsxs)(`div`,{className:`flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0`,children:[(0,T.jsxs)(J_,{value:n?.id||``,onValueChange:oe,disabled:s||o,children:[(0,T.jsx)(X_,{className:`w-full sm:w-64`,children:(0,T.jsx)(Y_,{placeholder:s?`Loading...`:r.length===0?`No conversations`:n?`Conversation ${n.id.slice(-8)}`:`Select conversation`,children:n&&(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs`,children:[(0,T.jsxs)(`span`,{children:[`Conversation `,n.id.slice(-8)]}),l.total_tokens>0&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`•`}),(0,T.jsxs)(`span`,{className:`text-muted-foreground`,children:[l.total_tokens>=1e3?`${(l.total_tokens/1e3).toFixed(1)}k`:l.total_tokens,` `,`tokens`]})]})]})})}),(0,T.jsx)(Z_,{children:r.map(e=>(0,T.jsx)(Q_,{value:e.id,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between w-full`,children:[(0,T.jsxs)(`span`,{children:[`Conversation `,e.id.slice(-8)]}),e.created_at&&(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground ml-3`,children:new Date(e.created_at*1e3).toLocaleDateString()})]})},e.id))})]}),(0,T.jsx)(K,{variant:`outline`,size:`icon`,onClick:()=>n&&ie(n.id),disabled:!n||o,title:n?`Delete Conversation ${n.id.slice(-8)}`:`No conversation selected`,children:(0,T.jsx)(xu,{className:`h-4 w-4`})}),(0,T.jsxs)(K,{variant:`outline`,size:`lg`,onClick:re,disabled:!e||o,className:`whitespace-nowrap `,children:[(0,T.jsx)(lu,{className:`h-4 w-4 mr-2`}),(0,T.jsx)(`span`,{className:`hidden md:inline`,children:` New Conversation`})]})]})]}),d.enabled?(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Using OpenAI model directly. Local agent tools and instructions are not applied.`}):e.description&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:e.description})]}),w&&(0,T.jsxs)(`div`,{className:`mx-4 mt-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md flex items-start gap-2`,children:[(0,T.jsx)(Ol,{className:`h-4 w-4 text-destructive mt-0.5 flex-shrink-0`}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsx)(`div`,{className:`text-sm font-medium text-destructive`,children:`Failed to Create Conversation`}),(0,T.jsx)(`div`,{className:`text-xs text-destructive/90 mt-1 break-words`,children:w.message}),w.code&&(0,T.jsxs)(`div`,{className:`text-xs text-destructive/70 mt-1`,children:[`Error Code: `,w.code]})]}),(0,T.jsx)(`button`,{onClick:()=>E(null),className:`text-destructive hover:text-destructive/80 flex-shrink-0`,title:`Dismiss error`,children:(0,T.jsx)(Du,{className:`h-4 w-4`})})]}),(0,T.jsx)(gf,{className:`flex-1 p-4 h-0`,ref:R,children:(0,T.jsxs)(`div`,{className:`space-y-4`,children:[i.length===0?(0,T.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-32 text-center`,children:[(0,T.jsxs)(`div`,{className:`text-muted-foreground text-sm`,children:[`Start a conversation with`,` `,e.name||e.id]}),(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground mt-1`,children:`Type a message below to begin`})]}):(()=>{let e=[],t=new Map,n=new Map,r=null,a=[],o=[];for(let e=0;e0){let e=t.get(s.id)||[];e.push(...a),t.set(s.id,e),a.length=0}if(o.length>0){let e=n.get(s.id)||[];e.push(...o),n.set(s.id,e),o.length=0}r=s.id}else if(s.type===`function_call`)if(r){let e=t.get(r)||[];e.push(s),t.set(r,e)}else a.push(s);else if(s.type===`function_call_output`)if(r){let e=n.get(r)||[];e.push(s),n.set(r,e)}else o.push(s);else s.type===`message`&&s.role===`user`&&(r=null)}for(let r of i)if(r.type===`message`){let i=t.get(r.id)||[],a=n.get(r.id)||[];e.push((0,T.jsx)(cv,{item:r,toolCalls:i,toolResults:a},r.id))}return e})(),k&&!a&&(0,T.jsx)(`div`,{className:`px-4 py-2`,children:(0,T.jsx)(`div`,{className:`border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10`,children:(0,T.jsxs)(`div`,{className:`px-4 py-3 flex items-center gap-2`,children:[(0,T.jsx)(yu,{className:`w-4 h-4 text-orange-500 dark:text-orange-400 fill-current`}),(0,T.jsx)(`span`,{className:`font-medium text-sm text-orange-700 dark:text-orange-300`,children:`Response stopped by user`})]})})}),(0,T.jsx)(`div`,{ref:z})]})}),u.length>0&&(0,T.jsx)(`div`,{className:`border-t bg-amber-50 dark:bg-amber-950/20 p-4 flex-shrink-0`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,T.jsx)(Ol,{className:`h-5 w-5 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0`}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsx)(`h4`,{className:`font-medium text-sm mb-2`,children:`Approval Required`}),(0,T.jsx)(`div`,{className:`space-y-2`,children:u.map(e=>(0,T.jsxs)(`div`,{className:`bg-white dark:bg-gray-900 rounded-lg p-3 border border-amber-200 dark:border-amber-900`,children:[(0,T.jsxs)(`div`,{className:`font-mono text-xs mb-3 break-all`,children:[(0,T.jsx)(`span`,{className:`text-blue-600 dark:text-blue-400 font-semibold`,children:e.function_call.name}),(0,T.jsx)(`span`,{className:`text-gray-500`,children:`(`}),(0,T.jsx)(`span`,{className:`text-gray-700 dark:text-gray-300`,children:JSON.stringify(e.function_call.arguments)}),(0,T.jsx)(`span`,{className:`text-gray-500`,children:`)`})]}),(0,T.jsxs)(`div`,{className:`flex gap-2`,children:[(0,T.jsxs)(K,{size:`sm`,onClick:async()=>{let t=await se(e.request_id,!0);t&&await ce(t)},variant:`default`,className:`flex-1 sm:flex-none`,children:[(0,T.jsx)(Tl,{className:`h-4 w-4 mr-1`}),`Approve`]}),(0,T.jsxs)(K,{size:`sm`,onClick:async()=>{let t=await se(e.request_id,!1);t&&await ce(t)},variant:`outline`,className:`flex-1 sm:flex-none`,children:[(0,T.jsx)(Du,{className:`h-4 w-4 mr-1`}),`Reject`]})]})]},e.request_id))})]})]})}),(0,T.jsx)(`div`,{className:`border-t flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`p-4`,children:(0,T.jsx)(cg,{onSubmit:ue,isSubmitting:o,isStreaming:a,onCancel:N,isCancelling:j,placeholder:`Message ${e.name||e.id}... (Shift+Enter for new line)`,showFileUpload:!0,entityName:e.name||e.id,disabled:!e,externalFiles:F,onExternalFilesProcessed:I})})}),(0,T.jsx)(nv,{agent:e,open:S,onOpenChange:C})]})}function uv({message:e=`Loading...`,description:t,size:n=`md`,className:r,fullPage:i=!1}){let a=(0,T.jsxs)(`div`,{className:at(`flex flex-col items-center justify-center gap-3`,i?`min-h-[50vh]`:`py-8`,r),children:[(0,T.jsx)(Fu,{size:n,className:`text-muted-foreground`}),(0,T.jsxs)(`div`,{className:`text-center space-y-1`,children:[(0,T.jsx)(`p`,{className:at(`font-medium text-muted-foreground`,n===`sm`&&`text-sm`,n===`lg`&&`text-lg`),children:e}),t&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground/80`,children:t})]})]});return i?(0,T.jsx)(`div`,{className:`flex items-center justify-center min-h-screen bg-background`,children:a}):a}function dv(e){return[`name`,`title`,`id`,`key`,`label`,`type`,`status`,`tag`,`category`,`code`,`username`,`password`,`email`].includes(e.toLowerCase())}function fv(e){if(e.type)return e;if(e.anyOf&&e.anyOf.length>0){let t=e.anyOf.filter(e=>e.type!==`null`&&e.type!==void 0);if(t.length>0)return{...t[0],default:e.default??t[0].default,description:e.description??t[0].description,title:e.title??t[0].title}}if(e.oneOf&&e.oneOf.length>0){let t=e.oneOf.filter(e=>e.type!==`null`&&e.type!==void 0);if(t.length>0)return{...t[0],default:e.default??t[0].default,description:e.description??t[0].description,title:e.title??t[0].title}}return e}function pv(e,t){return t.format===`textarea`||!!t.description&&t.description.length>100||t.type===`string`&&!t.enum&&!dv(e)}function mv(e,t){let n=pv(e,t),r=!!t.description&&t.description.length>150;return n||r?`md:col-span-2 lg:col-span-3 xl:col-span-4`:t.type===`array`||t.description&&t.description.length>80?`xl:col-span-2`:``}function hv(e,t){if(e.type!==`object`||!e.properties)return!1;let n=e.properties,r=Object.keys(n).filter(e=>!t.includes(e));return t.includes(`role`)&&r.some(e=>[`text`,`message`,`content`].includes(e))&&n.role?.type===`string`}function gv({name:e,schema:t,value:n,onChange:r,isRequired:i=!1,isReadOnly:a=!1}){let o=fv(t),{type:s,description:c,enum:l,default:u}=o,d=pv(e,o);return(0,T.jsx)(`div`,{className:mv(e,o),children:(()=>{if(a)return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(qm,{htmlFor:e,className:`text-muted-foreground`,children:e}),(0,T.jsx)(`div`,{className:`text-sm p-2 bg-muted rounded border`,children:typeof n==`object`?JSON.stringify(n,null,2):String(n)}),c&&(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:c})]});switch(s){case`string`:return l?(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsxs)(J_,{value:typeof n==`string`&&n?n:typeof u==`string`?u:l[0],onValueChange:e=>r(e),children:[(0,T.jsx)(X_,{children:(0,T.jsx)(Y_,{placeholder:`Select ${e}`})}),(0,T.jsx)(Z_,{children:l.map(e=>(0,T.jsx)(Q_,{value:e,children:e},e))})]}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]}):d?(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(ng,{id:e,value:typeof n==`string`?n:``,onChange:e=>r(e.target.value),placeholder:typeof u==`string`?u:`Enter ${e}`,rows:4,className:`min-w-[300px] w-full`}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]}):(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(Um,{id:e,type:`text`,value:typeof n==`string`?n:``,onChange:e=>r(e.target.value),placeholder:typeof u==`string`?u:`Enter ${e}`}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]});case`integer`:case`number`:return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(Um,{id:e,type:`number`,step:s===`integer`?`1`:`any`,value:typeof n==`number`?n:``,onChange:e=>{let t=s===`integer`?parseInt(e.target.value):parseFloat(e.target.value);r(isNaN(t)?``:t)},placeholder:typeof u==`number`?u.toString():`Enter ${e}`}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]});case`boolean`:return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center space-x-2`,children:[(0,T.jsx)(ip,{id:e,checked:!!n,onCheckedChange:e=>r(e)}),(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]})]}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]});case`array`:return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(ng,{id:e,value:Array.isArray(n)?n.join(`, `):typeof n==`string`?n:``,onChange:e=>{r(e.target.value.split(`,`).map(e=>e.trim()).filter(e=>e.length>0))},placeholder:`Enter items separated by commas`,rows:2}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]});default:return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(ng,{id:e,value:typeof n==`object`&&n?JSON.stringify(n,null,2):typeof n==`string`?n:``,onChange:e=>{try{r(JSON.parse(e.target.value))}catch{r(e.target.value)}},placeholder:`{"key": "value"}`,rows:3,className:`font-mono text-xs`}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]})}})()})}function _v({schema:e,values:t,onChange:n,disabled:r=!1,readOnlyFields:i=[],hideFields:a=[],showCollapsedByDefault:o=!1,layout:s=`stack`}){let[c,l]=(0,y.useState)(o),u=s===`grid`?`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6`:`space-y-3`,d=e.properties||{},f=Object.keys(d).filter(e=>!a.includes(e)),p=(e.required||[]).filter(e=>!a.includes(e)),m=hv(e,p),h=f.filter(e=>p.includes(e)&&!(m&&e===`role`)),g=f.filter(e=>!p.includes(e)),_=m?[...g].sort((e,t)=>{let n=e=>+!![`text`,`message`,`content`].includes(e);return n(t)-n(e)}):g,v=Math.max(0,(m?1:6)-h.length),b=_.slice(0,v),x=_.slice(v),S=x.length>0,C=h.length>0,w=(e,r)=>{n({...t,[e]:r})},E=s===`grid`?`md:col-span-2 lg:col-span-3 xl:col-span-4`:``;return(0,T.jsxs)(`div`,{className:u,children:[h.map(e=>(0,T.jsx)(gv,{name:e,schema:d[e],value:t[e],onChange:t=>w(e,t),isRequired:!0,isReadOnly:r||i.includes(e)},e)),C&&g.length>0&&(0,T.jsx)(`div`,{className:E,children:(0,T.jsx)(`div`,{className:`border-t border-border`})}),b.map(e=>(0,T.jsx)(gv,{name:e,schema:d[e],value:t[e],onChange:t=>w(e,t),isRequired:!1,isReadOnly:r||i.includes(e)},e)),S&&(0,T.jsx)(`div`,{className:E,children:(0,T.jsx)(K,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>l(!c),className:`w-full justify-center gap-2`,disabled:r,children:c?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Dl,{className:`h-4 w-4`}),`Hide `,x.length,` optional field`,x.length===1?``:`s`]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(X,{className:`h-4 w-4`}),`Show `,x.length,` optional field`,x.length===1?``:`s`]})})}),c&&x.map(e=>(0,T.jsx)(gv,{name:e,schema:d[e],value:t[e],onChange:t=>w(e,t),isRequired:!1,isReadOnly:r||i.includes(e)},e))]})}function vv(e,t){return(e.required||[]).every(e=>{let n=t[e];return n!==void 0&&n!==``&&n!==null})}function yv(e,t){let n=e.required||[],r={};return Object.keys(t).forEach(e=>{let i=t[e];(n.includes(e)||i!==void 0&&i!==``&&i!==null)&&(r[e]=i)}),r}function bv({inputSchema:e,inputTypeName:t,onSubmit:n,isSubmitting:r=!1,className:i}){let[a,o]=(0,y.useState)(!1),s=i?.includes(`embedded`),[c,l]=(0,y.useState)({}),[u,d]=(0,y.useState)(!1),f=e.properties||{},p=Object.keys(f),m=e.required||[],h=e.type===`string`&&!e.enum,g=hv(e,m),_=h?c.value!==void 0&&c.value!==``:m.length>0?m.every(e=>g&&e===`role`&&c.role===`user`?!0:c[e]!==void 0&&c[e]!==``):Object.keys(c).length>0;(0,y.useEffect)(()=>{if(e.type===`string`)l({value:e.default||``});else if(e.type===`object`&&e.properties){let t={};Object.entries(e.properties).forEach(([e,n])=>{n.default===void 0?n.enum&&n.enum.length>0&&(t[e]=n.enum[0]):t[e]=n.default}),g&&!t.role&&(t.role=`user`),l(t)}},[e,g]);let v=t=>{if(t.preventDefault(),d(!0),e.type===`string`)n({input:c.value||``});else if(e.type===`object`){let t=e.properties||{},r=Object.keys(t);if(r.length===1){let e=r[0];n({[e]:c[e]||``})}else n(yv(e,c))}else n(c);s||o(!1),d(!1)},b=e=>{l(e)},x=()=>(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(qm,{htmlFor:`simple-input`,children:`Input`}),(0,T.jsx)(ng,{id:`simple-input`,value:typeof c.value==`string`?c.value:``,onChange:e=>l({value:e.target.value}),placeholder:typeof e.default==`string`?e.default:`Enter input`,rows:4,className:`min-w-[300px] w-full`}),e.description&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:e.description})]});return s?(0,T.jsxs)(`form`,{onSubmit:v,className:i,children:[h&&x(),!h&&(0,T.jsx)(_v,{schema:e,values:c,onChange:b,disabled:u,hideFields:g?[`role`]:[],layout:`grid`}),(0,T.jsx)(`div`,{className:`flex gap-2 mt-4 justify-end`,children:(0,T.jsxs)(K,{type:`submit`,disabled:u||!_,size:`default`,children:[(0,T.jsx)(hu,{className:`h-4 w-4`}),u?`Running...`:`Run Workflow`]})})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{className:at(`flex flex-col`,i),children:[(0,T.jsxs)(`div`,{className:`border-b border-border px-4 py-3 bg-muted`,children:[(0,T.jsx)(Vh,{className:`text-sm mb-3`,children:`Run Workflow`}),(0,T.jsxs)(K,{onClick:()=>o(!0),disabled:r,className:`w-full`,size:`default`,children:[(0,T.jsx)(hu,{className:`h-4 w-4 mr-2`}),r?`Running...`:`Run Workflow`]})]}),(0,T.jsxs)(`div`,{className:`px-4 py-3`,children:[(0,T.jsxs)(`div`,{className:`text-sm text-muted-foreground`,children:[(0,T.jsx)(`strong`,{children:`Input Type:`}),` `,(0,T.jsx)(`code`,{className:`bg-muted px-1 py-0.5 rounded`,children:t}),e.type===`object`&&e.properties&&(0,T.jsxs)(`span`,{className:`ml-2`,children:[`(`,Object.keys(e.properties).length,` field`,Object.keys(e.properties).length===1?``:`s`,`)`]})]}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground mt-2`,children:`Click "Run Workflow" to configure inputs and execute`})]})]}),(0,T.jsx)(Im,{open:a,onOpenChange:o,children:(0,T.jsxs)(Lm,{className:`w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col`,children:[(0,T.jsxs)(Rm,{children:[(0,T.jsx)(zm,{children:`Run Workflow`}),(0,T.jsx)(Vm,{onClose:()=>o(!1)})]}),(0,T.jsx)(`div`,{className:`px-8 py-4 border-b flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,T.jsx)(`span`,{className:`font-medium`,children:`Input Type:`}),(0,T.jsx)(`code`,{className:`bg-muted px-3 py-1 text-xs font-mono`,children:t}),e.type===`object`&&(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[p.length,` field`,p.length===1?``:`s`]})]})})}),(0,T.jsx)(`div`,{className:`px-8 py-6 overflow-y-auto flex-1 min-h-0`,children:(0,T.jsxs)(`form`,{id:`workflow-modal-form`,onSubmit:v,children:[h&&x(),!h&&(0,T.jsx)(_v,{schema:e,values:c,onChange:b,disabled:u,hideFields:g?[`role`]:[],layout:`grid`})]})}),(0,T.jsx)(`div`,{className:`px-8 py-4 border-t flex-shrink-0`,children:(0,T.jsxs)(Hm,{children:[(0,T.jsx)(K,{variant:`outline`,onClick:()=>o(!1),disabled:u,children:`Cancel`}),(0,T.jsxs)(K,{type:`submit`,form:`workflow-modal-form`,disabled:u||!_,children:[(0,T.jsx)(hu,{className:`h-4 w-4 mr-2`}),u?`Running...`:`Run Workflow`]})]})})]})})]})}function xv(e,t,n=`LR`){if(e.length===0)return e;if(e.length===1)return e.map(e=>({...e,position:{x:0,y:0}}));let r=new Map,i=new Map;e.forEach(e=>{r.set(e.id,[]),i.set(e.id,[])}),t.forEach(e=>{r.get(e.source)?.push(e.target),i.get(e.target)?.push(e.source)});let a=e.filter(e=>(i.get(e.id)||[]).length===0);a.length===0&&a.push(e[0]);let o=n===`LR`?350:280,s=n===`TB`?250:180,c=new Map,l=new Map,u=[],d=new Set;for(a.forEach(e=>{u.push({nodeId:e.id,level:0})});u.length>0;){let{nodeId:e,level:t}=u.shift();d.has(e)||(d.add(e),l.has(t)||l.set(t,[]),l.get(t).push(e),(r.get(e)||[]).forEach(e=>{d.has(e)||u.push({nodeId:e,level:t+1})}))}return e.forEach(e=>{if(!d.has(e.id)){let t=Math.max(...Array.from(l.keys()),-1)+1;l.has(t)||l.set(t,[]),l.get(t).push(e.id)}}),l.forEach((e,t)=>{let r=e.length;e.forEach((e,i)=>{let a,l;n===`LR`?(a=t*o,l=-((r-1)*s)/2+i*s):(l=t*s,a=-((r-1)*o)/2+i*o),c.set(e,{x:a,y:l,level:t})})}),e.map(e=>{let t=c.get(e.id)||{x:0,y:0};return{...e,position:{x:t.x-220/2,y:t.y-120/2}}})}function Sv(e){if(typeof e!=`object`||!e)return!1;let t=e;return`id`in t&&`edge_groups`in t&&`executors`in t&&`start_executor_id`in t&&`max_iterations`in t&&typeof t.id==`string`&&Array.isArray(t.edge_groups)&&typeof t.executors==`object`&&typeof t.start_executor_id==`string`&&typeof t.max_iterations==`number`}function Cv(e){return Sv(e)?e:null}function wv(e){if(!e||e.type!==`object`||!e.properties)return!1;let t=e.properties,n=`text`in t&&t.text?.type===`string`,r=`role`in t&&t.role?.type===`string`;return!!(n&&r||Object.keys(t).length===1&&n)}function Tv(e,t=50,n=`...`){return e.length<=t?e:e.substring(0,t)+n}function Ev(e,t,n){if(!e)return console.warn(`convertWorkflowDumpToNodes: workflowDump is undefined`),[];let r=Cv(e),i,a;return r?(i=Object.values(r.executors).map(e=>({id:e.id,type:e.type,name:e.name||e.id,description:e.description,config:e.config})),a=r.start_executor_id):(i=Ov(e),a=e?.start_executor_id),!i||!Array.isArray(i)||i.length===0?(console.warn(`No executors found in workflow dump. Available keys:`,Object.keys(e)),[]):i.map(e=>({id:e.id,type:`executor`,position:{x:0,y:0},data:{executorId:e.id,executorType:e.type,name:e.name||e.id,state:`pending`,isStartNode:e.id===a,layoutDirection:n||`LR`,onNodeClick:t}}))}function Dv(e){if(!e)return console.warn(`convertWorkflowDumpToEdges: workflowDump is undefined`),[];let t=Cv(e),n;return t?(n=[],t.edge_groups.forEach(e=>{e.edges.forEach(e=>{n.push({source:e.source_id,target:e.target_id,condition:e.condition_name})})})):n=kv(e),!n||!Array.isArray(n)||n.length===0?(console.warn(`No connections found in workflow dump. Available keys:`,Object.keys(e)),[]):n.map(e=>{let t=e.source===e.target;return{id:`${e.source}-${e.target}`,source:e.source,target:e.target,sourceHandle:`source`,targetHandle:`target`,type:t?`selfLoop`:`default`,animated:!1,style:{stroke:`#6b7280`,strokeWidth:2}}})}function Ov(e){if(e.executors&&typeof e.executors==`object`&&!Array.isArray(e.executors)){let t=e.executors;return Object.entries(t).map(([e,t])=>({id:e,type:t.type_||t.type||`executor`,name:t.name||e,description:t.description,config:t.config}))}for(let t of[`executors`,`agents`,`steps`,`nodes`])if(e[t]&&Array.isArray(e[t]))return e[t];if(e.config&&typeof e.config==`object`)return Ov(e.config);let t=[];return Object.entries(e).forEach(([e,n])=>{if(typeof n==`object`&&n&&(`type`in n||`type_`in n)){let r=n;t.push({id:e,type:r.type_||r.type||`executor`,name:r.name||e,description:r.description,config:r.config})}}),t}function kv(e){if(e.edge_groups&&Array.isArray(e.edge_groups)){let t=[];return e.edge_groups.forEach(e=>{if(typeof e==`object`&&e&&`edges`in e){let n=e.edges;Array.isArray(n)&&n.forEach(e=>{if(typeof e==`object`&&e&&`source_id`in e&&`target_id`in e){let n=e;t.push({source:n.source_id,target:n.target_id,condition:n.condition_name||void 0})}})}}),t}for(let t of[`connections`,`edges`,`transitions`,`links`])if(e[t]&&Array.isArray(e[t]))return e[t];return e.config&&typeof e.config==`object`?kv(e.config):[]}function Av(e,t,n=`LR`){return xv(e,t,n)}function jv(e,t){let n={},r=!1,i={};return e.forEach(e=>{if(e.type===`response.output_item.added`||e.type===`response.output_item.done`){let t=e.item;if(t&&t.type===`executor_action`&&`executor_id`in t){let r=t.executor_id,a=t.id;if(e.type===`response.output_item.added`&&(i[r]=a),i[r]!==a&&e.type===`response.output_item.done`)return;let o=`pending`,s;e.type===`response.output_item.added`?o=`running`:e.type===`response.output_item.done`&&(t.status===`completed`?o=`completed`:t.status===`failed`?(o=`failed`,s=t.error?typeof t.error==`string`?t.error:JSON.stringify(t.error):`Execution failed`):t.status===`cancelled`&&(o=`cancelled`)),n[r]={nodeId:r,state:o,data:t.result,error:s,timestamp:new Date().toISOString()}}}else if(e.type===`response.created`||e.type===`response.in_progress`)r=!0;else if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let t=e.data,i=t.executor_id,a=t.event_type,o=t.data,s=`pending`,c;a===`ExecutorInvokedEvent`?s=`running`:a===`ExecutorCompletedEvent`?s=`completed`:a?.includes(`Error`)||a?.includes(`Failed`)?(s=`failed`,c=typeof o==`string`?o:`Execution failed`):a?.includes(`Cancel`)?s=`cancelled`:a===`WorkflowCompletedEvent`||a===`WorkflowOutputEvent`?s=`completed`:a===`WorkflowStartedEvent`&&(r=!0),i&&(n[i]={nodeId:i,state:s,data:o,error:c,timestamp:new Date().toISOString()})}}),r&&t&&!n[t]&&(e.some(e=>{if(e.type===`response.output_item.done`){let n=e.item;return n&&n.type===`executor_action`&&`executor_id`in n&&n.executor_id===t}if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let n=e.data;return n.executor_id===t&&(n.event_type===`ExecutorCompletedEvent`||n.event_type===`ExecutorFailedEvent`||typeof n.event_type==`string`&&n.event_type.includes(`Error`)||typeof n.event_type==`string`&&n.event_type.includes(`Failed`))}return!1})||(n[t]={nodeId:t,state:`running`,data:void 0,error:void 0,timestamp:new Date().toISOString()})),n}function Mv(e,t,n=!0){return e.map(e=>{let r=t[e.id];return r?{...e,data:{...e.data,state:r.state,outputData:r.data,error:r.error,isStreaming:n,layoutDirection:e.data.layoutDirection}}:e})}function Nv(e){let t={};return e.forEach(e=>{if(e.type===`response.output_item.added`||e.type===`response.output_item.done`){let n=e.item;if(n&&n.type===`executor_action`&&`executor_id`in n){let r=n.executor_id;t[r]={lastEvent:e.type===`response.output_item.added`?`ExecutorInvokedEvent`:`ExecutorCompletedEvent`,timestamp:new Date().toISOString()}}}else if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let n=e.data,r=n.executor_id,i=n.event_type;r&&(i===`ExecutorInvokedEvent`||i===`ExecutorCompletedEvent`)&&(t[r]={lastEvent:i,timestamp:new Date().toISOString()})}}),Object.entries(t).filter(([,e])=>e.lastEvent===`ExecutorInvokedEvent`).map(([e])=>e)}function Pv(e,t){let n=Nv(t),r={};return t.forEach(e=>{if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let t=e.data,n=t.executor_id,i=t.event_type;n&&i&&(r[n]||(r[n]={completed:!1,invoked:!1}),i===`ExecutorInvokedEvent`?r[n].invoked=!0:i===`ExecutorCompletedEvent`&&(r[n].completed=!0))}}),e.map(e=>{let t=r[e.source],i=r[e.target],a=n.includes(e.target),o={...e.style},s=!1;return t?.completed&&a?(o={stroke:`#643FB2`,strokeWidth:3,strokeDasharray:`5,5`},s=!0):o=t?.completed&&i?.completed?{stroke:`#10b981`,strokeWidth:2}:t?.completed&&i?.invoked?{stroke:`#f59e0b`,strokeWidth:2}:{stroke:`#6b7280`,strokeWidth:2},{...e,style:o,animated:s}})}function Fv(e){let t=new Map,n=new Set;return e.forEach(e=>{let r=`${e.source}-${e.target}`,i=`${e.target}-${e.source}`;if(e.source===e.target){t.set(r,e);return}if(t.has(i)){n.add(i),n.add(r);let e=t.get(i);t.set(i,{...e,markerStart:{type:`arrow`,width:20,height:20},markerEnd:{type:`arrow`,width:20,height:20},data:{...e.data,isBidirectional:!0}})}else n.has(r)||t.set(r,e)}),Array.from(t.values())}function Iv({inputSchema:e,onRun:t,onCancel:n,isSubmitting:r,isCancelling:i=!1,workflowState:a,checkpoints:o=[],showCheckpoints:s=!0}){let[c,l]=(0,y.useState)(!1);(0,y.useEffect)(()=>{let e=e=>{e.key===`Escape`&&c&&l(!1)};if(c)return document.addEventListener(`keydown`,e),()=>document.removeEventListener(`keydown`,e)},[c]);let u=(0,y.useMemo)(()=>{let t=wv(e);if(!e)return{needsInput:!1,hasDefaults:!1,fieldCount:0,canRunDirectly:!0,isChatMessage:!1};if(e.type===`string`)return{needsInput:!e.default,hasDefaults:!!e.default,fieldCount:1,canRunDirectly:!!e.default,isChatMessage:!1};if(e.type===`object`&&e.properties){let n=e.properties,r=Object.entries(n),i=r.filter(([,e])=>e.default!==void 0||e.enum&&e.enum.length>0);return{needsInput:r.length>0,hasDefaults:i.length>0,fieldCount:r.length,canRunDirectly:r.length===0||i.length===r.length,isChatMessage:t}}return{needsInput:!1,hasDefaults:!1,fieldCount:0,canRunDirectly:!0,isChatMessage:!1}},[e]),d=()=>{if(a===`running`&&n)n();else if(u.canRunDirectly){let n={};e?.type===`string`&&e.default?n.input=e.default:e?.type===`object`&&e.properties&&Object.entries(e.properties).forEach(([e,t])=>{t.default===void 0?t.enum&&t.enum.length>0&&(n[e]=t.enum[0]):n[e]=t.default}),t(n)}else l(!0)},f=n=>{if(u.canRunDirectly){let r={};e?.type===`string`&&e.default?r.input=e.default:e?.type===`object`&&e.properties&&Object.entries(e.properties).forEach(([e,t])=>{t.default===void 0?t.enum&&t.enum.length>0&&(r[e]=t.enum[0]):r[e]=t.default}),t(r,n)}else l(!0)},p=s&&o.length>0,m=e=>{if(!e)return``;let t=e/1024;return t<1?`${e} B`:t<1024?`${t.toFixed(1)} KB`:`${(t/1024).toFixed(1)} MB`},{icon:h,text:g}={icon:i?(0,T.jsx)(Ql,{className:`w-4 h-4 animate-spin`}):a===`running`&&n?(0,T.jsx)(yu,{className:`w-4 h-4 fill-current`}):a===`running`?(0,T.jsx)(Ql,{className:`w-4 h-4 animate-spin`}):a===`error`?(0,T.jsx)(fu,{className:`w-4 h-4`}):u.needsInput&&!u.canRunDirectly?(0,T.jsx)(_u,{className:`w-4 h-4`}):(0,T.jsx)(cu,{className:`w-4 h-4`}),text:i?`Stopping...`:a===`running`&&n?`Stop`:a===`running`?`Running...`:a===`completed`?`Run Again`:a===`error`?`Retry`:u.fieldCount===0||u.canRunDirectly?`Run Workflow`:`Configure & Run`},_=a===`running`&&!n||i,v=a===`error`?`destructive`:`default`;return(0,T.jsxs)(T.Fragment,{children:[p||u.needsInput?(0,T.jsxs)(ku,{children:[(0,T.jsxs)(`div`,{className:`flex w-full`,children:[(0,T.jsxs)(K,{onClick:d,disabled:_,variant:v,className:`gap-2 rounded-r-none flex-1`,title:a===`running`&&n?`Stop workflow execution`:void 0,children:[h,g]}),(0,T.jsx)(Au,{asChild:!0,children:(0,T.jsx)(K,{disabled:_,variant:v,className:`rounded-l-none border-l-0 px-2`,title:`More options`,children:(0,T.jsx)(X,{className:`w-4 h-4`})})})]}),(0,T.jsxs)(ju,{align:`end`,className:`w-80 max-h-[400px] overflow-y-auto`,children:[p&&(0,T.jsxs)(Mu,{onClick:d,children:[(0,T.jsx)(cu,{className:`w-4 h-4 mr-2`}),`Run Fresh`]}),u.needsInput&&(0,T.jsxs)(Mu,{onClick:()=>l(!0),children:[(0,T.jsx)(_u,{className:`w-4 h-4 mr-2`}),`Configure Inputs`]}),p&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Pu,{}),(0,T.jsx)(`div`,{className:`px-2 py-1.5 text-xs text-muted-foreground`,children:`Resume from checkpoint`}),o.map((e,t)=>(0,T.jsxs)(Mu,{onClick:()=>f(e.checkpoint_id),className:`flex flex-col items-start py-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 w-full`,children:[(0,T.jsx)(uu,{className:`w-4 h-4 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`font-medium`,children:e.metadata.iteration_count===0?`Initial State`:`Step ${e.metadata.iteration_count}`}),t===0&&(0,T.jsx)(ct,{variant:`secondary`,className:`text-[10px] h-4 px-1 ml-auto`,children:`Latest`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground ml-6 mt-0.5`,children:[(0,T.jsx)(Nl,{className:`w-3 h-3`}),(0,T.jsx)(`span`,{children:new Date(e.timestamp).toLocaleTimeString()}),e.metadata.size_bytes&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{children:`•`}),(0,T.jsx)(`span`,{children:m(e.metadata.size_bytes)})]})]})]},e.checkpoint_id))]})]})]}):(0,T.jsxs)(K,{onClick:d,disabled:_,variant:v,className:`gap-2 w-full`,title:a===`running`&&n?`Stop workflow execution`:void 0,children:[h,g]}),e&&(0,T.jsx)(Im,{open:c,onOpenChange:l,children:(0,T.jsxs)(Lm,{className:`w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col`,children:[(0,T.jsxs)(Rm,{className:`px-8 pt-6`,children:[(0,T.jsx)(zm,{children:`Configure Workflow Inputs`}),(0,T.jsx)(Vm,{onClose:()=>l(!1)})]}),(0,T.jsx)(`div`,{className:`px-8 py-4 border-b flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,T.jsx)(`span`,{className:`font-medium`,children:`Input Type:`}),(0,T.jsx)(ct,{variant:`secondary`,children:u.isChatMessage?`Chat Message`:e.type===`string`?`Simple Text`:`Structured Data`})]})})}),(0,T.jsx)(`div`,{className:`flex-1 overflow-y-auto py-4 px-8`,children:u.isChatMessage?(0,T.jsx)(cg,{onSubmit:async e=>{t([{type:`message`,role:`user`,content:e}]),l(!1)},isSubmitting:r,placeholder:`Enter your message...`,entityName:`workflow`,showFileUpload:!0}):(0,T.jsx)(bv,{inputSchema:e,inputTypeName:`Input`,onSubmit:e=>{t(e),l(!1)},isSubmitting:r,className:`embedded`})})]})})]})}function Lv(e){if(typeof e==`string`||typeof e==`number`)return``+e;let t=``;if(Array.isArray(e))for(let n=0,r;n{}};function zv(){for(var e=0,t=arguments.length,n={},r;e=0&&(n=e.slice(r+1),e=e.slice(0,r)),e&&!t.hasOwnProperty(e))throw Error(`unknown type: `+e);return{type:e,name:n}})}Bv.prototype=zv.prototype={constructor:Bv,on:function(e,t){var n=this._,r=Vv(e+``,n),i,a=-1,o=r.length;if(arguments.length<2){for(;++a0)for(var n=Array(i),r=0,i,a;r=0&&(t=e.slice(0,n))!==`xmlns`&&(e=e.slice(n+1)),Wv.hasOwnProperty(t)?{space:Wv[t],local:e}:e}function Kv(e){return function(){var t=this.ownerDocument,n=this.namespaceURI;return n===`http://www.w3.org/1999/xhtml`&&t.documentElement.namespaceURI===`http://www.w3.org/1999/xhtml`?t.createElement(e):t.createElementNS(n,e)}}function qv(e){return function(){return this.ownerDocument.createElementNS(e.space,e.local)}}function Jv(e){var t=Gv(e);return(t.local?qv:Kv)(t)}function Yv(){}function Xv(e){return e==null?Yv:function(){return this.querySelector(e)}}function Zv(e){typeof e!=`function`&&(e=Xv(e));for(var t=this._groups,n=t.length,r=Array(n),i=0;i=v&&(v=_+1);!(b=g[v])&&++v=0;)(o=r[i])&&(a&&o.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(o,a),a=o);return this}function Dy(e){e||=Oy;function t(t,n){return t&&n?e(t.__data__,n.__data__):!t-!n}for(var n=this._groups,r=n.length,i=Array(r),a=0;at?1:e>=t?0:NaN}function ky(){var e=arguments[0];return arguments[0]=this,e.apply(null,arguments),this}function Ay(){return Array.from(this)}function jy(){for(var e=this._groups,t=0,n=e.length;t1?this.each((t==null?Uy:typeof t==`function`?Gy:Wy)(e,t,n??``)):qy(this.node(),e)}function qy(e,t){return e.style.getPropertyValue(t)||Hy(e).getComputedStyle(e,null).getPropertyValue(t)}function Jy(e){return function(){delete this[e]}}function Yy(e,t){return function(){this[e]=t}}function Xy(e,t){return function(){var n=t.apply(this,arguments);n==null?delete this[e]:this[e]=n}}function Zy(e,t){return arguments.length>1?this.each((t==null?Jy:typeof t==`function`?Xy:Yy)(e,t)):this.node()[e]}function Qy(e){return e.trim().split(/^|\s+/)}function $y(e){return e.classList||new eb(e)}function eb(e){this._node=e,this._names=Qy(e.getAttribute(`class`)||``)}eb.prototype={add:function(e){this._names.indexOf(e)<0&&(this._names.push(e),this._node.setAttribute(`class`,this._names.join(` `)))},remove:function(e){var t=this._names.indexOf(e);t>=0&&(this._names.splice(t,1),this._node.setAttribute(`class`,this._names.join(` `)))},contains:function(e){return this._names.indexOf(e)>=0}};function tb(e,t){for(var n=$y(e),r=-1,i=t.length;++r=0&&(t=e.slice(n+1),e=e.slice(0,n)),{type:e,name:t}})}function Ab(e){return function(){var t=this.__on;if(t){for(var n=0,r=-1,i=t.length,a;n()=>e;function Qb(e,{sourceEvent:t,subject:n,target:r,identifier:i,active:a,x:o,y:s,dx:c,dy:l,dispatch:u}){Object.defineProperties(this,{type:{value:e,enumerable:!0,configurable:!0},sourceEvent:{value:t,enumerable:!0,configurable:!0},subject:{value:n,enumerable:!0,configurable:!0},target:{value:r,enumerable:!0,configurable:!0},identifier:{value:i,enumerable:!0,configurable:!0},active:{value:a,enumerable:!0,configurable:!0},x:{value:o,enumerable:!0,configurable:!0},y:{value:s,enumerable:!0,configurable:!0},dx:{value:c,enumerable:!0,configurable:!0},dy:{value:l,enumerable:!0,configurable:!0},_:{value:u}})}Qb.prototype.on=function(){var e=this._.on.apply(this._,arguments);return e===this._?this:e};function $b(e){return!e.ctrlKey&&!e.button}function ex(){return this.parentNode}function tx(e,t){return t??{x:e.x,y:e.y}}function nx(){return navigator.maxTouchPoints||`ontouchstart`in this}function rx(){var e=$b,t=ex,n=tx,r=nx,i={},a=zv(`start`,`drag`,`end`),o=0,s,c,l,u,d=0;function f(e){e.on(`mousedown.drag`,p).filter(r).on(`touchstart.drag`,g).on(`touchmove.drag`,_,Gb).on(`touchend.drag touchcancel.drag`,v).style(`touch-action`,`none`).style(`-webkit-tap-highlight-color`,`rgba(0,0,0,0)`)}function p(n,r){if(!(u||!e.call(this,n,r))){var i=y(this,t.call(this,n,r),n,r,`mouse`);i&&(Hb(n.view).on(`mousemove.drag`,m,Kb).on(`mouseup.drag`,h,Kb),Yb(n.view),qb(n),l=!1,s=n.clientX,c=n.clientY,i(`start`,n))}}function m(e){if(Jb(e),!l){var t=e.clientX-s,n=e.clientY-c;l=t*t+n*n>d}i.mouse(`drag`,e)}function h(e){Hb(e.view).on(`mousemove.drag mouseup.drag`,null),Xb(e.view,l),Jb(e),i.mouse(`end`,e)}function g(n,r){if(e.call(this,n,r)){var i=n.changedTouches,a=t.call(this,n,r),o=i.length,s,c;for(s=0;s>8&15|t>>4&240,t>>4&15|t&240,(t&15)<<4|t&15,1):n===8?Ex(t>>24&255,t>>16&255,t>>8&255,(t&255)/255):n===4?Ex(t>>12&15|t>>8&240,t>>8&15|t>>4&240,t>>4&15|t&240,((t&15)<<4|t&15)/255):null):(t=px.exec(e))?new kx(t[1],t[2],t[3],1):(t=mx.exec(e))?new kx(t[1]*255/100,t[2]*255/100,t[3]*255/100,1):(t=hx.exec(e))?Ex(t[1],t[2],t[3],t[4]):(t=gx.exec(e))?Ex(t[1]*255/100,t[2]*255/100,t[3]*255/100,t[4]):(t=_x.exec(e))?Ix(t[1],t[2]/100,t[3]/100,1):(t=vx.exec(e))?Ix(t[1],t[2]/100,t[3]/100,t[4]):yx.hasOwnProperty(e)?Tx(yx[e]):e===`transparent`?new kx(NaN,NaN,NaN,0):null}function Tx(e){return new kx(e>>16&255,e>>8&255,e&255,1)}function Ex(e,t,n,r){return r<=0&&(e=t=n=NaN),new kx(e,t,n,r)}function Dx(e){return e instanceof ox||(e=wx(e)),e?(e=e.rgb(),new kx(e.r,e.g,e.b,e.opacity)):new kx}function Ox(e,t,n,r){return arguments.length===1?Dx(e):new kx(e,t,n,r??1)}function kx(e,t,n,r){this.r=+e,this.g=+t,this.b=+n,this.opacity=+r}ix(kx,Ox,ax(ox,{brighter(e){return e=e==null?cx:cx**+e,new kx(this.r*e,this.g*e,this.b*e,this.opacity)},darker(e){return e=e==null?sx:sx**+e,new kx(this.r*e,this.g*e,this.b*e,this.opacity)},rgb(){return this},clamp(){return new kx(Px(this.r),Px(this.g),Px(this.b),Nx(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Ax,formatHex:Ax,formatHex8:jx,formatRgb:Mx,toString:Mx}));function Ax(){return`#${Fx(this.r)}${Fx(this.g)}${Fx(this.b)}`}function jx(){return`#${Fx(this.r)}${Fx(this.g)}${Fx(this.b)}${Fx((isNaN(this.opacity)?1:this.opacity)*255)}`}function Mx(){let e=Nx(this.opacity);return`${e===1?`rgb(`:`rgba(`}${Px(this.r)}, ${Px(this.g)}, ${Px(this.b)}${e===1?`)`:`, ${e})`}`}function Nx(e){return isNaN(e)?1:Math.max(0,Math.min(1,e))}function Px(e){return Math.max(0,Math.min(255,Math.round(e)||0))}function Fx(e){return e=Px(e),(e<16?`0`:``)+e.toString(16)}function Ix(e,t,n,r){return r<=0?e=t=n=NaN:n<=0||n>=1?e=t=NaN:t<=0&&(e=NaN),new zx(e,t,n,r)}function Lx(e){if(e instanceof zx)return new zx(e.h,e.s,e.l,e.opacity);if(e instanceof ox||(e=wx(e)),!e)return new zx;if(e instanceof zx)return e;e=e.rgb();var t=e.r/255,n=e.g/255,r=e.b/255,i=Math.min(t,n,r),a=Math.max(t,n,r),o=NaN,s=a-i,c=(a+i)/2;return s?(o=t===a?(n-r)/s+(n0&&c<1?0:o,new zx(o,s,c,e.opacity)}function Rx(e,t,n,r){return arguments.length===1?Lx(e):new zx(e,t,n,r??1)}function zx(e,t,n,r){this.h=+e,this.s=+t,this.l=+n,this.opacity=+r}ix(zx,Rx,ax(ox,{brighter(e){return e=e==null?cx:cx**+e,new zx(this.h,this.s,this.l*e,this.opacity)},darker(e){return e=e==null?sx:sx**+e,new zx(this.h,this.s,this.l*e,this.opacity)},rgb(){var e=this.h%360+(this.h<0)*360,t=isNaN(e)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*t,i=2*n-r;return new kx(Hx(e>=240?e-240:e+120,i,r),Hx(e,i,r),Hx(e<120?e+240:e-120,i,r),this.opacity)},clamp(){return new zx(Bx(this.h),Vx(this.s),Vx(this.l),Nx(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){let e=Nx(this.opacity);return`${e===1?`hsl(`:`hsla(`}${Bx(this.h)}, ${Vx(this.s)*100}%, ${Vx(this.l)*100}%${e===1?`)`:`, ${e})`}`}}));function Bx(e){return e=(e||0)%360,e<0?e+360:e}function Vx(e){return Math.max(0,Math.min(1,e||0))}function Hx(e,t,n){return(e<60?t+(n-t)*e/60:e<180?n:e<240?t+(n-t)*(240-e)/60:t)*255}var Ux=e=>()=>e;function Wx(e,t){return function(n){return e+n*t}}function Gx(e,t,n){return e**=+n,t=t**+n-e,n=1/n,function(r){return(e+r*t)**+n}}function Kx(e){return(e=+e)==1?qx:function(t,n){return n-t?Gx(t,n,e):Ux(isNaN(t)?n:t)}}function qx(e,t){var n=t-e;return n?Wx(e,n):Ux(isNaN(e)?t:e)}var Jx=(function e(t){var n=Kx(t);function r(e,t){var r=n((e=Ox(e)).r,(t=Ox(t)).r),i=n(e.g,t.g),a=n(e.b,t.b),o=qx(e.opacity,t.opacity);return function(t){return e.r=r(t),e.g=i(t),e.b=a(t),e.opacity=o(t),e+``}}return r.gamma=e,r})(1);function Yx(e,t){t||=[];var n=e?Math.min(t.length,e.length):0,r=t.slice(),i;return function(a){for(i=0;in&&(a=t.slice(n,a),s[o]?s[o]+=a:s[++o]=a),(r=r[0])===(i=i[0])?s[o]?s[o]+=i:s[++o]=i:(s[++o]=null,c.push({i:o,x:$x(r,i)})),n=nS.lastIndex;return n180?t+=360:t-e>180&&(e+=360),a.push({i:n.push(i(n)+`rotate(`,null,r)-2,x:$x(e,t)}))}function s(e,t,n,a){e===t?t&&n.push(i(n)+`skewX(`+t+r):a.push({i:n.push(i(n)+`skewX(`,null,r)-2,x:$x(e,t)})}function c(e,t,n,r,a,o){if(e!==n||t!==r){var s=a.push(i(a)+`scale(`,null,`,`,null,`)`);o.push({i:s-4,x:$x(e,n)},{i:s-2,x:$x(t,r)})}else (n!==1||r!==1)&&a.push(i(a)+`scale(`+n+`,`+r+`)`)}return function(t,n){var r=[],i=[];return t=e(t),n=e(n),a(t.translateX,t.translateY,n.translateX,n.translateY,r,i),o(t.rotate,n.rotate,r,i),s(t.skewX,n.skewX,r,i),c(t.scaleX,t.scaleY,n.scaleX,n.scaleY,r,i),t=n=null,function(e){for(var t=-1,n=i.length,a;++t=0&&e._call.call(void 0,t),e=e._next;--xS}function LS(){OS=(DS=AS.now())+kS,xS=SS=0;try{IS()}finally{xS=0,zS(),OS=0}}function RS(){var e=AS.now(),t=e-DS;t>wS&&(kS-=t,DS=e)}function zS(){for(var e,t=TS,n,r=1/0;t;)t._call?(r>t._time&&(r=t._time),e=t,t=t._next):(n=t._next,t._next=null,t=e?e._next=n:TS=n);ES=e,BS(r)}function BS(e){xS||(SS&&=clearTimeout(SS),e-OS>24?(e<1/0&&(SS=setTimeout(LS,e-AS.now()-kS)),CS&&=clearInterval(CS)):(CS||=(DS=AS.now(),setInterval(RS,wS)),xS=1,jS(LS)))}function VS(e,t,n){var r=new PS;return t=t==null?0:+t,r.restart(n=>{r.stop(),e(n+t)},t,n),r}var HS=zv(`start`,`end`,`cancel`,`interrupt`),US=[];function WS(e,t,n,r,i,a){var o=e.__transition;if(!o)e.__transition={};else if(n in o)return;JS(e,n,{name:t,index:r,group:i,on:HS,tween:US,time:a.time,delay:a.delay,duration:a.duration,ease:a.ease,timer:null,state:0})}function GS(e,t){var n=qS(e,t);if(n.state>0)throw Error(`too late; already scheduled`);return n}function KS(e,t){var n=qS(e,t);if(n.state>3)throw Error(`too late; already running`);return n}function qS(e,t){var n=e.__transition;if(!n||!(n=n[t]))throw Error(`transition not found`);return n}function JS(e,t,n){var r=e.__transition,i;r[t]=n,n.timer=FS(a,0,n.time);function a(e){n.state=1,n.timer.restart(o,n.delay,n.time),n.delay<=e&&o(e-n.delay)}function o(a){var l,u,d,f;if(n.state!==1)return c();for(l in r)if(f=r[l],f.name===n.name){if(f.state===3)return VS(o);f.state===4?(f.state=6,f.timer.stop(),f.on.call(`interrupt`,e,e.__data__,f.index,f.group),delete r[l]):+l2&&r.state<5,r.state=6,r.timer.stop(),r.on.call(i?`interrupt`:`cancel`,e,e.__data__,r.index,r.group),delete n[o]}a&&delete e.__transition}}function XS(e){return this.each(function(){YS(this,e)})}function ZS(e,t){var n,r;return function(){var i=KS(this,e),a=i.tween;if(a!==n){r=n=a;for(var o=0,s=r.length;o=0&&(e=e.slice(0,t)),!e||e===`start`})}function DC(e,t,n){var r,i,a=EC(t)?GS:KS;return function(){var o=a(this,e),s=o.on;s!==r&&(i=(r=s).copy()).on(t,n),o.on=i}}function OC(e,t){var n=this._id;return arguments.length<2?qS(this.node(),n).on.on(e):this.each(DC(n,e,t))}function kC(e){return function(){var t=this.parentNode;for(var n in this.__transition)if(+n!==e)return;t&&t.removeChild(this)}}function AC(){return this.on(`end.remove`,kC(this._id))}function jC(e){var t=this._name,n=this._id;typeof e!=`function`&&(e=Xv(e));for(var r=this._groups,i=r.length,a=Array(i),o=0;o()=>e;function cw(e,{sourceEvent:t,target:n,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:e,enumerable:!0,configurable:!0},sourceEvent:{value:t,enumerable:!0,configurable:!0},target:{value:n,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function lw(e,t,n){this.k=e,this.x=t,this.y=n}lw.prototype={constructor:lw,scale:function(e){return e===1?this:new lw(this.k*e,this.x,this.y)},translate:function(e,t){return e===0&t===0?this:new lw(this.k,this.x+this.k*e,this.y+this.k*t)},apply:function(e){return[e[0]*this.k+this.x,e[1]*this.k+this.y]},applyX:function(e){return e*this.k+this.x},applyY:function(e){return e*this.k+this.y},invert:function(e){return[(e[0]-this.x)/this.k,(e[1]-this.y)/this.k]},invertX:function(e){return(e-this.x)/this.k},invertY:function(e){return(e-this.y)/this.k},rescaleX:function(e){return e.copy().domain(e.range().map(this.invertX,this).map(e.invert,e))},rescaleY:function(e){return e.copy().domain(e.range().map(this.invertY,this).map(e.invert,e))},toString:function(){return`translate(`+this.x+`,`+this.y+`) scale(`+this.k+`)`}};var uw=new lw(1,0,0);dw.prototype=lw.prototype;function dw(e){for(;!e.__zoom;)if(!(e=e.parentNode))return uw;return e.__zoom}function fw(e){e.stopImmediatePropagation()}function pw(e){e.preventDefault(),e.stopImmediatePropagation()}function mw(e){return(!e.ctrlKey||e.type===`wheel`)&&!e.button}function hw(){var e=this;return e instanceof SVGElement?(e=e.ownerSVGElement||e,e.hasAttribute(`viewBox`)?(e=e.viewBox.baseVal,[[e.x,e.y],[e.x+e.width,e.y+e.height]]):[[0,0],[e.width.baseVal.value,e.height.baseVal.value]]):[[0,0],[e.clientWidth,e.clientHeight]]}function gw(){return this.__zoom||uw}function _w(e){return-e.deltaY*(e.deltaMode===1?.05:e.deltaMode?1:.002)*(e.ctrlKey?10:1)}function vw(){return navigator.maxTouchPoints||`ontouchstart`in this}function yw(e,t,n){var r=e.invertX(t[0][0])-n[0][0],i=e.invertX(t[1][0])-n[1][0],a=e.invertY(t[0][1])-n[0][1],o=e.invertY(t[1][1])-n[1][1];return e.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),o>a?(a+o)/2:Math.min(0,a)||Math.max(0,o))}function bw(){var e=mw,t=hw,n=yw,r=_w,i=vw,a=[0,1/0],o=[[-1/0,-1/0],[1/0,1/0]],s=250,c=bS,l=zv(`start`,`zoom`,`end`),u,d,f,p=500,m=150,h=0,g=10;function _(e){e.property(`__zoom`,gw).on(`wheel.zoom`,w,{passive:!1}).on(`mousedown.zoom`,T).on(`dblclick.zoom`,E).filter(i).on(`touchstart.zoom`,D).on(`touchmove.zoom`,O).on(`touchend.zoom touchcancel.zoom`,k).style(`-webkit-tap-highlight-color`,`rgba(0,0,0,0)`)}_.transform=function(e,t,n,r){var i=e.selection?e.selection():e;i.property(`__zoom`,gw),e===i?i.interrupt().each(function(){S(this,arguments).event(r).start().zoom(null,typeof t==`function`?t.apply(this,arguments):t).end()}):x(e,t,n,r)},_.scaleBy=function(e,t,n,r){_.scaleTo(e,function(){return this.__zoom.k*(typeof t==`function`?t.apply(this,arguments):t)},n,r)},_.scaleTo=function(e,r,i,a){_.transform(e,function(){var e=t.apply(this,arguments),a=this.__zoom,s=i==null?b(e):typeof i==`function`?i.apply(this,arguments):i,c=a.invert(s),l=typeof r==`function`?r.apply(this,arguments):r;return n(y(v(a,l),s,c),e,o)},i,a)},_.translateBy=function(e,r,i,a){_.transform(e,function(){return n(this.__zoom.translate(typeof r==`function`?r.apply(this,arguments):r,typeof i==`function`?i.apply(this,arguments):i),t.apply(this,arguments),o)},null,a)},_.translateTo=function(e,r,i,a,s){_.transform(e,function(){var e=t.apply(this,arguments),s=this.__zoom,c=a==null?b(e):typeof a==`function`?a.apply(this,arguments):a;return n(uw.translate(c[0],c[1]).scale(s.k).translate(typeof r==`function`?-r.apply(this,arguments):-r,typeof i==`function`?-i.apply(this,arguments):-i),e,o)},a,s)};function v(e,t){return t=Math.max(a[0],Math.min(a[1],t)),t===e.k?e:new lw(t,e.x,e.y)}function y(e,t,n){var r=t[0]-n[0]*e.k,i=t[1]-n[1]*e.k;return r===e.x&&i===e.y?e:new lw(e.k,r,i)}function b(e){return[(+e[0][0]+ +e[1][0])/2,(+e[0][1]+ +e[1][1])/2]}function x(e,n,r,i){e.on(`start.zoom`,function(){S(this,arguments).event(i).start()}).on(`interrupt.zoom end.zoom`,function(){S(this,arguments).event(i).end()}).tween(`zoom`,function(){var e=this,a=arguments,o=S(e,a).event(i),s=t.apply(e,a),l=r==null?b(s):typeof r==`function`?r.apply(e,a):r,u=Math.max(s[1][0]-s[0][0],s[1][1]-s[0][1]),d=e.__zoom,f=typeof n==`function`?n.apply(e,a):n,p=c(d.invert(l).concat(u/d.k),f.invert(l).concat(u/f.k));return function(e){if(e===1)e=f;else{var t=p(e),n=u/t[2];e=new lw(n,l[0]-t[0]*n,l[1]-t[1]*n)}o.zoom(null,e)}})}function S(e,t,n){return!n&&e.__zooming||new C(e,t)}function C(e,n){this.that=e,this.args=n,this.active=0,this.sourceEvent=null,this.extent=t.apply(e,n),this.taps=0}C.prototype={event:function(e){return e&&(this.sourceEvent=e),this},start:function(){return++this.active===1&&(this.that.__zooming=this,this.emit(`start`)),this},zoom:function(e,t){return this.mouse&&e!==`mouse`&&(this.mouse[1]=t.invert(this.mouse[0])),this.touch0&&e!==`touch`&&(this.touch0[1]=t.invert(this.touch0[0])),this.touch1&&e!==`touch`&&(this.touch1[1]=t.invert(this.touch1[0])),this.that.__zoom=t,this.emit(`zoom`),this},end:function(){return--this.active===0&&(delete this.that.__zooming,this.emit(`end`)),this},emit:function(e){var t=Hb(this.that).datum();l.call(e,this.that,new cw(e,{sourceEvent:this.sourceEvent,target:_,type:e,transform:this.that.__zoom,dispatch:l}),t)}};function w(t,...i){if(!e.apply(this,arguments))return;var s=S(this,i).event(t),c=this.__zoom,l=Math.max(a[0],Math.min(a[1],c.k*2**r.apply(this,arguments))),u=Wb(t);if(s.wheel)(s.mouse[0][0]!==u[0]||s.mouse[0][1]!==u[1])&&(s.mouse[1]=c.invert(s.mouse[0]=u)),clearTimeout(s.wheel);else if(c.k===l)return;else s.mouse=[u,c.invert(u)],YS(this),s.start();pw(t),s.wheel=setTimeout(d,m),s.zoom(`mouse`,n(y(v(c,l),s.mouse[0],s.mouse[1]),s.extent,o));function d(){s.wheel=null,s.end()}}function T(t,...r){if(f||!e.apply(this,arguments))return;var i=t.currentTarget,a=S(this,r,!0).event(t),s=Hb(t.view).on(`mousemove.zoom`,d,!0).on(`mouseup.zoom`,p,!0),c=Wb(t,i),l=t.clientX,u=t.clientY;Yb(t.view),fw(t),a.mouse=[c,this.__zoom.invert(c)],YS(this),a.start();function d(e){if(pw(e),!a.moved){var t=e.clientX-l,r=e.clientY-u;a.moved=t*t+r*r>h}a.event(e).zoom(`mouse`,n(y(a.that.__zoom,a.mouse[0]=Wb(e,i),a.mouse[1]),a.extent,o))}function p(e){s.on(`mousemove.zoom mouseup.zoom`,null),Xb(e.view,a.moved),pw(e),a.event(e).end()}}function E(r,...i){if(e.apply(this,arguments)){var a=this.__zoom,c=Wb(r.changedTouches?r.changedTouches[0]:r,this),l=a.invert(c),u=a.k*(r.shiftKey?.5:2),d=n(y(v(a,u),c,l),t.apply(this,i),o);pw(r),s>0?Hb(this).transition().duration(s).call(x,d,c,r):Hb(this).call(_.transform,d,c,r)}}function D(t,...n){if(e.apply(this,arguments)){var r=t.touches,i=r.length,a=S(this,n,t.changedTouches.length===i).event(t),o,s,c,l;for(fw(t),s=0;s`[React Flow]: Seems like you have not used zustand provider as an ancestor. Help: https://reactflow.dev/error#001`,error002:()=>`It looks like you've created a new nodeTypes or edgeTypes object. If this wasn't on purpose please define the nodeTypes/edgeTypes outside of the component or memoize them.`,error003:e=>`Node type "${e}" not found. Using fallback type "default".`,error004:()=>`The React Flow parent container needs a width and a height to render the graph.`,error005:()=>`Only child nodes can use a parent extent.`,error006:()=>`Can't create edge. An edge needs a source and a target.`,error007:e=>`The old edge with id=${e} does not exist.`,error009:e=>`Marker type "${e}" doesn't exist.`,error008:(e,{id:t,sourceHandle:n,targetHandle:r})=>`Couldn't create edge for ${e} handle id: "${e===`source`?n:r}", edge id: ${t}.`,error010:()=>`Handle: No node id found. Make sure to only use a Handle inside a custom Node.`,error011:e=>`Edge type "${e}" not found. Using fallback type "default".`,error012:e=>`Node with id "${e}" does not exist, it may have been removed. This can happen when a node is deleted before the "onNodeClick" handler is called.`,error013:(e=`react`)=>`It seems that you haven't loaded the styles. Please import '@xyflow/${e}/dist/style.css' or base.css to make sure everything is working properly.`,error014:()=>`useNodeConnections: No node ID found. Call useNodeConnections inside a custom Node or provide a node ID.`,error015:()=>`It seems that you are trying to drag a node that is not initialized. Please use onNodesChange as explained in the docs.`},Sw=[[-1/0,-1/0],[1/0,1/0]],Cw=[`Enter`,` `,`Escape`],ww={"node.a11yDescription.default":`Press enter or space to select a node. Press delete to remove it and escape to cancel.`,"node.a11yDescription.keyboardDisabled":`Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.`,"node.a11yDescription.ariaLiveMessage":({direction:e,x:t,y:n})=>`Moved selected node ${e}. New position, x: ${t}, y: ${n}`,"edge.a11yDescription.default":`Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.`,"controls.ariaLabel":`Control Panel`,"controls.zoomIn.ariaLabel":`Zoom In`,"controls.zoomOut.ariaLabel":`Zoom Out`,"controls.fitView.ariaLabel":`Fit View`,"controls.interactive.ariaLabel":`Toggle Interactivity`,"minimap.ariaLabel":`Mini Map`,"handle.ariaLabel":`Handle`},Tw;(function(e){e.Strict=`strict`,e.Loose=`loose`})(Tw||={});var Ew;(function(e){e.Free=`free`,e.Vertical=`vertical`,e.Horizontal=`horizontal`})(Ew||={});var Dw;(function(e){e.Partial=`partial`,e.Full=`full`})(Dw||={});var Ow={inProgress:!1,isValid:null,from:null,fromHandle:null,fromPosition:null,fromNode:null,to:null,toHandle:null,toPosition:null,toNode:null},kw;(function(e){e.Bezier=`default`,e.Straight=`straight`,e.Step=`step`,e.SmoothStep=`smoothstep`,e.SimpleBezier=`simplebezier`})(kw||={});var Aw;(function(e){e.Arrow=`arrow`,e.ArrowClosed=`arrowclosed`})(Aw||={});var $;(function(e){e.Left=`left`,e.Top=`top`,e.Right=`right`,e.Bottom=`bottom`})($||={});var jw={[$.Left]:$.Right,[$.Right]:$.Left,[$.Top]:$.Bottom,[$.Bottom]:$.Top};function Mw(e){return e===null?null:e?`valid`:`invalid`}var Nw=e=>`id`in e&&`source`in e&&`target`in e,Pw=e=>`id`in e&&`position`in e&&!(`source`in e)&&!(`target`in e),Fw=e=>`id`in e&&`internals`in e&&!(`source`in e)&&!(`target`in e),Iw=(e,t=[0,0])=>{let{width:n,height:r}=hT(e),i=e.origin??t,a=n*i[0],o=r*i[1];return{x:e.position.x-a,y:e.position.y-o}},Lw=(e,t={nodeOrigin:[0,0]})=>e.length===0?{x:0,y:0,width:0,height:0}:Qw(e.reduce((e,n)=>{let r=typeof n==`string`,i=!t.nodeLookup&&!r?n:void 0;return t.nodeLookup&&(i=r?t.nodeLookup.get(n):Fw(n)?n:t.nodeLookup.get(n.id)),Xw(e,i?eT(i,t.nodeOrigin):{x:0,y:0,x2:0,y2:0})},{x:1/0,y:1/0,x2:-1/0,y2:-1/0})),Rw=(e,t={})=>{if(e.size===0)return{x:0,y:0,width:0,height:0};let n={x:1/0,y:1/0,x2:-1/0,y2:-1/0};return e.forEach(e=>{if(t.filter===void 0||t.filter(e)){let t=eT(e);n=Xw(n,t)}}),Qw(n)},zw=(e,t,[n,r,i]=[0,0,1],a=!1,o=!1)=>{let s={...sT(t,[n,r,i]),width:t.width/i,height:t.height/i},c=[];for(let t of e.values()){let{measured:e,selectable:n=!0,hidden:r=!1}=t;if(o&&!n||r)continue;let i=e.width??t.width??t.initialWidth??null,l=e.height??t.height??t.initialHeight??null,u=nT(s,$w(t)),d=(i??0)*(l??0),f=a&&u>0;(!t.internals.handleBounds||f||u>=d||t.dragging)&&c.push(t)}return c},Bw=(e,t)=>{let n=new Set;return e.forEach(e=>{n.add(e.id)}),t.filter(e=>n.has(e.source)||n.has(e.target))};function Vw(e,t){let n=new Map,r=t?.nodes?new Set(t.nodes.map(e=>e.id)):null;return e.forEach(e=>{e.measured.width&&e.measured.height&&(t?.includeHiddenNodes||!e.hidden)&&(!r||r.has(e.id))&&n.set(e.id,e)}),n}async function Hw({nodes:e,width:t,height:n,panZoom:r,minZoom:i,maxZoom:a},o){if(e.size===0)return Promise.resolve(!0);let s=fT(Rw(Vw(e,o)),t,n,o?.minZoom??i,o?.maxZoom??a,o?.padding??.1);return await r.setViewport(s,{duration:o?.duration,ease:o?.ease,interpolate:o?.interpolate}),Promise.resolve(!0)}function Uw({nodeId:e,nextPosition:t,nodeLookup:n,nodeOrigin:r=[0,0],nodeExtent:i,onError:a}){let o=n.get(e),s=o.parentId?n.get(o.parentId):void 0,{x:c,y:l}=s?s.internals.positionAbsolute:{x:0,y:0},u=o.origin??r,d=o.extent||i;if(o.extent===`parent`&&!o.expandParent)if(!s)a?.(`005`,xw.error005());else{let e=s.measured.width,t=s.measured.height;e&&t&&(d=[[c,l],[c+e,l+t]])}else s&&mT(o.extent)&&(d=[[o.extent[0][0]+c,o.extent[0][1]+l],[o.extent[1][0]+c,o.extent[1][1]+l]]);let f=mT(d)?Kw(t,d,o.measured):t;return(o.measured.width===void 0||o.measured.height===void 0)&&a?.(`015`,xw.error015()),{position:{x:f.x-c+(o.measured.width??0)*u[0],y:f.y-l+(o.measured.height??0)*u[1]},positionAbsolute:f}}async function Ww({nodesToRemove:e=[],edgesToRemove:t=[],nodes:n,edges:r,onBeforeDelete:i}){let a=new Set(e.map(e=>e.id)),o=[];for(let e of n){if(e.deletable===!1)continue;let t=a.has(e.id),n=!t&&e.parentId&&o.find(t=>t.id===e.parentId);(t||n)&&o.push(e)}let s=new Set(t.map(e=>e.id)),c=r.filter(e=>e.deletable!==!1),l=Bw(o,c);for(let e of c)s.has(e.id)&&!l.find(t=>t.id===e.id)&&l.push(e);if(!i)return{edges:l,nodes:o};let u=await i({nodes:o,edges:l});return typeof u==`boolean`?u?{edges:l,nodes:o}:{edges:[],nodes:[]}:u}var Gw=(e,t=0,n=1)=>Math.min(Math.max(e,t),n),Kw=(e={x:0,y:0},t,n)=>({x:Gw(e.x,t[0][0],t[1][0]-(n?.width??0)),y:Gw(e.y,t[0][1],t[1][1]-(n?.height??0))});function qw(e,t,n){let{width:r,height:i}=hT(n),{x:a,y:o}=n.internals.positionAbsolute;return Kw(e,[[a,o],[a+r,o+i]],t)}var Jw=(e,t,n)=>en?-Gw(Math.abs(e-n),1,t)/t:0,Yw=(e,t,n=15,r=40)=>[Jw(e.x,r,t.width-r)*n,Jw(e.y,r,t.height-r)*n],Xw=(e,t)=>({x:Math.min(e.x,t.x),y:Math.min(e.y,t.y),x2:Math.max(e.x2,t.x2),y2:Math.max(e.y2,t.y2)}),Zw=({x:e,y:t,width:n,height:r})=>({x:e,y:t,x2:e+n,y2:t+r}),Qw=({x:e,y:t,x2:n,y2:r})=>({x:e,y:t,width:n-e,height:r-t}),$w=(e,t=[0,0])=>{let{x:n,y:r}=Fw(e)?e.internals.positionAbsolute:Iw(e,t);return{x:n,y:r,width:e.measured?.width??e.width??e.initialWidth??0,height:e.measured?.height??e.height??e.initialHeight??0}},eT=(e,t=[0,0])=>{let{x:n,y:r}=Fw(e)?e.internals.positionAbsolute:Iw(e,t);return{x:n,y:r,x2:n+(e.measured?.width??e.width??e.initialWidth??0),y2:r+(e.measured?.height??e.height??e.initialHeight??0)}},tT=(e,t)=>Qw(Xw(Zw(e),Zw(t))),nT=(e,t)=>{let n=Math.max(0,Math.min(e.x+e.width,t.x+t.width)-Math.max(e.x,t.x)),r=Math.max(0,Math.min(e.y+e.height,t.y+t.height)-Math.max(e.y,t.y));return Math.ceil(n*r)},rT=e=>iT(e.width)&&iT(e.height)&&iT(e.x)&&iT(e.y),iT=e=>!isNaN(e)&&isFinite(e),aT=(e,t)=>{},oT=(e,t=[1,1])=>({x:t[0]*Math.round(e.x/t[0]),y:t[1]*Math.round(e.y/t[1])}),sT=({x:e,y:t},[n,r,i],a=!1,o=[1,1])=>{let s={x:(e-n)/i,y:(t-r)/i};return a?oT(s,o):s},cT=({x:e,y:t},[n,r,i])=>({x:e*i+n,y:t*i+r});function lT(e,t){if(typeof e==`number`)return Math.floor((t-t/(1+e))*.5);if(typeof e==`string`&&e.endsWith(`px`)){let t=parseFloat(e);if(!Number.isNaN(t))return Math.floor(t)}if(typeof e==`string`&&e.endsWith(`%`)){let n=parseFloat(e);if(!Number.isNaN(n))return Math.floor(t*n*.01)}return console.error(`[React Flow] The padding value "${e}" is invalid. Please provide a number or a string with a valid unit (px or %).`),0}function uT(e,t,n){if(typeof e==`string`||typeof e==`number`){let r=lT(e,n),i=lT(e,t);return{top:r,right:i,bottom:r,left:i,x:i*2,y:r*2}}if(typeof e==`object`){let r=lT(e.top??e.y??0,n),i=lT(e.bottom??e.y??0,n),a=lT(e.left??e.x??0,t),o=lT(e.right??e.x??0,t);return{top:r,right:o,bottom:i,left:a,x:a+o,y:r+i}}return{top:0,right:0,bottom:0,left:0,x:0,y:0}}function dT(e,t,n,r,i,a){let{x:o,y:s}=cT(e,[t,n,r]),{x:c,y:l}=cT({x:e.x+e.width,y:e.y+e.height},[t,n,r]),u=i-c,d=a-l;return{left:Math.floor(o),top:Math.floor(s),right:Math.floor(u),bottom:Math.floor(d)}}var fT=(e,t,n,r,i,a)=>{let o=uT(a,t,n),s=(t-o.x)/e.width,c=(n-o.y)/e.height,l=Gw(Math.min(s,c),r,i),u=e.x+e.width/2,d=e.y+e.height/2,f=t/2-u*l,p=n/2-d*l,m=dT(e,f,p,l,t,n),h={left:Math.min(m.left-o.left,0),top:Math.min(m.top-o.top,0),right:Math.min(m.right-o.right,0),bottom:Math.min(m.bottom-o.bottom,0)};return{x:f-h.left+h.right,y:p-h.top+h.bottom,zoom:l}},pT=()=>typeof navigator<`u`&&navigator?.userAgent?.indexOf(`Mac`)>=0;function mT(e){return e!=null&&e!==`parent`}function hT(e){return{width:e.measured?.width??e.width??e.initialWidth??0,height:e.measured?.height??e.height??e.initialHeight??0}}function gT(e){return(e.measured?.width??e.width??e.initialWidth)!==void 0&&(e.measured?.height??e.height??e.initialHeight)!==void 0}function _T(e,t={width:0,height:0},n,r,i){let a={...e},o=r.get(n);if(o){let e=o.origin||i;a.x+=o.internals.positionAbsolute.x-(t.width??0)*e[0],a.y+=o.internals.positionAbsolute.y-(t.height??0)*e[1]}return a}function vT(e,t){if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0}function yT(){let e,t;return{promise:new Promise((n,r)=>{e=n,t=r}),resolve:e,reject:t}}function bT(e){return{...ww,...e||{}}}function xT(e,{snapGrid:t=[0,0],snapToGrid:n=!1,transform:r,containerBounds:i}){let{x:a,y:o}=DT(e),s=sT({x:a-(i?.left??0),y:o-(i?.top??0)},r),{x:c,y:l}=n?oT(s,t):s;return{xSnapped:c,ySnapped:l,...s}}var ST=e=>({width:e.offsetWidth,height:e.offsetHeight}),CT=e=>e?.getRootNode?.()||window?.document,wT=[`INPUT`,`SELECT`,`TEXTAREA`];function TT(e){let t=e.composedPath?.()?.[0]||e.target;return t?.nodeType===1?wT.includes(t.nodeName)||t.hasAttribute(`contenteditable`)||!!t.closest(`.nokey`):!1}var ET=e=>`clientX`in e,DT=(e,t)=>{let n=ET(e),r=n?e.clientX:e.touches?.[0].clientX,i=n?e.clientY:e.touches?.[0].clientY;return{x:r-(t?.left??0),y:i-(t?.top??0)}},OT=(e,t,n,r,i)=>{let a=t.querySelectorAll(`.${e}`);return!a||!a.length?null:Array.from(a).map(t=>{let a=t.getBoundingClientRect();return{id:t.getAttribute(`data-handleid`),type:e,nodeId:i,position:t.getAttribute(`data-handlepos`),x:(a.left-n.left)/r,y:(a.top-n.top)/r,...ST(t)}})};function kT({sourceX:e,sourceY:t,targetX:n,targetY:r,sourceControlX:i,sourceControlY:a,targetControlX:o,targetControlY:s}){let c=e*.125+i*.375+o*.375+n*.125,l=t*.125+a*.375+s*.375+r*.125;return[c,l,Math.abs(c-e),Math.abs(l-t)]}function AT(e,t){return e>=0?.5*e:t*25*Math.sqrt(-e)}function jT({pos:e,x1:t,y1:n,x2:r,y2:i,c:a}){switch(e){case $.Left:return[t-AT(t-r,a),n];case $.Right:return[t+AT(r-t,a),n];case $.Top:return[t,n-AT(n-i,a)];case $.Bottom:return[t,n+AT(i-n,a)]}}function MT({sourceX:e,sourceY:t,sourcePosition:n=$.Bottom,targetX:r,targetY:i,targetPosition:a=$.Top,curvature:o=.25}){let[s,c]=jT({pos:n,x1:e,y1:t,x2:r,y2:i,c:o}),[l,u]=jT({pos:a,x1:r,y1:i,x2:e,y2:t,c:o}),[d,f,p,m]=kT({sourceX:e,sourceY:t,targetX:r,targetY:i,sourceControlX:s,sourceControlY:c,targetControlX:l,targetControlY:u});return[`M${e},${t} C${s},${c} ${l},${u} ${r},${i}`,d,f,p,m]}function NT({sourceX:e,sourceY:t,targetX:n,targetY:r}){let i=Math.abs(n-e)/2,a=n0}var IT=({source:e,sourceHandle:t,target:n,targetHandle:r})=>`xy-edge__${e}${t||``}-${n}${r||``}`,LT=(e,t)=>t.some(t=>t.source===e.source&&t.target===e.target&&(t.sourceHandle===e.sourceHandle||!t.sourceHandle&&!e.sourceHandle)&&(t.targetHandle===e.targetHandle||!t.targetHandle&&!e.targetHandle)),RT=(e,t)=>{if(!e.source||!e.target)return xw.error006(),t;let n;return n=Nw(e)?{...e}:{...e,id:IT(e)},LT(n,t)?t:(n.sourceHandle===null&&delete n.sourceHandle,n.targetHandle===null&&delete n.targetHandle,t.concat(n))};function zT({sourceX:e,sourceY:t,targetX:n,targetY:r}){let[i,a,o,s]=NT({sourceX:e,sourceY:t,targetX:n,targetY:r});return[`M ${e},${t}L ${n},${r}`,i,a,o,s]}var BT={[$.Left]:{x:-1,y:0},[$.Right]:{x:1,y:0},[$.Top]:{x:0,y:-1},[$.Bottom]:{x:0,y:1}},VT=({source:e,sourcePosition:t=$.Bottom,target:n})=>t===$.Left||t===$.Right?e.xMath.sqrt((t.x-e.x)**2+(t.y-e.y)**2);function UT({source:e,sourcePosition:t=$.Bottom,target:n,targetPosition:r=$.Top,center:i,offset:a,stepPosition:o}){let s=BT[t],c=BT[r],l={x:e.x+s.x*a,y:e.y+s.y*a},u={x:n.x+c.x*a,y:n.y+c.y*a},d=VT({source:l,sourcePosition:t,target:u}),f=d.x===0?`y`:`x`,p=d[f],m=[],h,g,_={x:0,y:0},v={x:0,y:0},[,,y,b]=NT({sourceX:e.x,sourceY:e.y,targetX:n.x,targetY:n.y});if(s[f]*c[f]===-1){f===`x`?(h=i.x??l.x+(u.x-l.x)*o,g=i.y??(l.y+u.y)/2):(h=i.x??(l.x+u.x)/2,g=i.y??l.y+(u.y-l.y)*o);let e=[{x:h,y:l.y},{x:h,y:u.y}],t=[{x:l.x,y:g},{x:u.x,y:g}];m=s[f]===p?f===`x`?e:t:f===`x`?t:e}else{let i=[{x:l.x,y:u.y}],o=[{x:u.x,y:l.y}];if(m=f===`x`?s.x===p?o:i:s.y===p?i:o,t===r){let t=Math.abs(e[f]-n[f]);if(t<=a){let r=Math.min(a-1,a-t);s[f]===p?_[f]=(l[f]>e[f]?-1:1)*r:v[f]=(u[f]>n[f]?-1:1)*r}}if(t!==r){let e=f===`x`?`y`:`x`,t=s[f]===c[e],n=l[e]>u[e],r=l[e]=Math.max(Math.abs(d.y-m[0].y),Math.abs(y.y-m[0].y))?(h=(d.x+y.x)/2,g=m[0].y):(h=m[0].x,g=(d.y+y.y)/2)}return[[e,{x:l.x+_.x,y:l.y+_.y},...m,{x:u.x+v.x,y:u.y+v.y},n],h,g,y,b]}function WT(e,t,n,r){let i=Math.min(HT(e,t)/2,HT(t,n)/2,r),{x:a,y:o}=t;if(e.x===a&&a===n.x||e.y===o&&o===n.y)return`L${a} ${o}`;if(e.y===o){let t=e.x{let r=``;return r=n>0&&ne.id===t):e[0])||null}function ZT(e,t){return e?typeof e==`string`?e:`${t?`${t}__`:``}${Object.keys(e).sort().map(t=>`${t}=${e[t]}`).join(`&`)}`:``}function QT(e,{id:t,defaultColor:n,defaultMarkerStart:r,defaultMarkerEnd:i}){let a=new Set;return e.reduce((e,o)=>([o.markerStart||r,o.markerEnd||i].forEach(r=>{if(r&&typeof r==`object`){let i=ZT(r,t);a.has(i)||(e.push({id:i,color:r.color||n,...r}),a.add(i))}}),e),[]).sort((e,t)=>e.id.localeCompare(t.id))}var $T={nodeOrigin:[0,0],nodeExtent:Sw,elevateNodesOnSelect:!0,defaults:{}},eE={...$T,checkEquality:!0};function tE(e,t){let n={...e};for(let e in t)t[e]!==void 0&&(n[e]=t[e]);return n}function nE(e,t,n){let r=tE($T,n);for(let n of e.values())if(n.parentId)aE(n,e,t,r);else{let e=Kw(Iw(n,r.nodeOrigin),mT(n.extent)?n.extent:r.nodeExtent,hT(n));n.internals.positionAbsolute=e}}function rE(e,t,n,r){let i=tE(eE,r),a=e.length>0,o=new Map(t),s=i?.elevateNodesOnSelect?1e3:0;t.clear(),n.clear();for(let c of e){let e=o.get(c.id);if(i.checkEquality&&c===e?.internals.userNode)t.set(c.id,e);else{let n=Kw(Iw(c,i.nodeOrigin),mT(c.extent)?c.extent:i.nodeExtent,hT(c));e={...i.defaults,...c,measured:{width:c.measured?.width,height:c.measured?.height},internals:{positionAbsolute:n,handleBounds:c.measured?e?.internals.handleBounds:void 0,z:oE(c,s),userNode:c}},t.set(c.id,e)}(e.measured===void 0||e.measured.width===void 0||e.measured.height===void 0)&&!e.hidden&&(a=!1),c.parentId&&aE(e,t,n,r)}return a}function iE(e,t){if(!e.parentId)return;let n=t.get(e.parentId);n?n.set(e.id,e):t.set(e.parentId,new Map([[e.id,e]]))}function aE(e,t,n,r){let{elevateNodesOnSelect:i,nodeOrigin:a,nodeExtent:o}=tE($T,r),s=e.parentId,c=t.get(s);if(!c){console.warn(`Parent node ${s} not found. Please make sure that parent nodes are in front of their child nodes in the nodes array.`);return}iE(e,n);let{x:l,y:u,z:d}=sE(e,c,a,o,i?1e3:0),{positionAbsolute:f}=e.internals,p=l!==f.x||u!==f.y;(p||d!==e.internals.z)&&t.set(e.id,{...e,internals:{...e.internals,positionAbsolute:p?{x:l,y:u}:f,z:d}})}function oE(e,t){return(iT(e.zIndex)?e.zIndex:0)+(e.selected?t:0)}function sE(e,t,n,r,i){let{x:a,y:o}=t.internals.positionAbsolute,s=hT(e),c=Iw(e,n),l=mT(e.extent)?Kw(c,e.extent,s):c,u=Kw({x:a+l.x,y:o+l.y},r,s);e.extent===`parent`&&(u=qw(u,s,t));let d=oE(e,i),f=t.internals.z??0;return{x:u.x,y:u.y,z:f>=d?f+1:d}}function cE(e,t,n,r=[0,0]){let i=[],a=new Map;for(let n of e){let e=t.get(n.parentId);if(!e)continue;let r=tT(a.get(n.parentId)?.expandedRect??$w(e),n.rect);a.set(n.parentId,{expandedRect:r,parent:e})}return a.size>0&&a.forEach(({expandedRect:t,parent:a},o)=>{let s=a.internals.positionAbsolute,c=hT(a),l=a.origin??r,u=t.x0||d>0||m||h)&&(i.push({id:o,type:`position`,position:{x:a.position.x-u+m,y:a.position.y-d+h}}),n.get(o)?.forEach(t=>{e.some(e=>e.id===t.id)||i.push({id:t.id,type:`position`,position:{x:t.position.x+u,y:t.position.y+d}})})),(c.width0){let e=cE(d,t,n,i);c.push(...e)}return{changes:c,updatedInternals:s}}async function uE({delta:e,panZoom:t,transform:n,translateExtent:r,width:i,height:a}){if(!t||!e.x&&!e.y)return Promise.resolve(!1);let o=await t.setViewportConstrained({x:n[0]+e.x,y:n[1]+e.y,zoom:n[2]},[[0,0],[i,a]],r),s=!!o&&(o.x!==n[0]||o.y!==n[1]||o.k!==n[2]);return Promise.resolve(s)}function dE(e,t,n,r,i,a){let o=i,s=r.get(o)||new Map;r.set(o,s.set(n,t)),o=`${i}-${e}`;let c=r.get(o)||new Map;if(r.set(o,c.set(n,t)),a){o=`${i}-${e}-${a}`;let s=r.get(o)||new Map;r.set(o,s.set(n,t))}}function fE(e,t,n){e.clear(),t.clear();for(let r of n){let{source:n,target:i,sourceHandle:a=null,targetHandle:o=null}=r,s={edgeId:r.id,source:n,target:i,sourceHandle:a,targetHandle:o},c=`${n}-${a}--${i}-${o}`;dE(`source`,s,`${i}-${o}--${n}-${a}`,e,n,a),dE(`target`,s,c,e,i,o),t.set(r.id,r)}}function pE(e,t){if(!e.parentId)return!1;let n=t.get(e.parentId);return n?n.selected?!0:pE(n,t):!1}function mE(e,t,n){let r=e;do{if(r?.matches?.(t))return!0;if(r===n)return!1;r=r?.parentElement}while(r);return!1}function hE(e,t,n,r){let i=new Map;for(let[a,o]of e)if((o.selected||o.id===r)&&(!o.parentId||!pE(o,e))&&(o.draggable||t&&o.draggable===void 0)){let t=e.get(a);t&&i.set(a,{id:a,position:t.position||{x:0,y:0},distance:{x:n.x-t.internals.positionAbsolute.x,y:n.y-t.internals.positionAbsolute.y},extent:t.extent,parentId:t.parentId,origin:t.origin,expandParent:t.expandParent,internals:{positionAbsolute:t.internals.positionAbsolute||{x:0,y:0}},measured:{width:t.measured.width??0,height:t.measured.height??0}})}return i}function gE({nodeId:e,dragItems:t,nodeLookup:n,dragging:r=!0}){let i=[];for(let[e,a]of t){let t=n.get(e)?.internals.userNode;t&&i.push({...t,position:a.position,dragging:r})}if(!e)return[i[0],i];let a=n.get(e)?.internals.userNode;return[a?{...a,position:t.get(e)?.position||a.position,dragging:r}:i[0],i]}function _E({dragItems:e,snapGrid:t,x:n,y:r}){let i=e.values().next().value;if(!i)return null;let a={x:n-i.distance.x,y:r-i.distance.y},o=oT(a,t);return{x:o.x-a.x,y:o.y-a.y}}function vE({onNodeMouseDown:e,getStoreItems:t,onDragStart:n,onDrag:r,onDragStop:i}){let a={x:null,y:null},o=0,s=new Map,c=!1,l={x:0,y:0},u=null,d=!1,f=null,p=!1,m=!1,h=null;function g({noDragClassName:g,handleSelector:_,domNode:v,isSelectable:y,nodeId:b,nodeClickDistance:x=0}){f=Hb(v);function S({x:e,y:n}){let{nodeLookup:i,nodeExtent:o,snapGrid:c,snapToGrid:l,nodeOrigin:u,onNodeDrag:d,onSelectionDrag:f,onError:p,updateNodePositions:g}=t();a={x:e,y:n};let _=!1,v=s.size>1,y=v&&o?Zw(Rw(s)):null,x=v&&l?_E({dragItems:s,snapGrid:c,x:e,y:n}):null;for(let[t,r]of s){if(!i.has(t))continue;let a={x:e-r.distance.x,y:n-r.distance.y};l&&(a=x?{x:Math.round(a.x+x.x),y:Math.round(a.y+x.y)}:oT(a,c));let s=null;if(v&&o&&!r.extent&&y){let{positionAbsolute:e}=r.internals,t=e.x-y.x+o[0][0],n=e.x+r.measured.width-y.x2+o[1][0],i=e.y-y.y+o[0][1],a=e.y+r.measured.height-y.y2+o[1][1];s=[[t,i],[n,a]]}let{position:d,positionAbsolute:f}=Uw({nodeId:t,nextPosition:a,nodeLookup:i,nodeExtent:s||o,nodeOrigin:u,onError:p});_=_||r.position.x!==d.x||r.position.y!==d.y,r.position=d,r.internals.positionAbsolute=f}if(m||=_,_&&(g(s,!0),h&&(r||d||!b&&f))){let[e,t]=gE({nodeId:b,dragItems:s,nodeLookup:i});r?.(h,s,e,t),d?.(h,e,t),b||f?.(h,t)}}async function C(){if(!u)return;let{transform:e,panBy:n,autoPanSpeed:r,autoPanOnNodeDrag:i}=t();if(!i){c=!1,cancelAnimationFrame(o);return}let[s,d]=Yw(l,u,r);(s!==0||d!==0)&&(a.x=(a.x??0)-s/e[2],a.y=(a.y??0)-d/e[2],await n({x:s,y:d})&&S(a)),o=requestAnimationFrame(C)}function w(r){let{nodeLookup:i,multiSelectionActive:o,nodesDraggable:c,transform:l,snapGrid:f,snapToGrid:p,selectNodesOnDrag:m,onNodeDragStart:h,onSelectionDragStart:g,unselectNodesAndEdges:_}=t();d=!0,(!m||!y)&&!o&&b&&(i.get(b)?.selected||_()),y&&m&&b&&e?.(b);let v=xT(r.sourceEvent,{transform:l,snapGrid:f,snapToGrid:p,containerBounds:u});if(a=v,s=hE(i,c,v,b),s.size>0&&(n||h||!b&&g)){let[e,t]=gE({nodeId:b,dragItems:s,nodeLookup:i});n?.(r.sourceEvent,s,e,t),h?.(r.sourceEvent,e,t),b||g?.(r.sourceEvent,t)}}let T=rx().clickDistance(x).on(`start`,e=>{let{domNode:n,nodeDragThreshold:r,transform:i,snapGrid:o,snapToGrid:s}=t();u=n?.getBoundingClientRect()||null,p=!1,m=!1,h=e.sourceEvent,r===0&&w(e),a=xT(e.sourceEvent,{transform:i,snapGrid:o,snapToGrid:s,containerBounds:u}),l=DT(e.sourceEvent,u)}).on(`drag`,e=>{let{autoPanOnNodeDrag:n,transform:r,snapGrid:i,snapToGrid:o,nodeDragThreshold:f,nodeLookup:m}=t(),g=xT(e.sourceEvent,{transform:r,snapGrid:i,snapToGrid:o,containerBounds:u});if(h=e.sourceEvent,(e.sourceEvent.type===`touchmove`&&e.sourceEvent.touches.length>1||b&&!m.has(b))&&(p=!0),!p){if(!c&&n&&d&&(c=!0,C()),!d){let t=g.xSnapped-(a.x??0),n=g.ySnapped-(a.y??0);Math.sqrt(t*t+n*n)>f&&w(e)}(a.x!==g.xSnapped||a.y!==g.ySnapped)&&s&&d&&(l=DT(e.sourceEvent,u),S(g))}}).on(`end`,e=>{if(!(!d||p)&&(c=!1,d=!1,cancelAnimationFrame(o),s.size>0)){let{nodeLookup:n,updateNodePositions:r,onNodeDragStop:a,onSelectionDragStop:o}=t();if(m&&=(r(s,!1),!1),i||a||!b&&o){let[t,r]=gE({nodeId:b,dragItems:s,nodeLookup:n,dragging:!1});i?.(e.sourceEvent,s,t,r),a?.(e.sourceEvent,t,r),b||o?.(e.sourceEvent,r)}}}).filter(e=>{let t=e.target;return!e.button&&(!g||!mE(t,`.${g}`,v))&&(!_||mE(t,_,v))});f.call(T)}function _(){f?.on(`.drag`,null)}return{update:g,destroy:_}}function yE(e,t,n){let r=[],i={x:e.x-n,y:e.y-n,width:n*2,height:n*2};for(let e of t.values())nT(i,$w(e))>0&&r.push(e);return r}var bE=250;function xE(e,t,n,r){let i=[],a=1/0,o=yE(e,n,t+bE);for(let n of o){let o=[...n.internals.handleBounds?.source??[],...n.internals.handleBounds?.target??[]];for(let s of o){if(r.nodeId===s.nodeId&&r.type===s.type&&r.id===s.id)continue;let{x:o,y:c}=YT(n,s,s.position,!0),l=Math.sqrt((o-e.x)**2+(c-e.y)**2);l>t||(l1){let e=r.type===`source`?`target`:`source`;return i.find(t=>t.type===e)??i[0]}return i[0]}function SE(e,t,n,r,i,a=!1){let o=r.get(e);if(!o)return null;let s=i===`strict`?o.internals.handleBounds?.[t]:[...o.internals.handleBounds?.source??[],...o.internals.handleBounds?.target??[]],c=(n?s?.find(e=>e.id===n):s?.[0])??null;return c&&a?{...c,...YT(o,c,c.position,!0)}:c}function CE(e,t){return e||(t?.classList.contains(`target`)?`target`:t?.classList.contains(`source`)?`source`:null)}function wE(e,t){let n=null;return t?n=!0:e&&!t&&(n=!1),n}var TE=()=>!0;function EE(e,{connectionMode:t,connectionRadius:n,handleId:r,nodeId:i,edgeUpdaterType:a,isTarget:o,domNode:s,nodeLookup:c,lib:l,autoPanOnConnect:u,flowId:d,panBy:f,cancelConnection:p,onConnectStart:m,onConnect:h,onConnectEnd:g,isValidConnection:_=TE,onReconnectEnd:v,updateConnection:y,getTransform:b,getFromHandle:x,autoPanSpeed:S,dragThreshold:C=1,handleDomNode:w}){let T=CT(e.target),E=0,D,{x:O,y:k}=DT(e),A=CE(a,w),j=s?.getBoundingClientRect(),M=!1;if(!j||!A)return;let N=SE(i,A,r,c,t);if(!N)return;let P=DT(e,j),ee=!1,F=null,I=!1,L=null;function R(){if(!u||!j)return;let[e,t]=Yw(P,j,S);f({x:e,y:t}),E=requestAnimationFrame(R)}let z={...N,nodeId:i,type:A,position:N.position},B=c.get(i),V={inProgress:!0,isValid:null,from:YT(B,z,$.Left,!0),fromHandle:z,fromPosition:z.position,fromNode:B,to:P,toHandle:null,toPosition:jw[z.position],toNode:null};function H(){M=!0,y(V),m?.(e,{nodeId:i,handleId:r,handleType:A})}C===0&&H();function U(e){if(!M){let{x:t,y:n}=DT(e),r=t-O,i=n-k;if(!(r*r+i*i>C*C))return;H()}if(!x()||!z){te(e);return}let a=b();P=DT(e,j),D=xE(sT(P,a,!1,[1,1]),n,c,z),ee||=(R(),!0);let s=DE(e,{handle:D,connectionMode:t,fromNodeId:i,fromHandleId:r,fromType:o?`target`:`source`,isValidConnection:_,doc:T,lib:l,flowId:d,nodeLookup:c});L=s.handleDomNode,F=s.connection,I=wE(!!D,s.isValid);let u={...V,isValid:I,to:s.toHandle&&I?cT({x:s.toHandle.x,y:s.toHandle.y},a):P,toHandle:s.toHandle,toPosition:I&&s.toHandle?s.toHandle.position:jw[z.position],toNode:s.toHandle?c.get(s.toHandle.nodeId):null};I&&D&&V.toHandle&&u.toHandle&&V.toHandle.type===u.toHandle.type&&V.toHandle.nodeId===u.toHandle.nodeId&&V.toHandle.id===u.toHandle.id&&V.to.x===u.to.x&&V.to.y===u.to.y||(y(u),V=u)}function te(e){if(M){(D||L)&&F&&I&&h?.(F);let{inProgress:t,...n}=V,r={...n,toPosition:V.toHandle?V.toPosition:null};g?.(e,r),a&&v?.(e,r)}p(),cancelAnimationFrame(E),ee=!1,I=!1,F=null,L=null,T.removeEventListener(`mousemove`,U),T.removeEventListener(`mouseup`,te),T.removeEventListener(`touchmove`,U),T.removeEventListener(`touchend`,te)}T.addEventListener(`mousemove`,U),T.addEventListener(`mouseup`,te),T.addEventListener(`touchmove`,U),T.addEventListener(`touchend`,te)}function DE(e,{handle:t,connectionMode:n,fromNodeId:r,fromHandleId:i,fromType:a,doc:o,lib:s,flowId:c,isValidConnection:l=TE,nodeLookup:u}){let d=a===`target`,f=t?o.querySelector(`.${s}-flow__handle[data-id="${c}-${t?.nodeId}-${t?.id}-${t?.type}"]`):null,{x:p,y:m}=DT(e),h=o.elementFromPoint(p,m),g=h?.classList.contains(`${s}-flow__handle`)?h:f,_={handleDomNode:g,isValid:!1,connection:null,toHandle:null};if(g){let e=CE(void 0,g),t=g.getAttribute(`data-nodeid`),a=g.getAttribute(`data-handleid`),o=g.classList.contains(`connectable`),s=g.classList.contains(`connectableend`);if(!t||!e)return _;let c={source:d?t:r,sourceHandle:d?a:i,target:d?r:t,targetHandle:d?i:a};_.connection=c,_.isValid=o&&s&&(n===Tw.Strict?d&&e===`source`||!d&&e===`target`:t!==r||a!==i)&&l(c),_.toHandle=SE(t,e,a,u,n,!0)}return _}var OE={onPointerDown:EE,isValid:DE};function kE({domNode:e,panZoom:t,getTransform:n,getViewScale:r}){let i=Hb(e);function a({translateExtent:e,width:a,height:o,zoomStep:s=1,pannable:c=!0,zoomable:l=!0,inversePan:u=!1}){let d=e=>{if(e.sourceEvent.type!==`wheel`||!t)return;let r=n(),i=e.sourceEvent.ctrlKey&&pT()?10:1,a=-e.sourceEvent.deltaY*(e.sourceEvent.deltaMode===1?.05:e.sourceEvent.deltaMode?1:.002)*s,o=r[2]*2**(a*i);t.scaleTo(o)},f=[0,0],p=bw().on(`start`,e=>{(e.sourceEvent.type===`mousedown`||e.sourceEvent.type===`touchstart`)&&(f=[e.sourceEvent.clientX??e.sourceEvent.touches[0].clientX,e.sourceEvent.clientY??e.sourceEvent.touches[0].clientY])}).on(`zoom`,c?i=>{let s=n();if(i.sourceEvent.type!==`mousemove`&&i.sourceEvent.type!==`touchmove`||!t)return;let c=[i.sourceEvent.clientX??i.sourceEvent.touches[0].clientX,i.sourceEvent.clientY??i.sourceEvent.touches[0].clientY],l=[c[0]-f[0],c[1]-f[1]];f=c;let d=r()*Math.max(s[2],Math.log(s[2]))*(u?-1:1),p={x:s[0]-l[0]*d,y:s[1]-l[1]*d},m=[[0,0],[a,o]];t.setViewportConstrained({x:p.x,y:p.y,zoom:s[2]},m,e)}:null).on(`zoom.wheel`,l?d:null);i.call(p,{})}function o(){i.on(`zoom`,null)}return{update:a,destroy:o,pointer:Wb}}var AE=(e,t)=>e.x!==t.x||e.y!==t.y||e.zoom!==t.k,jE=e=>({x:e.x,y:e.y,zoom:e.k}),ME=({x:e,y:t,zoom:n})=>uw.translate(e,t).scale(n),NE=(e,t)=>e.target.closest(`.${t}`),PE=(e,t)=>t===2&&Array.isArray(e)&&e.includes(2),FE=e=>((e*=2)<=1?e*e*e:(e-=2)*e*e+2)/2,IE=(e,t=0,n=FE,r=()=>{})=>{let i=typeof t==`number`&&t>0;return i||r(),i?e.transition().duration(t).ease(n).on(`end`,r):e},LE=e=>{let t=e.ctrlKey&&pT()?10:1;return-e.deltaY*(e.deltaMode===1?.05:e.deltaMode?1:.002)*t};function RE({zoomPanValues:e,noWheelClassName:t,d3Selection:n,d3Zoom:r,panOnScrollMode:i,panOnScrollSpeed:a,zoomOnPinch:o,onPanZoomStart:s,onPanZoom:c,onPanZoomEnd:l}){return u=>{if(NE(u,t))return!1;u.preventDefault(),u.stopImmediatePropagation();let d=n.property(`__zoom`).k||1;if(u.ctrlKey&&o){let e=Wb(u),t=d*2**LE(u);r.scaleTo(n,t,e,u);return}let f=u.deltaMode===1?20:1,p=i===Ew.Vertical?0:u.deltaX*f,m=i===Ew.Horizontal?0:u.deltaY*f;!pT()&&u.shiftKey&&i!==Ew.Vertical&&(p=u.deltaY*f,m=0),r.translateBy(n,-(p/d)*a,-(m/d)*a,{internal:!0});let h=jE(n.property(`__zoom`));clearTimeout(e.panScrollTimeout),e.isPanScrolling||(e.isPanScrolling=!0,s?.(u,h)),e.isPanScrolling&&(c?.(u,h),e.panScrollTimeout=setTimeout(()=>{l?.(u,h),e.isPanScrolling=!1},150))}}function zE({noWheelClassName:e,preventScrolling:t,d3ZoomHandler:n}){return function(r,i){let a=r.type===`wheel`,o=!t&&a&&!r.ctrlKey,s=NE(r,e);if(r.ctrlKey&&a&&s&&r.preventDefault(),o||s)return null;r.preventDefault(),n.call(this,r,i)}}function BE({zoomPanValues:e,onDraggingChange:t,onPanZoomStart:n}){return r=>{if(r.sourceEvent?.internal)return;let i=jE(r.transform);e.mouseButton=r.sourceEvent?.button||0,e.isZoomingOrPanning=!0,e.prevViewport=i,r.sourceEvent?.type===`mousedown`&&t(!0),n&&n?.(r.sourceEvent,i)}}function VE({zoomPanValues:e,panOnDrag:t,onPaneContextMenu:n,onTransformChange:r,onPanZoom:i}){return a=>{e.usedRightMouseButton=!!(n&&PE(t,e.mouseButton??0)),a.sourceEvent?.sync||r([a.transform.x,a.transform.y,a.transform.k]),i&&!a.sourceEvent?.internal&&i?.(a.sourceEvent,jE(a.transform))}}function HE({zoomPanValues:e,panOnDrag:t,panOnScroll:n,onDraggingChange:r,onPanZoomEnd:i,onPaneContextMenu:a}){return o=>{if(!o.sourceEvent?.internal&&(e.isZoomingOrPanning=!1,a&&PE(t,e.mouseButton??0)&&!e.usedRightMouseButton&&o.sourceEvent&&a(o.sourceEvent),e.usedRightMouseButton=!1,r(!1),i&&AE(e.prevViewport,o.transform))){let t=jE(o.transform);e.prevViewport=t,clearTimeout(e.timerId),e.timerId=setTimeout(()=>{i?.(o.sourceEvent,t)},n?150:0)}}}function UE({zoomActivationKeyPressed:e,zoomOnScroll:t,zoomOnPinch:n,panOnDrag:r,panOnScroll:i,zoomOnDoubleClick:a,userSelectionActive:o,noWheelClassName:s,noPanClassName:c,lib:l}){return u=>{let d=e||t,f=n&&u.ctrlKey;if(u.button===1&&u.type===`mousedown`&&(NE(u,`${l}-flow__node`)||NE(u,`${l}-flow__edge`)))return!0;if(!r&&!d&&!i&&!a&&!n||o||NE(u,s)&&u.type===`wheel`||NE(u,c)&&(u.type!==`wheel`||i&&u.type===`wheel`&&!e)||!n&&u.ctrlKey&&u.type===`wheel`)return!1;if(!n&&u.type===`touchstart`&&u.touches?.length>1)return u.preventDefault(),!1;if(!d&&!i&&!f&&u.type===`wheel`||!r&&(u.type===`mousedown`||u.type===`touchstart`)||Array.isArray(r)&&!r.includes(u.button)&&u.type===`mousedown`)return!1;let p=Array.isArray(r)&&r.includes(u.button)||!u.button||u.button<=1;return(!u.ctrlKey||u.type===`wheel`)&&p}}function WE({domNode:e,minZoom:t,maxZoom:n,paneClickDistance:r,translateExtent:i,viewport:a,onPanZoom:o,onPanZoomStart:s,onPanZoomEnd:c,onDraggingChange:l}){let u={isZoomingOrPanning:!1,usedRightMouseButton:!1,prevViewport:{x:0,y:0,zoom:0},mouseButton:0,timerId:void 0,panScrollTimeout:void 0,isPanScrolling:!1},d=e.getBoundingClientRect(),f=bw().clickDistance(!iT(r)||r<0?0:r).scaleExtent([t,n]).translateExtent(i),p=Hb(e).call(f);y({x:a.x,y:a.y,zoom:Gw(a.zoom,t,n)},[[0,0],[d.width,d.height]],i);let m=p.on(`wheel.zoom`),h=p.on(`dblclick.zoom`);f.wheelDelta(LE);function g(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?oS:bS).transform(IE(p,t?.duration,t?.ease,()=>n(!0)),e)}):Promise.resolve(!1)}function _({noWheelClassName:e,noPanClassName:t,onPaneContextMenu:n,userSelectionActive:r,panOnScroll:i,panOnDrag:a,panOnScrollMode:d,panOnScrollSpeed:g,preventScrolling:_,zoomOnPinch:y,zoomOnScroll:b,zoomOnDoubleClick:x,zoomActivationKeyPressed:S,lib:C,onTransformChange:w}){r&&!u.isZoomingOrPanning&&v();let T=i&&!S&&!r?RE({zoomPanValues:u,noWheelClassName:e,d3Selection:p,d3Zoom:f,panOnScrollMode:d,panOnScrollSpeed:g,zoomOnPinch:y,onPanZoomStart:s,onPanZoom:o,onPanZoomEnd:c}):zE({noWheelClassName:e,preventScrolling:_,d3ZoomHandler:m});if(p.on(`wheel.zoom`,T,{passive:!1}),!r){let e=BE({zoomPanValues:u,onDraggingChange:l,onPanZoomStart:s});f.on(`start`,e);let t=VE({zoomPanValues:u,panOnDrag:a,onPaneContextMenu:!!n,onPanZoom:o,onTransformChange:w});f.on(`zoom`,t);let r=HE({zoomPanValues:u,panOnDrag:a,panOnScroll:i,onPaneContextMenu:n,onPanZoomEnd:c,onDraggingChange:l});f.on(`end`,r)}let E=UE({zoomActivationKeyPressed:S,panOnDrag:a,zoomOnScroll:b,panOnScroll:i,zoomOnDoubleClick:x,zoomOnPinch:y,userSelectionActive:r,noPanClassName:t,noWheelClassName:e,lib:C});f.filter(E),x?p.on(`dblclick.zoom`,h):p.on(`dblclick.zoom`,null)}function v(){f.on(`zoom`,null)}async function y(e,t,n){let r=ME(e),i=f?.constrain()(r,t,n);return i&&await g(i),new Promise(e=>e(i))}async function b(e,t){let n=ME(e);return await g(n,t),new Promise(e=>e(n))}function x(e){if(p){let t=ME(e),n=p.property(`__zoom`);(n.k!==e.zoom||n.x!==e.x||n.y!==e.y)&&f?.transform(p,t,null,{sync:!0})}}function S(){let e=p?dw(p.node()):{x:0,y:0,k:1};return{x:e.x,y:e.y,zoom:e.k}}function C(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?oS:bS).scaleTo(IE(p,t?.duration,t?.ease,()=>n(!0)),e)}):Promise.resolve(!1)}function w(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?oS:bS).scaleBy(IE(p,t?.duration,t?.ease,()=>n(!0)),e)}):Promise.resolve(!1)}function T(e){f?.scaleExtent(e)}function E(e){f?.translateExtent(e)}function D(e){let t=!iT(e)||e<0?0:e;f?.clickDistance(t)}return{update:_,destroy:v,setViewport:b,setViewportConstrained:y,getViewport:S,scaleTo:C,scaleBy:w,setScaleExtent:T,setTranslateExtent:E,syncViewport:x,setClickDistance:D}}var GE;(function(e){e.Line=`line`,e.Handle=`handle`})(GE||={});function KE({width:e,prevWidth:t,height:n,prevHeight:r,affectsX:i,affectsY:a}){let o=e-t,s=n-r,c=[o>0?1:o<0?-1:0,s>0?1:s<0?-1:0];return o&&i&&(c[0]*=-1),s&&a&&(c[1]*=-1),c}function qE(e){return{isHorizontal:e.includes(`right`)||e.includes(`left`),isVertical:e.includes(`bottom`)||e.includes(`top`),affectsX:e.includes(`left`),affectsY:e.includes(`top`)}}function JE(e,t){return Math.max(0,t-e)}function YE(e,t){return Math.max(0,e-t)}function XE(e,t,n){return Math.max(0,t-e,e-n)}function ZE(e,t){return e?!t:t}function QE(e,t,n,r,i,a,o,s){let{affectsX:c,affectsY:l}=t,{isHorizontal:u,isVertical:d}=t,f=u&&d,{xSnapped:p,ySnapped:m}=n,{minWidth:h,maxWidth:g,minHeight:_,maxHeight:v}=r,{x:y,y:b,width:x,height:S,aspectRatio:C}=e,w=Math.floor(u?p-e.pointerX:0),T=Math.floor(d?m-e.pointerY:0),E=x+(c?-w:w),D=S+(l?-T:T),O=-a[0]*x,k=-a[1]*S,A=XE(E,h,g),j=XE(D,_,v);if(o){let e=0,t=0;c&&w<0?e=JE(y+w+O,o[0][0]):!c&&w>0&&(e=YE(y+E+O,o[1][0])),l&&T<0?t=JE(b+T+k,o[0][1]):!l&&T>0&&(t=YE(b+D+k,o[1][1])),A=Math.max(A,e),j=Math.max(j,t)}if(s){let e=0,t=0;c&&w>0?e=YE(y+w,s[0][0]):!c&&w<0&&(e=JE(y+E,s[1][0])),l&&T>0?t=YE(b+T,s[0][1]):!l&&T<0&&(t=JE(b+D,s[1][1])),A=Math.max(A,e),j=Math.max(j,t)}if(i){if(u){let e=XE(E/C,_,v)*C;if(A=Math.max(A,e),o){let e=0;e=!c&&!l||c&&!l&&f?YE(b+k+E/C,o[1][1])*C:JE(b+k+(c?w:-w)/C,o[0][1])*C,A=Math.max(A,e)}if(s){let e=0;e=!c&&!l||c&&!l&&f?JE(b+E/C,s[1][1])*C:YE(b+(c?w:-w)/C,s[0][1])*C,A=Math.max(A,e)}}if(d){let e=XE(D*C,h,g)/C;if(j=Math.max(j,e),o){let e=0;e=!c&&!l||l&&!c&&f?YE(y+D*C+O,o[1][0])/C:JE(y+(l?T:-T)*C+O,o[0][0])/C,j=Math.max(j,e)}if(s){let e=0;e=!c&&!l||l&&!c&&f?JE(y+D*C,s[1][0])/C:YE(y+(l?T:-T)*C,s[0][0])/C,j=Math.max(j,e)}}}T+=T<0?j:-j,w+=w<0?A:-A,i&&(f?E>D*C?T=(ZE(c,l)?-w:w)/C:w=(ZE(c,l)?-T:T)*C:u?(T=w/C,l=c):(w=T*C,c=l));let M=c?y+w:y,N=l?b+T:b;return{width:x+(c?-w:w),height:S+(l?-T:T),x:a[0]*w*(c?-1:1)+M,y:a[1]*T*(l?-1:1)+N}}var $E={width:0,height:0,x:0,y:0},eD={...$E,pointerX:0,pointerY:0,aspectRatio:1};function tD(e){return[[0,0],[e.measured.width,e.measured.height]]}function nD(e,t,n){let r=t.position.x+e.position.x,i=t.position.y+e.position.y,a=e.measured.width??0,o=e.measured.height??0,s=n[0]*a,c=n[1]*o;return[[r-s,i-c],[r+a-s,i+o-c]]}function rD({domNode:e,nodeId:t,getStoreItems:n,onChange:r,onEnd:i}){let a=Hb(e);function o({controlPosition:e,boundaries:o,keepAspectRatio:s,resizeDirection:c,onResizeStart:l,onResize:u,onResizeEnd:d,shouldResize:f}){let p={...$E},m={...eD},h=qE(e),g,_=null,v=[],y,b,x,S=rx().on(`start`,e=>{let{nodeLookup:r,transform:i,snapGrid:a,snapToGrid:o,nodeOrigin:s,paneDomNode:c}=n();if(g=r.get(t),!g)return;_=c?.getBoundingClientRect()??null;let{xSnapped:u,ySnapped:d}=xT(e.sourceEvent,{transform:i,snapGrid:a,snapToGrid:o,containerBounds:_});p={width:g.measured.width??0,height:g.measured.height??0,x:g.position.x??0,y:g.position.y??0},m={...p,pointerX:u,pointerY:d,aspectRatio:p.width/p.height},y=void 0,g.parentId&&(g.extent===`parent`||g.expandParent)&&(y=r.get(g.parentId),b=y&&g.extent===`parent`?tD(y):void 0),v=[],x=void 0;for(let[e,n]of r)if(n.parentId===t&&(v.push({id:e,position:{...n.position},extent:n.extent}),n.extent===`parent`||n.expandParent)){let e=nD(n,g,n.origin??s);x=x?[[Math.min(e[0][0],x[0][0]),Math.min(e[0][1],x[0][1])],[Math.max(e[1][0],x[1][0]),Math.max(e[1][1],x[1][1])]]:e}l?.(e,{...p})}).on(`drag`,e=>{let{transform:t,snapGrid:i,snapToGrid:a,nodeOrigin:l}=n(),d=xT(e.sourceEvent,{transform:t,snapGrid:i,snapToGrid:a,containerBounds:_}),S=[];if(!g)return;let{x:C,y:w,width:T,height:E}=p,D={},O=g.origin??l,{width:k,height:A,x:j,y:M}=QE(m,h,d,o,s,O,b,x),N=k!==T,P=A!==E,ee=j!==C&&N,F=M!==w&&P;if(!ee&&!F&&!N&&!P)return;if((ee||F||O[0]===1||O[1]===1)&&(D.x=ee?j:p.x,D.y=F?M:p.y,p.x=D.x,p.y=D.y,v.length>0)){let e=j-C,t=M-w;for(let n of v)n.position={x:n.position.x-e+O[0]*(k-T),y:n.position.y-t+O[1]*(A-E)},S.push(n)}if((N||P)&&(D.width=N&&(!c||c===`horizontal`)?k:p.width,D.height=P&&(!c||c===`vertical`)?A:p.height,p.width=D.width,p.height=D.height),y&&g.expandParent){let e=O[0]*(D.width??0);D.x&&D.x{d?.(e,{...p}),i?.({...p})});a.call(S)}function s(){a.on(`.drag`,null)}return{update:o,destroy:s}}var iD=s((e=>{var t=f();function n(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var r=typeof Object.is==`function`?Object.is:n,i=t.useState,a=t.useEffect,o=t.useLayoutEffect,s=t.useDebugValue;function c(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),c=r[0].inst,u=r[1];return o(function(){c.value=n,c.getSnapshot=t,l(c)&&u({inst:c})},[e,n,t]),a(function(){return l(c)&&u({inst:c}),e(function(){l(c)&&u({inst:c})})},[e]),s(n),n}function l(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!r(e,n)}catch{return!0}}function u(e,t){return t()}var d=typeof window>`u`||window.document===void 0||window.document.createElement===void 0?u:c;e.useSyncExternalStore=t.useSyncExternalStore===void 0?d:t.useSyncExternalStore})),aD=s(((e,t)=>{t.exports=iD()})),oD=s((e=>{var t=f(),n=aD();function r(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var i=typeof Object.is==`function`?Object.is:r,a=n.useSyncExternalStore,o=t.useRef,s=t.useEffect,c=t.useMemo,l=t.useDebugValue;e.useSyncExternalStoreWithSelector=function(e,t,n,r,u){var d=o(null);if(d.current===null){var f={hasValue:!1,value:null};d.current=f}else f=d.current;d=c(function(){function e(e){if(!a){if(a=!0,o=e,e=r(e),u!==void 0&&f.hasValue){var t=f.value;if(u(t,e))return s=t}return s=e}if(t=s,i(o,e))return t;var n=r(e);return u!==void 0&&u(t,n)?(o=e,t):(o=e,s=n)}var a=!1,o,s,c=n===void 0?null:n;return[function(){return e(t())},c===null?void 0:function(){return e(c())}]},[t,n,r,u]);var p=a(e,d[0],d[1]);return s(function(){f.hasValue=!0,f.value=p},[p]),l(p),p}})),sD=u(s(((e,t)=>{t.exports=oD()}))(),1),cD=e=>{let t,n=new Set,r=(e,r)=>{let i=typeof e==`function`?e(t):e;if(!Object.is(i,t)){let e=t;t=r??(typeof i!=`object`||!i)?i:Object.assign({},t,i),n.forEach(n=>n(t,e))}},i=()=>t,a={setState:r,getState:i,getInitialState:()=>o,subscribe:e=>(n.add(e),()=>n.delete(e)),destroy:()=>{n.clear()}},o=t=e(r,i,a);return a},lD=e=>e?cD(e):cD,{useDebugValue:uD}=y.default,{useSyncExternalStoreWithSelector:dD}=sD.default,fD=e=>e;function pD(e,t=fD,n){let r=dD(e.subscribe,e.getState,e.getServerState||e.getInitialState,t,n);return uD(r),r}var mD=(e,t)=>{let n=lD(e),r=(e,r=t)=>pD(n,e,r);return Object.assign(r,n),r},hD=(e,t)=>e?mD(e,t):mD;function gD(e,t){if(Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map){if(e.size!==t.size)return!1;for(let[n,r]of e)if(!Object.is(r,t.get(n)))return!1;return!0}if(e instanceof Set&&t instanceof Set){if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0}let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n)if(!Object.prototype.hasOwnProperty.call(t,r)||!Object.is(e[r],t[r]))return!1;return!0}var _D=(0,y.createContext)(null),vD=_D.Provider,yD=xw.error001();function bD(e,t){let n=(0,y.useContext)(_D);if(n===null)throw Error(yD);return pD(n,e,t)}function xD(){let e=(0,y.useContext)(_D);if(e===null)throw Error(yD);return(0,y.useMemo)(()=>({getState:e.getState,setState:e.setState,subscribe:e.subscribe}),[e])}var SD={display:`none`},CD={position:`absolute`,width:1,height:1,margin:-1,border:0,padding:0,overflow:`hidden`,clip:`rect(0px, 0px, 0px, 0px)`,clipPath:`inset(100%)`},wD=`react-flow__node-desc`,TD=`react-flow__edge-desc`,ED=`react-flow__aria-live`,DD=e=>e.ariaLiveMessage,OD=e=>e.ariaLabelConfig;function kD({rfId:e}){let t=bD(DD);return(0,T.jsx)(`div`,{id:`${ED}-${e}`,"aria-live":`assertive`,"aria-atomic":`true`,style:CD,children:t})}function AD({rfId:e,disableKeyboardA11y:t}){let n=bD(OD);return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`div`,{id:`${wD}-${e}`,style:SD,children:t?n[`node.a11yDescription.default`]:n[`node.a11yDescription.keyboardDisabled`]}),(0,T.jsx)(`div`,{id:`${TD}-${e}`,style:SD,children:n[`edge.a11yDescription.default`]}),!t&&(0,T.jsx)(kD,{rfId:e})]})}var jD=(0,y.forwardRef)(({position:e=`top-left`,children:t,className:n,style:r,...i},a)=>(0,T.jsx)(`div`,{className:Lv([`react-flow__panel`,n,...`${e}`.split(`-`)]),style:r,ref:a,...i,children:t}));jD.displayName=`Panel`;function MD({proOptions:e,position:t=`bottom-right`}){return e?.hideAttribution?null:(0,T.jsx)(jD,{position:t,className:`react-flow__attribution`,"data-message":`Please only hide this attribution when you are subscribed to React Flow Pro: https://pro.reactflow.dev`,children:(0,T.jsx)(`a`,{href:`https://reactflow.dev`,target:`_blank`,rel:`noopener noreferrer`,"aria-label":`React Flow attribution`,children:`React Flow`})})}var ND=e=>{let t=[],n=[];for(let[,n]of e.nodeLookup)n.selected&&t.push(n.internals.userNode);for(let[,t]of e.edgeLookup)t.selected&&n.push(t);return{selectedNodes:t,selectedEdges:n}},PD=e=>e.id;function FD(e,t){return gD(e.selectedNodes.map(PD),t.selectedNodes.map(PD))&&gD(e.selectedEdges.map(PD),t.selectedEdges.map(PD))}function ID({onSelectionChange:e}){let t=xD(),{selectedNodes:n,selectedEdges:r}=bD(ND,FD);return(0,y.useEffect)(()=>{let i={nodes:n,edges:r};e?.(i),t.getState().onSelectionChangeHandlers.forEach(e=>e(i))},[n,r,e]),null}var LD=e=>!!e.onSelectionChangeHandlers;function RD({onSelectionChange:e}){let t=bD(LD);return e||t?(0,T.jsx)(ID,{onSelectionChange:e}):null}var zD=[0,0],BD={x:0,y:0,zoom:1},VD=[...`nodes.edges.defaultNodes.defaultEdges.onConnect.onConnectStart.onConnectEnd.onClickConnectStart.onClickConnectEnd.nodesDraggable.autoPanOnNodeFocus.nodesConnectable.nodesFocusable.edgesFocusable.edgesReconnectable.elevateNodesOnSelect.elevateEdgesOnSelect.minZoom.maxZoom.nodeExtent.onNodesChange.onEdgesChange.elementsSelectable.connectionMode.snapGrid.snapToGrid.translateExtent.connectOnClick.defaultEdgeOptions.fitView.fitViewOptions.onNodesDelete.onEdgesDelete.onDelete.onNodeDrag.onNodeDragStart.onNodeDragStop.onSelectionDrag.onSelectionDragStart.onSelectionDragStop.onMoveStart.onMove.onMoveEnd.noPanClassName.nodeOrigin.autoPanOnConnect.autoPanOnNodeDrag.onError.connectionRadius.isValidConnection.selectNodesOnDrag.nodeDragThreshold.connectionDragThreshold.onBeforeDelete.debug.autoPanSpeed.paneClickDistance.ariaLabelConfig`.split(`.`),`rfId`],HD=e=>({setNodes:e.setNodes,setEdges:e.setEdges,setMinZoom:e.setMinZoom,setMaxZoom:e.setMaxZoom,setTranslateExtent:e.setTranslateExtent,setNodeExtent:e.setNodeExtent,reset:e.reset,setDefaultNodesAndEdges:e.setDefaultNodesAndEdges,setPaneClickDistance:e.setPaneClickDistance}),UD={translateExtent:Sw,nodeOrigin:zD,minZoom:.5,maxZoom:2,elementsSelectable:!0,noPanClassName:`nopan`,rfId:`1`,paneClickDistance:0};function WD(e){let{setNodes:t,setEdges:n,setMinZoom:r,setMaxZoom:i,setTranslateExtent:a,setNodeExtent:o,reset:s,setDefaultNodesAndEdges:c,setPaneClickDistance:l}=bD(HD,gD),u=xD();(0,y.useEffect)(()=>(c(e.defaultNodes,e.defaultEdges),()=>{d.current=UD,s()}),[]);let d=(0,y.useRef)(UD);return(0,y.useEffect)(()=>{for(let s of VD){let c=e[s];c!==d.current[s]&&e[s]!==void 0&&(s===`nodes`?t(c):s===`edges`?n(c):s===`minZoom`?r(c):s===`maxZoom`?i(c):s===`translateExtent`?a(c):s===`nodeExtent`?o(c):s===`paneClickDistance`?l(c):s===`ariaLabelConfig`?u.setState({ariaLabelConfig:bT(c)}):s===`fitView`?u.setState({fitViewQueued:c}):s===`fitViewOptions`?u.setState({fitViewOptions:c}):u.setState({[s]:c}))}d.current=e},VD.map(t=>e[t])),null}function GD(){return typeof window>`u`||!window.matchMedia?null:window.matchMedia(`(prefers-color-scheme: dark)`)}function KD(e){let[t,n]=(0,y.useState)(e===`system`?null:e);return(0,y.useEffect)(()=>{if(e!==`system`){n(e);return}let t=GD(),r=()=>n(t?.matches?`dark`:`light`);return r(),t?.addEventListener(`change`,r),()=>{t?.removeEventListener(`change`,r)}},[e]),t===null?GD()?.matches?`dark`:`light`:t}var qD=typeof document<`u`?document:null;function JD(e=null,t={target:qD,actInsideInputWithModifier:!0}){let[n,r]=(0,y.useState)(!1),i=(0,y.useRef)(!1),a=(0,y.useRef)(new Set([])),[o,s]=(0,y.useMemo)(()=>{if(e!==null){let t=(Array.isArray(e)?e:[e]).filter(e=>typeof e==`string`).map(e=>e.replace(`+`,` + opacity-0 group-hover:opacity-100`,title:a?`Copied!`:`Copy message`,children:a?(0,T.jsx)(wl,{className:`h-3.5 w-3.5 text-green-600 dark:text-green-400`}):(0,T.jsx)(Ll,{className:`h-3.5 w-3.5`})})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground font-mono`,children:[(0,T.jsx)(`span`,{children:e.created_at?new Date(e.created_at*1e3).toLocaleTimeString():new Date().toLocaleTimeString()}),!o&&e.usage&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{children:`•`}),(0,T.jsxs)(`span`,{className:`flex items-center gap-1`,children:[(0,T.jsxs)(`span`,{className:`text-blue-600 dark:text-blue-400`,children:[`↑`,e.usage.input_tokens]}),(0,T.jsxs)(`span`,{className:`text-green-600 dark:text-green-400`,children:[`↓`,e.usage.output_tokens]}),(0,T.jsxs)(`span`,{children:[`(`,e.usage.total_tokens,` tokens)`]})]})]}),!o&&l&&t.length>0&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{children:`•`}),(0,T.jsxs)(`button`,{onClick:()=>c(!s),className:`flex items-center gap-1 hover:text-foreground transition-colors`,title:`${t.length} tool call${t.length>1?`s`:``} - click to ${s?`hide`:`show`} details`,children:[(0,T.jsx)(Eu,{className:`h-3 w-3`}),(0,T.jsx)(`span`,{children:t.length})]})]})]}),!o&&s&&t.length>0&&(0,T.jsx)(`div`,{className:`mt-2 ml-0 p-3 bg-muted/30 rounded-md border border-muted`,children:(0,T.jsx)(`div`,{className:`space-y-2`,children:t.map(e=>{let t=n.find(t=>t.call_id===e.call_id);return(0,T.jsx)(`div`,{className:`text-xs`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-2`,children:[(0,T.jsx)(Eu,{className:`h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0`}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`font-mono text-muted-foreground`,children:[(0,T.jsx)(`span`,{className:`text-blue-600 dark:text-blue-400`,children:e.name}),(0,T.jsx)(`span`,{className:`text-muted-foreground/60 ml-1`,children:e.arguments&&(0,T.jsxs)(`span`,{className:`break-all`,children:[`(`,e.arguments,`)`]})})]}),t&&t.output&&(0,T.jsx)(`div`,{className:`mt-1 pl-5 border-l-2 border-green-600/20`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-1`,children:[(0,T.jsx)(Tl,{className:`h-3 w-3 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0`}),(0,T.jsx)(`pre`,{className:`font-mono text-muted-foreground whitespace-pre-wrap break-all`,children:t.output.substring(0,200)+(t.output.length>200?`...`:``)})]})}),e.status===`incomplete`&&(0,T.jsx)(`div`,{className:`mt-1 pl-5 border-l-2 border-orange-600/20`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-1`,children:[(0,T.jsx)(Du,{className:`h-3 w-3 text-orange-600 dark:text-orange-400 mt-0.5 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`font-mono text-orange-600 dark:text-orange-400`,children:`Failed`})]})})]})]})},e.id)})})})]})]})}return e.type===`function_call`||e.type,null}function pv({selectedAgent:e,onDebugEvent:t}){let n=Q(e=>e.currentConversation),r=Q(e=>e.availableConversations),i=Q(e=>e.chatItems),a=Q(e=>e.isStreaming),o=Q(e=>e.isSubmitting),s=Q(e=>e.loadingConversations),c=Q(e=>e.uiMode),l=Q(e=>e.conversationUsage),u=Q(e=>e.pendingApprovals),d=Q(e=>e.oaiMode),f=Q(e=>e.streamingEnabled),p=Q(e=>e.setCurrentConversation),m=Q(e=>e.setAvailableConversations),h=Q(e=>e.setChatItems),g=Q(e=>e.setIsStreaming),_=Q(e=>e.setIsSubmitting),v=Q(e=>e.setLoadingConversations),b=Q(e=>e.updateConversationUsage),x=Q(e=>e.setPendingApprovals),[S,C]=(0,y.useState)(!1),[w,E]=(0,y.useState)(null),[D,O]=(0,y.useState)(!1),[k,A]=(0,y.useState)(!1),{isCancelling:j,createAbortSignal:M,handleCancel:N,resetCancelling:P}=wh(),{isDragOver:ee,droppedFiles:F,clearDroppedFiles:I,dragHandlers:L}=Eh({disabled:o||a}),R=(0,y.useRef)(null),z=(0,y.useRef)(null),B=(0,y.useRef)(null),V=(0,y.useRef)(!1),H=(0,y.useRef)(``),U=(0,y.useRef)([]),te=(0,y.useRef)(0),ne=(0,y.useRef)(new Set),re=(0,y.useCallback)(()=>{for(let e of ne.current)e.abort();ne.current.clear()},[]),ie=(0,y.useCallback)(e=>{let n=new AbortController;ne.current.add(n),Vh.getTraceEvents(e,Q.getState().serverCapabilities.trace_retrieval,n.signal).then(e=>{if(!n.signal.aborted)for(let n of e)t(n)}).catch(e=>{Th(e)||console.debug(`Failed to retrieve response traces`,e)}).finally(()=>{ne.current.delete(n)})},[t]),ae=(0,y.useCallback)(()=>{re(),t(`clear`)},[re,t]);(0,y.useEffect)(()=>re,[e?.id,re]);let oe=(0,y.useCallback)((e,t=`in_progress`,n=!1)=>{let r=performance.now();if(!n&&r-te.current{if(n.id!==e||n.type!==`message`)return n;let r=n.content.filter(e=>e.type!==`text`&&e.type!==`output_text`&&e.type!==`refusal`),i=dv(U.current),o=n.content.filter(e=>e.type===`text`||e.type===`output_text`||e.type===`refusal`);return JSON.stringify(o)===JSON.stringify(i)&&n.status===t?n:(a=!0,{...n,content:[...r,...i],status:t})});a?(te.current=r,h(o)):n&&(te.current=r)},[h]);(0,y.useEffect)(()=>{if(!z.current)return;let e=R.current?.querySelector(`[data-radix-scroll-area-viewport]`),t=!1;if(e){let{scrollTop:n,scrollHeight:r,clientHeight:i}=e,a=r-n-i<100;t=V.current||a}else t=!0;t&&z.current.scrollIntoView({behavior:a?`instant`:`smooth`}),V.current&&!a&&(V.current=!1)},[i,a]),(0,y.useEffect)(()=>{},[a,o]),(0,y.useEffect)(()=>{let n=async(e,n,r)=>{let i=bh(n.id);if(!i||!i.responseId){g(!1);return}try{let a={model:r.id,input:[],stream:!0,conversation:n.id},o=Vh.streamAgentExecutionOpenAIDirect(r.id,a,n.id,void 0,i.responseId);te.current=0;for await(let n of o){if(t(n),n.type===`response.completed`){let e=n.response?.usage;e&&(B.current={input_tokens:e.input_tokens,output_tokens:e.output_tokens,total_tokens:e.total_tokens});continue}if(n.type===`response.failed`){let t=n.response?.error,r=t?typeof t==`object`&&`message`in t?t.message:JSON.stringify(t):`Request failed`;if(H.current){oe(e.id,`incomplete`,!0),g(!1);return}let i=Q.getState().chatItems;h(i.map(t=>t.id===e.id&&t.type===`message`?{...t,content:[{type:`text`,text:H.current||r}],status:`incomplete`}:t)),g(!1);return}if(n.type===`response.function_approval.requested`){let t=n;oe(e.id,`in_progress`,!0),x([...Q.getState().pendingApprovals,{request_id:t.request_id,function_call:t.function_call}]);continue}if(n.type===`response.function_approval.responded`){let e=n;x(Q.getState().pendingApprovals.filter(t=>t.request_id!==e.request_id));continue}if(n.type===`error`){let t=n.message||`An error occurred`;if(H.current){oe(e.id,`incomplete`,!0),g(!1);return}let r=Q.getState().chatItems;h(r.map(n=>n.id===e.id&&n.type===`message`?{...n,content:[{type:`text`,text:H.current||t}],status:`incomplete`}:n)),g(!1);return}(n.type===`response.output_text.delta`||n.type===`response.refusal.delta`)&&`delta`in n&&n.delta&&(U.current=uh(U.current,n),H.current=U.current.map(e=>e.text).join(``),oe(e.id))}let s=B.current;oe(e.id,`in_progress`,!0);let c=Q.getState().chatItems;h(c.map(t=>t.id===e.id&&t.type===`message`?{...t,status:`completed`,usage:s||void 0}:t)),g(!1),s&&b(s.total_tokens),B.current=null}catch(t){let n=Q.getState().chatItems;h(n.map(n=>n.id===e.id&&n.type===`message`?{...n,content:[{type:`text`,text:`Error resuming stream: ${t instanceof Error?t.message:`Unknown error`}`}],status:`incomplete`}:n)),g(!1)}};h([]),g(!1),p(void 0),H.current=``,U.current=[],te.current=0,(async()=>{if(e){v(!0);try{try{let{data:r}=await Vh.listConversations(e.id);if(m(r),r.length>0){let i=r[0];p(i);try{let r=[],a=!0,o,s=[];for(;a;){let e=await Vh.listConversationItems(i.id,{order:`asc`,after:o});r=r.concat(e.data),a=e.has_more,e.metadata?.traces&&e.metadata.traces.length>0&&(s=e.metadata.traces),a&&e.data.length>0&&(o=e.data[e.data.length-1].id)}if(h(r),g(!1),s.length>0){ae();for(let e of s)t({type:`response.trace.completed`,data:e,sequence_number:0})}let c=bh(i.id);if(c&&!c.completed){let t=lv(c);U.current=t,H.current=t.map(e=>e.text).join(``);let a={id:c.lastMessageId||`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:dv(t),status:`in_progress`};h([...r,a]),g(!0),setTimeout(()=>{n(a,i,e)},100)}setTimeout(()=>{z.current?.scrollIntoView({behavior:`smooth`})},100)}catch{console.debug(`No items found for conversation ${i.id}, starting fresh`),h([]),g(!1)}return}}catch{}let r=`devui_convs_${e.id}`,i=localStorage.getItem(r);if(i)try{let e=JSON.parse(i);if(e.length>0)try{await Vh.listConversationItems(e[0].id),m(e),p(e[0]),h([]),g(!1);return}catch{console.debug(`Cached conversation ${e[0].id} no longer exists, clearing cache`),localStorage.removeItem(r)}}catch{localStorage.removeItem(r)}let a=await Vh.createConversation({agent_id:e.id});p(a),m([a]),h([]),g(!1),E(null),localStorage.setItem(r,JSON.stringify([a]))}catch(e){m([]),h([]),g(!1),E({message:e instanceof Error?e.message:`Failed to create conversation`,type:`conversation_creation_error`})}finally{v(!1)}}})()},[e,t,ae,oe,h,g,v,m,p,x,b]);let se=(0,y.useCallback)(async()=>{if(e)try{let t=await Vh.createConversation({agent_id:e.id});p(t),m([t,...Q.getState().availableConversations]),h([]),g(!1),E(null),Q.setState({conversationUsage:{total_tokens:0,message_count:0}}),H.current=``,U.current=[],ae();let n=`devui_convs_${e.id}`,i=[t,...r];localStorage.setItem(n,JSON.stringify(i))}catch(e){E({message:e instanceof Error?e.message:`Failed to create conversation`,type:`conversation_creation_error`})}},[e,r,ae,p,m,h,g]),ce=(0,y.useCallback)(async(e,t)=>{if(t&&(t.preventDefault(),t.stopPropagation()),confirm(`Delete this conversation? This cannot be undone.`))try{if(await Vh.deleteConversation(e)){let t=r.filter(t=>t.id!==e);if(m(t),n?.id===e)if(t.length>0){let e=t[0];p(e),h([]),g(!1)}else p(void 0),h([]),g(!1),Q.setState({conversationUsage:{total_tokens:0,message_count:0}}),H.current=``,U.current=[];ae()}}catch{alert(`Failed to delete conversation. Please try again.`)}},[r,n,ae,m,p,h,g]),le=(0,y.useCallback)(async()=>{if(D||!e)return;O(!0);let t=Q.getState().addToast,n=Q.getState().updateAgent;try{await Vh.reloadEntity(e.id),n(await Vh.getAgentInfo(e.id)),t({message:`${e.name} has been reloaded successfully`,type:`success`})}catch(e){t({message:`Failed to reload: ${e instanceof Error?e.message:`Failed to reload entity`}`,type:`error`,duration:6e3})}finally{O(!1)}},[D,e]),ue=(0,y.useCallback)(async e=>{let n=r.find(t=>t.id===e);if(n){p(n),ae();try{let n=[],r=!0,i,a=[];for(;r;){let t=await Vh.listConversationItems(e,{order:`asc`,after:i});n=n.concat(t.data),r=t.has_more,t.metadata?.traces&&t.metadata.traces.length>0&&(a=t.metadata.traces),r&&t.data.length>0&&(i=t.data[t.data.length-1].id)}let o=n;if(h(o),g(!1),a.length>0)for(let e of a)t({type:`response.trace.completed`,data:e,sequence_number:0});Q.setState({conversationUsage:{total_tokens:0,message_count:o.length}});let s=bh(e);if(s?.accumulatedText){let e=lv(s);U.current=e,H.current=e.map(e=>e.text).join(``);let t={id:`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:dv(e),status:`in_progress`};h([...o,t]),g(!0)}setTimeout(()=>{z.current?.scrollIntoView({behavior:`smooth`})},100)}catch{console.debug(`No items found for conversation ${e}, starting with empty chat`),h([]),g(!1),Q.setState({conversationUsage:{total_tokens:0,message_count:0}})}H.current=``,U.current=[]}},[r,t,ae,p,h,g]),de=async(e,t)=>{let r=u.find(t=>t.request_id===e);if(!r)return;let i=Math.floor(Date.now()/1e3),a={id:`user-approval-${Date.now()}`,type:`message`,role:`user`,content:[{type:`function_approval_request`,request_id:e,status:t?`approved`:`rejected`,function_call:r.function_call}],status:`completed`,created_at:i},o=Q.getState().chatItems;h([...o,a]);let s={input:[{type:`message`,role:`user`,content:[{type:`function_approval_response`,request_id:e,approved:t,function_call:r.function_call}]}],conversation_id:n?.id};return x(Q.getState().pendingApprovals.filter(t=>t.request_id!==e)),s},fe=(0,y.useCallback)(async r=>{if(!e)return;let i=r.input.some(e=>e.type===`message`&&Array.isArray(e.content)&&e.content.some(e=>e.type===`function_approval_response`)),a=[];for(let e of r.input)if(e.type===`message`&&Array.isArray(e.content)){for(let t of e.content)if(t.type===`input_text`)a.push({type:`text`,text:t.text});else if(t.type===`input_image`)a.push({type:`input_image`,image_url:t.image_url||``,detail:`auto`});else if(t.type===`input_file`){let e=t;a.push({type:`input_file`,file_data:e.file_data,filename:e.filename})}}let o=Math.floor(Date.now()/1e3);if(!i&&a.length>0){let e={id:`user-${Date.now()}`,type:`message`,role:`user`,content:a,status:`completed`,created_at:o};h([...Q.getState().chatItems,e])}g(!0);let s={id:`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:[],status:`in_progress`,created_at:o};h([...Q.getState().chatItems,s]);let c,l;try{let i=n;if(!i)try{i=await Vh.createConversation({agent_id:e.id}),p(i),m([i,...Q.getState().availableConversations]),E(null)}catch(e){E({message:e instanceof Error?e.message:`Failed to create conversation`,type:`conversation_creation_error`}),_(!1),g(!1);return}i?.id&&Vh.clearStreamingState(i.id);let a={input:r.input,conversation_id:i?.id};H.current=``,U.current=[],te.current=0,l=M();let o=Vh.streamAgentExecutionOpenAI(e.id,a,l);for await(let e of o){if(t(e),`response`in e&&e.response&&typeof e.response==`object`&&`id`in e.response&&typeof e.response.id==`string`&&(c=e.response.id),e.type===`response.completed`){let t=e.response?.usage;t&&(B.current={input_tokens:t.input_tokens,output_tokens:t.output_tokens,total_tokens:t.total_tokens});continue}if(e.type===`response.failed`){let t=e.response?.error,n=`Request failed`;if(t&&(typeof t==`object`&&`message`in t?(n=t.message,`code`in t&&t.code&&(n+=` (Code: ${t.code})`)):typeof t==`string`&&(n=t)),H.current){oe(s.id,`incomplete`,!0),g(!1);return}let r=Q.getState().chatItems;h(r.map(e=>e.id===s.id&&e.type===`message`?{...e,content:[{type:`text`,text:H.current||n}],status:`incomplete`}:e)),g(!1);return}if(e.type===`response.function_approval.requested`){let t=e;oe(s.id,`in_progress`,!0),x([...Q.getState().pendingApprovals,{request_id:t.request_id,function_call:t.function_call}]);let n=Q.getState().chatItems;h(n.map(e=>e.id===s.id&&e.type===`message`?{...e,content:[...e.content,{type:`function_approval_request`,request_id:t.request_id,status:`pending`,function_call:t.function_call}],status:`in_progress`}:e));continue}if(e.type===`response.function_call_arguments.delta`){let t=e,n=Q.getState().chatItems;h(n.map(e=>e.type===`function_call`&&e.call_id===t.item_id?{...e,arguments:(e.arguments||``)+(t.delta||``)}:e));continue}if(e.type===`response.function_result.complete`){let t=e,n={id:`result-${Date.now()}`,type:`function_call_output`,call_id:t.call_id,output:t.output,status:t.status===`completed`?`completed`:`incomplete`,created_at:Math.floor(Date.now()/1e3)},r=Q.getState().chatItems;h([...r,n]);continue}if(e.type===`error`){let t=e.message||`An error occurred`;if(H.current){oe(s.id,`incomplete`,!0),g(!1);return}let n=Q.getState().chatItems;h(n.map(e=>e.id===s.id&&e.type===`message`?{...e,content:[{type:`text`,text:t}],status:`incomplete`}:e)),g(!1);return}if(e.type===`response.output_item.added`){let t=e.item;if(oe(s.id,`in_progress`,!0),t.type===`function_call`){let e=t,n={id:e.id||`call-${Date.now()}`,type:`function_call`,name:e.name,arguments:e.arguments||``,call_id:e.call_id,status:e.status||`in_progress`,created_at:Math.floor(Date.now()/1e3)},r=Q.getState().chatItems;h([...r,n]);continue}let n=Q.getState().chatItems;h(n.map(e=>{if(e.id===s.id&&e.type===`message`){let n=e.content,r=null;if(t.type===`output_image`?r={type:`output_image`,image_url:t.image_url,alt_text:t.alt_text,mime_type:t.mime_type}:t.type===`output_file`?r={type:`output_file`,filename:t.filename,file_url:t.file_url,file_data:t.file_data,mime_type:t.mime_type}:t.type===`output_data`&&(r={type:`output_data`,data:t.data,mime_type:t.mime_type,description:t.description}),r)return{...e,content:[...n,r],status:`in_progress`}}return e}));continue}(e.type===`response.output_text.delta`||e.type===`response.refusal.delta`)&&`delta`in e&&e.delta&&(U.current=uh(U.current,e),H.current=U.current.map(e=>e.text).join(``),oe(s.id))}let u=B.current;oe(s.id,`in_progress`,!0);let d=Q.getState().chatItems;h(d.map(e=>e.id===s.id&&e.type===`message`?{...e,status:`completed`,usage:u||void 0}:e)),g(!1),u&&b(u.total_tokens),B.current=null}catch(e){if(Th(e))A(!0),oe(s.id,H.current?`completed`:`incomplete`,!0);else if(H.current)oe(s.id,`incomplete`,!0);else{let t=Q.getState().chatItems;h(t.map(t=>t.id===s.id&&t.type===`message`?{...t,content:[{type:`text`,text:`Error: ${e instanceof Error?e.message:`Failed to get response`}`}],status:`incomplete`}:t))}g(!1),P()}finally{c&&!l?.aborted&&ie(c)}},[e,n,t,oe,h,g,_,p,m,x,b,M,P,ie]),pe=(0,y.useCallback)(async r=>{if(!e)return;let i=r.input.some(e=>e.type===`message`&&Array.isArray(e.content)&&e.content.some(e=>e.type===`function_approval_response`)),a=[];for(let e of r.input)if(e.type===`message`&&Array.isArray(e.content)){for(let t of e.content)if(t.type===`input_text`)a.push({type:`text`,text:t.text});else if(t.type===`input_image`)a.push({type:`input_image`,image_url:t.image_url||``,detail:`auto`});else if(t.type===`input_file`){let e=t;a.push({type:`input_file`,file_data:e.file_data,filename:e.filename})}}let o=Math.floor(Date.now()/1e3);if(!i&&a.length>0){let e={id:`user-${Date.now()}`,type:`message`,role:`user`,content:a,status:`completed`,created_at:o};h([...Q.getState().chatItems,e])}_(!0);try{let i=n;if(!i)try{i=await Vh.createConversation({agent_id:e.id}),p(i),m([i,...Q.getState().availableConversations]),E(null)}catch(e){E({message:e instanceof Error?e.message:`Failed to create conversation`,type:`conversation_creation_error`}),_(!1);return}let a=await Vh.runAgentSync(e.id,{input:r.input,conversation_id:i?.id}),s=[],c=[],l=[];if(a.output){for(let e of a.output)if(e.type===`message`){let t=e;if(t.content)for(let e of t.content)e.type===`output_text`?s.push({type:`text`,text:e.text}):e.type===`refusal`?s.push({type:`refusal`,refusal:e.refusal}):(e.type===`output_image`||e.type===`output_file`||e.type===`output_data`)&&s.push(e)}else if(e.type===`function_call`){let t=e;c.push({id:t.id||`call-${Date.now()}`,type:`function_call`,name:t.name,arguments:t.arguments||``,call_id:t.call_id,status:t.status||`completed`,created_at:o})}else if(e.type===`function_call_output`){let t=e;l.push({id:`result-${Date.now()}`,type:`function_call_output`,call_id:t.call_id,output:t.output,status:`completed`,created_at:o})}}let u={id:`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:s,status:`completed`,created_at:o,usage:a.usage?{input_tokens:a.usage.input_tokens,output_tokens:a.usage.output_tokens,total_tokens:a.usage.total_tokens}:void 0};h([...Q.getState().chatItems,u,...c,...l]),a.usage&&b(a.usage.total_tokens),t({type:`response.completed`,response:a,sequence_number:0}),typeof a.id==`string`&&ie(a.id)}catch(e){let t=e instanceof Error?e.message:`Failed to get response`,n={id:`assistant-${Date.now()}`,type:`message`,role:`assistant`,content:[{type:`text`,text:`Error: ${t}`}],status:`incomplete`,created_at:o},r=Q.getState().chatItems;h([...r,n])}finally{_(!1)}},[e,n,t,h,p,m,b,_,ie]),me=async t=>{if(!(!e||t.length===0)){V.current=!0,A(!1),_(!0);try{let e={input:[{type:`message`,role:`user`,content:t}],conversation_id:n?.id};f?await fe(e):await pe(e)}finally{_(!1)}}};return(0,T.jsxs)(`div`,{className:`flex h-[calc(100vh-3.5rem)] flex-col relative`,...L,children:[ee&&(0,T.jsx)(`div`,{className:`absolute inset-0 z-50 bg-blue-50/95 dark:bg-blue-950/95 backdrop-blur-sm flex items-center justify-center border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-lg m-2`,children:(0,T.jsxs)(`div`,{className:`text-center p-8`,children:[(0,T.jsx)(`div`,{className:`text-blue-600 dark:text-blue-400 text-lg font-medium mb-2`,children:`Drop files here`}),(0,T.jsx)(`div`,{className:`text-blue-500/80 dark:text-blue-400/70 text-sm`,children:`Images, PDFs, audio, and other files`})]})}),(0,T.jsxs)(`div`,{className:`border-b pb-2 p-4 flex-shrink-0`,children:[(0,T.jsxs)(`div`,{className:`flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 min-w-0`,children:[(0,T.jsx)(`h2`,{className:`font-semibold text-sm truncate`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Sl,{className:`h-4 w-4 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`truncate`,children:d.enabled?`Chat with ${d.model}`:`Chat with ${e.name||e.id}`})]})}),!d.enabled&&c===`developer`&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>C(!0),className:`h-6 w-6 p-0 flex-shrink-0`,title:`View agent details`,children:(0,T.jsx)(Jl,{className:`h-4 w-4`})}),e.source!==`in_memory`&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:le,disabled:D,className:`h-6 w-6 p-0 flex-shrink-0`,title:D?`Reloading...`:`Reload entity code (hot reload)`,children:(0,T.jsx)(uu,{className:`h-4 w-4 ${D?`animate-spin`:``}`})})]})]}),(0,T.jsxs)(`div`,{className:`flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0`,children:[(0,T.jsxs)(Q_,{value:n?.id||``,onValueChange:ue,disabled:s||o,children:[(0,T.jsx)(ev,{className:`w-full sm:w-64`,children:(0,T.jsx)($_,{placeholder:s?`Loading...`:r.length===0?`No conversations`:n?`Conversation ${n.id.slice(-8)}`:`Select conversation`,children:n&&(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs`,children:[(0,T.jsxs)(`span`,{children:[`Conversation `,n.id.slice(-8)]}),l.total_tokens>0&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`•`}),(0,T.jsxs)(`span`,{className:`text-muted-foreground`,children:[l.total_tokens>=1e3?`${(l.total_tokens/1e3).toFixed(1)}k`:l.total_tokens,` `,`tokens`]})]})]})})}),(0,T.jsx)(tv,{children:r.map(e=>(0,T.jsx)(nv,{value:e.id,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between w-full`,children:[(0,T.jsxs)(`span`,{children:[`Conversation `,e.id.slice(-8)]}),e.created_at&&(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground ml-3`,children:new Date(e.created_at*1e3).toLocaleDateString()})]})},e.id))})]}),(0,T.jsx)(K,{variant:`outline`,size:`icon`,onClick:()=>n&&ce(n.id),disabled:!n||o,title:n?`Delete Conversation ${n.id.slice(-8)}`:`No conversation selected`,children:(0,T.jsx)(xu,{className:`h-4 w-4`})}),(0,T.jsxs)(K,{variant:`outline`,size:`lg`,onClick:se,disabled:!e||o,className:`whitespace-nowrap `,children:[(0,T.jsx)(lu,{className:`h-4 w-4 mr-2`}),(0,T.jsx)(`span`,{className:`hidden md:inline`,children:` New Conversation`})]})]})]}),d.enabled?(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:`Using OpenAI model directly. Local agent tools and instructions are not applied.`}):e.description&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:e.description})]}),w&&(0,T.jsxs)(`div`,{className:`mx-4 mt-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md flex items-start gap-2`,children:[(0,T.jsx)(Ol,{className:`h-4 w-4 text-destructive mt-0.5 flex-shrink-0`}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsx)(`div`,{className:`text-sm font-medium text-destructive`,children:`Failed to Create Conversation`}),(0,T.jsx)(`div`,{className:`text-xs text-destructive/90 mt-1 break-words`,children:w.message}),w.code&&(0,T.jsxs)(`div`,{className:`text-xs text-destructive/70 mt-1`,children:[`Error Code: `,w.code]})]}),(0,T.jsx)(`button`,{onClick:()=>E(null),className:`text-destructive hover:text-destructive/80 flex-shrink-0`,title:`Dismiss error`,children:(0,T.jsx)(Du,{className:`h-4 w-4`})})]}),(0,T.jsx)(gf,{className:`flex-1 p-4 h-0`,ref:R,children:(0,T.jsxs)(`div`,{className:`space-y-4`,children:[i.length===0?(0,T.jsxs)(`div`,{className:`flex flex-col items-center justify-center h-32 text-center`,children:[(0,T.jsxs)(`div`,{className:`text-muted-foreground text-sm`,children:[`Start a conversation with`,` `,e.name||e.id]}),(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground mt-1`,children:`Type a message below to begin`})]}):(()=>{let e=[],t=new Map,n=new Map,r=null,a=[],o=[];for(let e=0;e0){let e=t.get(s.id)||[];e.push(...a),t.set(s.id,e),a.length=0}if(o.length>0){let e=n.get(s.id)||[];e.push(...o),n.set(s.id,e),o.length=0}r=s.id}else if(s.type===`function_call`)if(r){let e=t.get(r)||[];e.push(s),t.set(r,e)}else a.push(s);else if(s.type===`function_call_output`)if(r){let e=n.get(r)||[];e.push(s),n.set(r,e)}else o.push(s);else s.type===`message`&&s.role===`user`&&(r=null)}for(let r of i)if(r.type===`message`){let i=t.get(r.id)||[],a=n.get(r.id)||[];e.push((0,T.jsx)(fv,{item:r,toolCalls:i,toolResults:a},r.id))}return e})(),k&&!a&&(0,T.jsx)(`div`,{className:`px-4 py-2`,children:(0,T.jsx)(`div`,{className:`border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10`,children:(0,T.jsxs)(`div`,{className:`px-4 py-3 flex items-center gap-2`,children:[(0,T.jsx)(yu,{className:`w-4 h-4 text-orange-500 dark:text-orange-400 fill-current`}),(0,T.jsx)(`span`,{className:`font-medium text-sm text-orange-700 dark:text-orange-300`,children:`Response stopped by user`})]})})}),(0,T.jsx)(`div`,{ref:z})]})}),u.length>0&&(0,T.jsx)(`div`,{className:`border-t bg-amber-50 dark:bg-amber-950/20 p-4 flex-shrink-0`,children:(0,T.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,T.jsx)(Ol,{className:`h-5 w-5 text-amber-600 dark:text-amber-500 mt-0.5 flex-shrink-0`}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsx)(`h4`,{className:`font-medium text-sm mb-2`,children:`Approval Required`}),(0,T.jsx)(`div`,{className:`space-y-2`,children:u.map(e=>(0,T.jsxs)(`div`,{className:`bg-white dark:bg-gray-900 rounded-lg p-3 border border-amber-200 dark:border-amber-900`,children:[(0,T.jsxs)(`div`,{className:`font-mono text-xs mb-3 break-all`,children:[(0,T.jsx)(`span`,{className:`text-blue-600 dark:text-blue-400 font-semibold`,children:e.function_call.name}),(0,T.jsx)(`span`,{className:`text-gray-500`,children:`(`}),(0,T.jsx)(`span`,{className:`text-gray-700 dark:text-gray-300`,children:JSON.stringify(e.function_call.arguments)}),(0,T.jsx)(`span`,{className:`text-gray-500`,children:`)`})]}),(0,T.jsxs)(`div`,{className:`flex gap-2`,children:[(0,T.jsxs)(K,{size:`sm`,onClick:async()=>{let t=await de(e.request_id,!0);t&&await fe(t)},variant:`default`,className:`flex-1 sm:flex-none`,children:[(0,T.jsx)(Tl,{className:`h-4 w-4 mr-1`}),`Approve`]}),(0,T.jsxs)(K,{size:`sm`,onClick:async()=>{let t=await de(e.request_id,!1);t&&await fe(t)},variant:`outline`,className:`flex-1 sm:flex-none`,children:[(0,T.jsx)(Du,{className:`h-4 w-4 mr-1`}),`Reject`]})]})]},e.request_id))})]})]})}),(0,T.jsx)(`div`,{className:`border-t flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`p-4`,children:(0,T.jsx)(fg,{onSubmit:me,isSubmitting:o,isStreaming:a,onCancel:N,isCancelling:j,placeholder:`Message ${e.name||e.id}... (Shift+Enter for new line)`,showFileUpload:!0,entityName:e.name||e.id,disabled:!e,externalFiles:F,onExternalFilesProcessed:I})})}),(0,T.jsx)(ov,{agent:e,open:S,onOpenChange:C})]})}function mv({message:e=`Loading...`,description:t,size:n=`md`,className:r,fullPage:i=!1}){let a=(0,T.jsxs)(`div`,{className:at(`flex flex-col items-center justify-center gap-3`,i?`min-h-[50vh]`:`py-8`,r),children:[(0,T.jsx)(Fu,{size:n,className:`text-muted-foreground`}),(0,T.jsxs)(`div`,{className:`text-center space-y-1`,children:[(0,T.jsx)(`p`,{className:at(`font-medium text-muted-foreground`,n===`sm`&&`text-sm`,n===`lg`&&`text-lg`),children:e}),t&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground/80`,children:t})]})]});return i?(0,T.jsx)(`div`,{className:`flex items-center justify-center min-h-screen bg-background`,children:a}):a}function hv(e){return[`name`,`title`,`id`,`key`,`label`,`type`,`status`,`tag`,`category`,`code`,`username`,`password`,`email`].includes(e.toLowerCase())}function gv(e){if(e.type)return e;if(e.anyOf&&e.anyOf.length>0){let t=e.anyOf.filter(e=>e.type!==`null`&&e.type!==void 0);if(t.length>0)return{...t[0],default:e.default??t[0].default,description:e.description??t[0].description,title:e.title??t[0].title}}if(e.oneOf&&e.oneOf.length>0){let t=e.oneOf.filter(e=>e.type!==`null`&&e.type!==void 0);if(t.length>0)return{...t[0],default:e.default??t[0].default,description:e.description??t[0].description,title:e.title??t[0].title}}return e}function _v(e,t){return t.format===`textarea`||!!t.description&&t.description.length>100||t.type===`string`&&!t.enum&&!hv(e)}function vv(e,t){let n=_v(e,t),r=!!t.description&&t.description.length>150;return n||r?`md:col-span-2 lg:col-span-3 xl:col-span-4`:t.type===`array`||t.description&&t.description.length>80?`xl:col-span-2`:``}function yv(e,t){if(e.type!==`object`||!e.properties)return!1;let n=e.properties,r=Object.keys(n).filter(e=>!t.includes(e));return t.includes(`role`)&&r.some(e=>[`text`,`message`,`content`].includes(e))&&n.role?.type===`string`}function bv({name:e,schema:t,value:n,onChange:r,isRequired:i=!1,isReadOnly:a=!1}){let o=gv(t),{type:s,description:c,enum:l,default:u}=o,d=_v(e,o);return(0,T.jsx)(`div`,{className:vv(e,o),children:(()=>{if(a)return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(qm,{htmlFor:e,className:`text-muted-foreground`,children:e}),(0,T.jsx)(`div`,{className:`text-sm p-2 bg-muted rounded border`,children:typeof n==`object`?JSON.stringify(n,null,2):String(n)}),c&&(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:c})]});switch(s){case`string`:return l?(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsxs)(Q_,{value:typeof n==`string`&&n?n:typeof u==`string`?u:l[0],onValueChange:e=>r(e),children:[(0,T.jsx)(ev,{children:(0,T.jsx)($_,{placeholder:`Select ${e}`})}),(0,T.jsx)(tv,{children:l.map(e=>(0,T.jsx)(nv,{value:e,children:e},e))})]}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]}):d?(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(og,{id:e,value:typeof n==`string`?n:``,onChange:e=>r(e.target.value),placeholder:typeof u==`string`?u:`Enter ${e}`,rows:4,className:`min-w-[300px] w-full`}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]}):(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(Um,{id:e,type:`text`,value:typeof n==`string`?n:``,onChange:e=>r(e.target.value),placeholder:typeof u==`string`?u:`Enter ${e}`}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]});case`integer`:case`number`:return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(Um,{id:e,type:`number`,step:s===`integer`?`1`:`any`,value:typeof n==`number`?n:``,onChange:e=>{let t=s===`integer`?parseInt(e.target.value):parseFloat(e.target.value);r(isNaN(t)?``:t)},placeholder:typeof u==`number`?u.toString():`Enter ${e}`}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]});case`boolean`:return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center space-x-2`,children:[(0,T.jsx)(ip,{id:e,checked:!!n,onCheckedChange:e=>r(e)}),(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]})]}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]});case`array`:return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(og,{id:e,value:Array.isArray(n)?n.join(`, `):typeof n==`string`?n:``,onChange:e=>{r(e.target.value.split(`,`).map(e=>e.trim()).filter(e=>e.length>0))},placeholder:`Enter items separated by commas`,rows:2}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]});default:return(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsxs)(qm,{htmlFor:e,children:[e,i&&(0,T.jsx)(`span`,{className:`text-destructive ml-1`,children:`*`})]}),(0,T.jsx)(og,{id:e,value:typeof n==`object`&&n?JSON.stringify(n,null,2):typeof n==`string`?n:``,onChange:e=>{try{r(JSON.parse(e.target.value))}catch{r(e.target.value)}},placeholder:`{"key": "value"}`,rows:3,className:`font-mono text-xs`}),c&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:c})]})}})()})}function xv({schema:e,values:t,onChange:n,disabled:r=!1,readOnlyFields:i=[],hideFields:a=[],showCollapsedByDefault:o=!1,layout:s=`stack`}){let[c,l]=(0,y.useState)(o),u=s===`grid`?`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6`:`space-y-3`,d=e.properties||{},f=Object.keys(d).filter(e=>!a.includes(e)),p=(e.required||[]).filter(e=>!a.includes(e)),m=yv(e,p),h=f.filter(e=>p.includes(e)&&!(m&&e===`role`)),g=f.filter(e=>!p.includes(e)),_=m?[...g].sort((e,t)=>{let n=e=>+!![`text`,`message`,`content`].includes(e);return n(t)-n(e)}):g,v=Math.max(0,(m?1:6)-h.length),b=_.slice(0,v),x=_.slice(v),S=x.length>0,C=h.length>0,w=(e,r)=>{n({...t,[e]:r})},E=s===`grid`?`md:col-span-2 lg:col-span-3 xl:col-span-4`:``;return(0,T.jsxs)(`div`,{className:u,children:[h.map(e=>(0,T.jsx)(bv,{name:e,schema:d[e],value:t[e],onChange:t=>w(e,t),isRequired:!0,isReadOnly:r||i.includes(e)},e)),C&&g.length>0&&(0,T.jsx)(`div`,{className:E,children:(0,T.jsx)(`div`,{className:`border-t border-border`})}),b.map(e=>(0,T.jsx)(bv,{name:e,schema:d[e],value:t[e],onChange:t=>w(e,t),isRequired:!1,isReadOnly:r||i.includes(e)},e)),S&&(0,T.jsx)(`div`,{className:E,children:(0,T.jsx)(K,{type:`button`,variant:`ghost`,size:`sm`,onClick:()=>l(!c),className:`w-full justify-center gap-2`,disabled:r,children:c?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Dl,{className:`h-4 w-4`}),`Hide `,x.length,` optional field`,x.length===1?``:`s`]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(X,{className:`h-4 w-4`}),`Show `,x.length,` optional field`,x.length===1?``:`s`]})})}),c&&x.map(e=>(0,T.jsx)(bv,{name:e,schema:d[e],value:t[e],onChange:t=>w(e,t),isRequired:!1,isReadOnly:r||i.includes(e)},e))]})}function Sv(e,t){return(e.required||[]).every(e=>{let n=t[e];return n!==void 0&&n!==``&&n!==null})}function Cv(e,t){let n=e.required||[],r={};return Object.keys(t).forEach(e=>{let i=t[e];(n.includes(e)||i!==void 0&&i!==``&&i!==null)&&(r[e]=i)}),r}function wv({inputSchema:e,inputTypeName:t,onSubmit:n,isSubmitting:r=!1,className:i}){let[a,o]=(0,y.useState)(!1),s=i?.includes(`embedded`),[c,l]=(0,y.useState)({}),[u,d]=(0,y.useState)(!1),f=e.properties||{},p=Object.keys(f),m=e.required||[],h=e.type===`string`&&!e.enum,g=yv(e,m),_=h?c.value!==void 0&&c.value!==``:m.length>0?m.every(e=>g&&e===`role`&&c.role===`user`?!0:c[e]!==void 0&&c[e]!==``):Object.keys(c).length>0;(0,y.useEffect)(()=>{if(e.type===`string`)l({value:e.default||``});else if(e.type===`object`&&e.properties){let t={};Object.entries(e.properties).forEach(([e,n])=>{n.default===void 0?n.enum&&n.enum.length>0&&(t[e]=n.enum[0]):t[e]=n.default}),g&&!t.role&&(t.role=`user`),l(t)}},[e,g]);let v=t=>{if(t.preventDefault(),d(!0),e.type===`string`)n({input:c.value||``});else if(e.type===`object`){let t=e.properties||{},r=Object.keys(t);if(r.length===1){let e=r[0];n({[e]:c[e]||``})}else n(Cv(e,c))}else n(c);s||o(!1),d(!1)},b=e=>{l(e)},x=()=>(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(qm,{htmlFor:`simple-input`,children:`Input`}),(0,T.jsx)(og,{id:`simple-input`,value:typeof c.value==`string`?c.value:``,onChange:e=>l({value:e.target.value}),placeholder:typeof e.default==`string`?e.default:`Enter input`,rows:4,className:`min-w-[300px] w-full`}),e.description&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:e.description})]});return s?(0,T.jsxs)(`form`,{onSubmit:v,className:i,children:[h&&x(),!h&&(0,T.jsx)(xv,{schema:e,values:c,onChange:b,disabled:u,hideFields:g?[`role`]:[],layout:`grid`}),(0,T.jsx)(`div`,{className:`flex gap-2 mt-4 justify-end`,children:(0,T.jsxs)(K,{type:`submit`,disabled:u||!_,size:`default`,children:[(0,T.jsx)(hu,{className:`h-4 w-4`}),u?`Running...`:`Run Workflow`]})})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{className:at(`flex flex-col`,i),children:[(0,T.jsxs)(`div`,{className:`border-b border-border px-4 py-3 bg-muted`,children:[(0,T.jsx)(Gh,{className:`text-sm mb-3`,children:`Run Workflow`}),(0,T.jsxs)(K,{onClick:()=>o(!0),disabled:r,className:`w-full`,size:`default`,children:[(0,T.jsx)(hu,{className:`h-4 w-4 mr-2`}),r?`Running...`:`Run Workflow`]})]}),(0,T.jsxs)(`div`,{className:`px-4 py-3`,children:[(0,T.jsxs)(`div`,{className:`text-sm text-muted-foreground`,children:[(0,T.jsx)(`strong`,{children:`Input Type:`}),` `,(0,T.jsx)(`code`,{className:`bg-muted px-1 py-0.5 rounded`,children:t}),e.type===`object`&&e.properties&&(0,T.jsxs)(`span`,{className:`ml-2`,children:[`(`,Object.keys(e.properties).length,` field`,Object.keys(e.properties).length===1?``:`s`,`)`]})]}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground mt-2`,children:`Click "Run Workflow" to configure inputs and execute`})]})]}),(0,T.jsx)(Im,{open:a,onOpenChange:o,children:(0,T.jsxs)(Lm,{className:`w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col`,children:[(0,T.jsxs)(Rm,{children:[(0,T.jsx)(zm,{children:`Run Workflow`}),(0,T.jsx)(Vm,{onClose:()=>o(!1)})]}),(0,T.jsx)(`div`,{className:`px-8 py-4 border-b flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,T.jsx)(`span`,{className:`font-medium`,children:`Input Type:`}),(0,T.jsx)(`code`,{className:`bg-muted px-3 py-1 text-xs font-mono`,children:t}),e.type===`object`&&(0,T.jsxs)(`span`,{className:`text-xs text-muted-foreground`,children:[p.length,` field`,p.length===1?``:`s`]})]})})}),(0,T.jsx)(`div`,{className:`px-8 py-6 overflow-y-auto flex-1 min-h-0`,children:(0,T.jsxs)(`form`,{id:`workflow-modal-form`,onSubmit:v,children:[h&&x(),!h&&(0,T.jsx)(xv,{schema:e,values:c,onChange:b,disabled:u,hideFields:g?[`role`]:[],layout:`grid`})]})}),(0,T.jsx)(`div`,{className:`px-8 py-4 border-t flex-shrink-0`,children:(0,T.jsxs)(Hm,{children:[(0,T.jsx)(K,{variant:`outline`,onClick:()=>o(!1),disabled:u,children:`Cancel`}),(0,T.jsxs)(K,{type:`submit`,form:`workflow-modal-form`,disabled:u||!_,children:[(0,T.jsx)(hu,{className:`h-4 w-4 mr-2`}),u?`Running...`:`Run Workflow`]})]})})]})})]})}function Tv(e,t,n=`LR`){if(e.length===0)return e;if(e.length===1)return e.map(e=>({...e,position:{x:0,y:0}}));let r=new Map,i=new Map;e.forEach(e=>{r.set(e.id,[]),i.set(e.id,[])}),t.forEach(e=>{r.get(e.source)?.push(e.target),i.get(e.target)?.push(e.source)});let a=e.filter(e=>(i.get(e.id)||[]).length===0);a.length===0&&a.push(e[0]);let o=n===`LR`?350:280,s=n===`TB`?250:180,c=new Map,l=new Map,u=[],d=new Set;for(a.forEach(e=>{u.push({nodeId:e.id,level:0})});u.length>0;){let{nodeId:e,level:t}=u.shift();d.has(e)||(d.add(e),l.has(t)||l.set(t,[]),l.get(t).push(e),(r.get(e)||[]).forEach(e=>{d.has(e)||u.push({nodeId:e,level:t+1})}))}return e.forEach(e=>{if(!d.has(e.id)){let t=Math.max(...Array.from(l.keys()),-1)+1;l.has(t)||l.set(t,[]),l.get(t).push(e.id)}}),l.forEach((e,t)=>{let r=e.length;e.forEach((e,i)=>{let a,l;n===`LR`?(a=t*o,l=-((r-1)*s)/2+i*s):(l=t*s,a=-((r-1)*o)/2+i*o),c.set(e,{x:a,y:l,level:t})})}),e.map(e=>{let t=c.get(e.id)||{x:0,y:0};return{...e,position:{x:t.x-220/2,y:t.y-120/2}}})}function Ev(e){if(typeof e!=`object`||!e)return!1;let t=e;return`id`in t&&`edge_groups`in t&&`executors`in t&&`start_executor_id`in t&&`max_iterations`in t&&typeof t.id==`string`&&Array.isArray(t.edge_groups)&&typeof t.executors==`object`&&typeof t.start_executor_id==`string`&&typeof t.max_iterations==`number`}function Dv(e){return Ev(e)?e:null}function Ov(e){if(!e||e.type!==`object`||!e.properties)return!1;let t=e.properties,n=`text`in t&&t.text?.type===`string`,r=`role`in t&&t.role?.type===`string`;return!!(n&&r||Object.keys(t).length===1&&n)}function kv(e,t=50,n=`...`){return e.length<=t?e:e.substring(0,t)+n}function Av(e,t,n){if(!e)return console.warn(`convertWorkflowDumpToNodes: workflowDump is undefined`),[];let r=Dv(e),i,a;return r?(i=Object.values(r.executors).map(e=>({id:e.id,type:e.type,name:e.name||e.id,description:e.description,config:e.config})),a=r.start_executor_id):(i=Mv(e),a=e?.start_executor_id),!i||!Array.isArray(i)||i.length===0?(console.warn(`No executors found in workflow dump. Available keys:`,Object.keys(e)),[]):i.map(e=>({id:e.id,type:`executor`,position:{x:0,y:0},data:{executorId:e.id,executorType:e.type,name:e.name||e.id,state:`pending`,isStartNode:e.id===a,layoutDirection:n||`LR`,onNodeClick:t}}))}function jv(e){if(!e)return console.warn(`convertWorkflowDumpToEdges: workflowDump is undefined`),[];let t=Dv(e),n;return t?(n=[],t.edge_groups.forEach(e=>{e.edges.forEach(e=>{n.push({source:e.source_id,target:e.target_id,condition:e.condition_name})})})):n=Nv(e),!n||!Array.isArray(n)||n.length===0?(console.warn(`No connections found in workflow dump. Available keys:`,Object.keys(e)),[]):n.map(e=>{let t=e.source===e.target;return{id:`${e.source}-${e.target}`,source:e.source,target:e.target,sourceHandle:`source`,targetHandle:`target`,type:t?`selfLoop`:`default`,animated:!1,style:{stroke:`#6b7280`,strokeWidth:2}}})}function Mv(e){if(e.executors&&typeof e.executors==`object`&&!Array.isArray(e.executors)){let t=e.executors;return Object.entries(t).map(([e,t])=>({id:e,type:t.type_||t.type||`executor`,name:t.name||e,description:t.description,config:t.config}))}for(let t of[`executors`,`agents`,`steps`,`nodes`])if(e[t]&&Array.isArray(e[t]))return e[t];if(e.config&&typeof e.config==`object`)return Mv(e.config);let t=[];return Object.entries(e).forEach(([e,n])=>{if(typeof n==`object`&&n&&(`type`in n||`type_`in n)){let r=n;t.push({id:e,type:r.type_||r.type||`executor`,name:r.name||e,description:r.description,config:r.config})}}),t}function Nv(e){if(e.edge_groups&&Array.isArray(e.edge_groups)){let t=[];return e.edge_groups.forEach(e=>{if(typeof e==`object`&&e&&`edges`in e){let n=e.edges;Array.isArray(n)&&n.forEach(e=>{if(typeof e==`object`&&e&&`source_id`in e&&`target_id`in e){let n=e;t.push({source:n.source_id,target:n.target_id,condition:n.condition_name||void 0})}})}}),t}for(let t of[`connections`,`edges`,`transitions`,`links`])if(e[t]&&Array.isArray(e[t]))return e[t];return e.config&&typeof e.config==`object`?Nv(e.config):[]}function Pv(e,t,n=`LR`){return Tv(e,t,n)}function Fv(e,t){let n={},r=!1,i={};return e.forEach(e=>{if(e.type===`response.output_item.added`||e.type===`response.output_item.done`){let t=e.item;if(t&&t.type===`executor_action`&&`executor_id`in t){let r=t.executor_id,a=t.id;if(e.type===`response.output_item.added`&&(i[r]=a),i[r]!==a&&e.type===`response.output_item.done`)return;let o=`pending`,s;e.type===`response.output_item.added`?o=`running`:e.type===`response.output_item.done`&&(t.status===`completed`?o=`completed`:t.status===`failed`?(o=`failed`,s=t.error?typeof t.error==`string`?t.error:JSON.stringify(t.error):`Execution failed`):t.status===`cancelled`&&(o=`cancelled`)),n[r]={nodeId:r,state:o,data:t.result,error:s,timestamp:new Date().toISOString()}}}else if(e.type===`response.created`||e.type===`response.in_progress`)r=!0;else if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let t=e.data,i=t.executor_id,a=t.event_type,o=t.data,s=`pending`,c;a===`ExecutorInvokedEvent`?s=`running`:a===`ExecutorCompletedEvent`?s=`completed`:a?.includes(`Error`)||a?.includes(`Failed`)?(s=`failed`,c=typeof o==`string`?o:`Execution failed`):a?.includes(`Cancel`)?s=`cancelled`:a===`WorkflowCompletedEvent`||a===`WorkflowOutputEvent`?s=`completed`:a===`WorkflowStartedEvent`&&(r=!0),i&&(n[i]={nodeId:i,state:s,data:o,error:c,timestamp:new Date().toISOString()})}}),r&&t&&!n[t]&&(e.some(e=>{if(e.type===`response.output_item.done`){let n=e.item;return n&&n.type===`executor_action`&&`executor_id`in n&&n.executor_id===t}if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let n=e.data;return n.executor_id===t&&(n.event_type===`ExecutorCompletedEvent`||n.event_type===`ExecutorFailedEvent`||typeof n.event_type==`string`&&n.event_type.includes(`Error`)||typeof n.event_type==`string`&&n.event_type.includes(`Failed`))}return!1})||(n[t]={nodeId:t,state:`running`,data:void 0,error:void 0,timestamp:new Date().toISOString()})),n}function Iv(e,t,n=!0){return e.map(e=>{let r=t[e.id];return r?{...e,data:{...e.data,state:r.state,outputData:r.data,error:r.error,isStreaming:n,layoutDirection:e.data.layoutDirection}}:e})}function Lv(e){let t={};return e.forEach(e=>{if(e.type===`response.output_item.added`||e.type===`response.output_item.done`){let n=e.item;if(n&&n.type===`executor_action`&&`executor_id`in n){let r=n.executor_id;t[r]={lastEvent:e.type===`response.output_item.added`?`ExecutorInvokedEvent`:`ExecutorCompletedEvent`,timestamp:new Date().toISOString()}}}else if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let n=e.data,r=n.executor_id,i=n.event_type;r&&(i===`ExecutorInvokedEvent`||i===`ExecutorCompletedEvent`)&&(t[r]={lastEvent:i,timestamp:new Date().toISOString()})}}),Object.entries(t).filter(([,e])=>e.lastEvent===`ExecutorInvokedEvent`).map(([e])=>e)}function Rv(e,t){let n=Lv(t),r={};return t.forEach(e=>{if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let t=e.data,n=t.executor_id,i=t.event_type;n&&i&&(r[n]||(r[n]={completed:!1,invoked:!1}),i===`ExecutorInvokedEvent`?r[n].invoked=!0:i===`ExecutorCompletedEvent`&&(r[n].completed=!0))}}),e.map(e=>{let t=r[e.source],i=r[e.target],a=n.includes(e.target),o={...e.style},s=!1;return t?.completed&&a?(o={stroke:`#643FB2`,strokeWidth:3,strokeDasharray:`5,5`},s=!0):o=t?.completed&&i?.completed?{stroke:`#10b981`,strokeWidth:2}:t?.completed&&i?.invoked?{stroke:`#f59e0b`,strokeWidth:2}:{stroke:`#6b7280`,strokeWidth:2},{...e,style:o,animated:s}})}function zv(e){let t=new Map,n=new Set;return e.forEach(e=>{let r=`${e.source}-${e.target}`,i=`${e.target}-${e.source}`;if(e.source===e.target){t.set(r,e);return}if(t.has(i)){n.add(i),n.add(r);let e=t.get(i);t.set(i,{...e,markerStart:{type:`arrow`,width:20,height:20},markerEnd:{type:`arrow`,width:20,height:20},data:{...e.data,isBidirectional:!0}})}else n.has(r)||t.set(r,e)}),Array.from(t.values())}function Bv({inputSchema:e,onRun:t,onCancel:n,isSubmitting:r,isCancelling:i=!1,workflowState:a,checkpoints:o=[],showCheckpoints:s=!0}){let[c,l]=(0,y.useState)(!1);(0,y.useEffect)(()=>{let e=e=>{e.key===`Escape`&&c&&l(!1)};if(c)return document.addEventListener(`keydown`,e),()=>document.removeEventListener(`keydown`,e)},[c]);let u=(0,y.useMemo)(()=>{let t=Ov(e);if(!e)return{needsInput:!1,hasDefaults:!1,fieldCount:0,canRunDirectly:!0,isChatMessage:!1};if(e.type===`string`)return{needsInput:!e.default,hasDefaults:!!e.default,fieldCount:1,canRunDirectly:!!e.default,isChatMessage:!1};if(e.type===`object`&&e.properties){let n=e.properties,r=Object.entries(n),i=r.filter(([,e])=>e.default!==void 0||e.enum&&e.enum.length>0);return{needsInput:r.length>0,hasDefaults:i.length>0,fieldCount:r.length,canRunDirectly:r.length===0||i.length===r.length,isChatMessage:t}}return{needsInput:!1,hasDefaults:!1,fieldCount:0,canRunDirectly:!0,isChatMessage:!1}},[e]),d=()=>{if(a===`running`&&n)n();else if(u.canRunDirectly){let n={};e?.type===`string`&&e.default?n.input=e.default:e?.type===`object`&&e.properties&&Object.entries(e.properties).forEach(([e,t])=>{t.default===void 0?t.enum&&t.enum.length>0&&(n[e]=t.enum[0]):n[e]=t.default}),t(n)}else l(!0)},f=n=>{if(u.canRunDirectly){let r={};e?.type===`string`&&e.default?r.input=e.default:e?.type===`object`&&e.properties&&Object.entries(e.properties).forEach(([e,t])=>{t.default===void 0?t.enum&&t.enum.length>0&&(r[e]=t.enum[0]):r[e]=t.default}),t(r,n)}else l(!0)},p=s&&o.length>0,m=e=>{if(!e)return``;let t=e/1024;return t<1?`${e} B`:t<1024?`${t.toFixed(1)} KB`:`${(t/1024).toFixed(1)} MB`},{icon:h,text:g}={icon:i?(0,T.jsx)(Ql,{className:`w-4 h-4 animate-spin`}):a===`running`&&n?(0,T.jsx)(yu,{className:`w-4 h-4 fill-current`}):a===`running`?(0,T.jsx)(Ql,{className:`w-4 h-4 animate-spin`}):a===`error`?(0,T.jsx)(fu,{className:`w-4 h-4`}):u.needsInput&&!u.canRunDirectly?(0,T.jsx)(_u,{className:`w-4 h-4`}):(0,T.jsx)(cu,{className:`w-4 h-4`}),text:i?`Stopping...`:a===`running`&&n?`Stop`:a===`running`?`Running...`:a===`completed`?`Run Again`:a===`error`?`Retry`:u.fieldCount===0||u.canRunDirectly?`Run Workflow`:`Configure & Run`},_=a===`running`&&!n||i,v=a===`error`?`destructive`:`default`;return(0,T.jsxs)(T.Fragment,{children:[p||u.needsInput?(0,T.jsxs)(ku,{children:[(0,T.jsxs)(`div`,{className:`flex w-full`,children:[(0,T.jsxs)(K,{onClick:d,disabled:_,variant:v,className:`gap-2 rounded-r-none flex-1`,title:a===`running`&&n?`Stop workflow execution`:void 0,children:[h,g]}),(0,T.jsx)(Au,{asChild:!0,children:(0,T.jsx)(K,{disabled:_,variant:v,className:`rounded-l-none border-l-0 px-2`,title:`More options`,children:(0,T.jsx)(X,{className:`w-4 h-4`})})})]}),(0,T.jsxs)(ju,{align:`end`,className:`w-80 max-h-[400px] overflow-y-auto`,children:[p&&(0,T.jsxs)(Mu,{onClick:d,children:[(0,T.jsx)(cu,{className:`w-4 h-4 mr-2`}),`Run Fresh`]}),u.needsInput&&(0,T.jsxs)(Mu,{onClick:()=>l(!0),children:[(0,T.jsx)(_u,{className:`w-4 h-4 mr-2`}),`Configure Inputs`]}),p&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Pu,{}),(0,T.jsx)(`div`,{className:`px-2 py-1.5 text-xs text-muted-foreground`,children:`Resume from checkpoint`}),o.map((e,t)=>(0,T.jsxs)(Mu,{onClick:()=>f(e.checkpoint_id),className:`flex flex-col items-start py-2`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 w-full`,children:[(0,T.jsx)(uu,{className:`w-4 h-4 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`font-medium`,children:e.metadata.iteration_count===0?`Initial State`:`Step ${e.metadata.iteration_count}`}),t===0&&(0,T.jsx)(ct,{variant:`secondary`,className:`text-[10px] h-4 px-1 ml-auto`,children:`Latest`})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground ml-6 mt-0.5`,children:[(0,T.jsx)(Nl,{className:`w-3 h-3`}),(0,T.jsx)(`span`,{children:new Date(e.timestamp).toLocaleTimeString()}),e.metadata.size_bytes&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{children:`•`}),(0,T.jsx)(`span`,{children:m(e.metadata.size_bytes)})]})]})]},e.checkpoint_id))]})]})]}):(0,T.jsxs)(K,{onClick:d,disabled:_,variant:v,className:`gap-2 w-full`,title:a===`running`&&n?`Stop workflow execution`:void 0,children:[h,g]}),e&&(0,T.jsx)(Im,{open:c,onOpenChange:l,children:(0,T.jsxs)(Lm,{className:`w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col`,children:[(0,T.jsxs)(Rm,{className:`px-8 pt-6`,children:[(0,T.jsx)(zm,{children:`Configure Workflow Inputs`}),(0,T.jsx)(Vm,{onClose:()=>l(!1)})]}),(0,T.jsx)(`div`,{className:`px-8 py-4 border-b flex-shrink-0`,children:(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,T.jsx)(`span`,{className:`font-medium`,children:`Input Type:`}),(0,T.jsx)(ct,{variant:`secondary`,children:u.isChatMessage?`Chat Message`:e.type===`string`?`Simple Text`:`Structured Data`})]})})}),(0,T.jsx)(`div`,{className:`flex-1 overflow-y-auto py-4 px-8`,children:u.isChatMessage?(0,T.jsx)(fg,{onSubmit:async e=>{t([{type:`message`,role:`user`,content:e}]),l(!1)},isSubmitting:r,placeholder:`Enter your message...`,entityName:`workflow`,showFileUpload:!0}):(0,T.jsx)(wv,{inputSchema:e,inputTypeName:`Input`,onSubmit:e=>{t(e),l(!1)},isSubmitting:r,className:`embedded`})})]})})]})}function Vv(e){if(typeof e==`string`||typeof e==`number`)return``+e;let t=``;if(Array.isArray(e))for(let n=0,r;n{}};function Uv(){for(var e=0,t=arguments.length,n={},r;e=0&&(n=e.slice(r+1),e=e.slice(0,r)),e&&!t.hasOwnProperty(e))throw Error(`unknown type: `+e);return{type:e,name:n}})}Wv.prototype=Uv.prototype={constructor:Wv,on:function(e,t){var n=this._,r=Gv(e+``,n),i,a=-1,o=r.length;if(arguments.length<2){for(;++a0)for(var n=Array(i),r=0,i,a;r=0&&(t=e.slice(0,n))!==`xmlns`&&(e=e.slice(n+1)),Jv.hasOwnProperty(t)?{space:Jv[t],local:e}:e}function Xv(e){return function(){var t=this.ownerDocument,n=this.namespaceURI;return n===`http://www.w3.org/1999/xhtml`&&t.documentElement.namespaceURI===`http://www.w3.org/1999/xhtml`?t.createElement(e):t.createElementNS(n,e)}}function Zv(e){return function(){return this.ownerDocument.createElementNS(e.space,e.local)}}function Qv(e){var t=Yv(e);return(t.local?Zv:Xv)(t)}function $v(){}function ey(e){return e==null?$v:function(){return this.querySelector(e)}}function ty(e){typeof e!=`function`&&(e=ey(e));for(var t=this._groups,n=t.length,r=Array(n),i=0;i=v&&(v=_+1);!(b=g[v])&&++v=0;)(o=r[i])&&(a&&o.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(o,a),a=o);return this}function jy(e){e||=My;function t(t,n){return t&&n?e(t.__data__,n.__data__):!t-!n}for(var n=this._groups,r=n.length,i=Array(r),a=0;at?1:e>=t?0:NaN}function Ny(){var e=arguments[0];return arguments[0]=this,e.apply(null,arguments),this}function Py(){return Array.from(this)}function Fy(){for(var e=this._groups,t=0,n=e.length;t1?this.each((t==null?qy:typeof t==`function`?Yy:Jy)(e,t,n??``)):Zy(this.node(),e)}function Zy(e,t){return e.style.getPropertyValue(t)||Ky(e).getComputedStyle(e,null).getPropertyValue(t)}function Qy(e){return function(){delete this[e]}}function $y(e,t){return function(){this[e]=t}}function eb(e,t){return function(){var n=t.apply(this,arguments);n==null?delete this[e]:this[e]=n}}function tb(e,t){return arguments.length>1?this.each((t==null?Qy:typeof t==`function`?eb:$y)(e,t)):this.node()[e]}function nb(e){return e.trim().split(/^|\s+/)}function rb(e){return e.classList||new ib(e)}function ib(e){this._node=e,this._names=nb(e.getAttribute(`class`)||``)}ib.prototype={add:function(e){this._names.indexOf(e)<0&&(this._names.push(e),this._node.setAttribute(`class`,this._names.join(` `)))},remove:function(e){var t=this._names.indexOf(e);t>=0&&(this._names.splice(t,1),this._node.setAttribute(`class`,this._names.join(` `)))},contains:function(e){return this._names.indexOf(e)>=0}};function ab(e,t){for(var n=rb(e),r=-1,i=t.length;++r=0&&(t=e.slice(n+1),e=e.slice(0,n)),{type:e,name:t}})}function Pb(e){return function(){var t=this.__on;if(t){for(var n=0,r=-1,i=t.length,a;n()=>e;function nx(e,{sourceEvent:t,subject:n,target:r,identifier:i,active:a,x:o,y:s,dx:c,dy:l,dispatch:u}){Object.defineProperties(this,{type:{value:e,enumerable:!0,configurable:!0},sourceEvent:{value:t,enumerable:!0,configurable:!0},subject:{value:n,enumerable:!0,configurable:!0},target:{value:r,enumerable:!0,configurable:!0},identifier:{value:i,enumerable:!0,configurable:!0},active:{value:a,enumerable:!0,configurable:!0},x:{value:o,enumerable:!0,configurable:!0},y:{value:s,enumerable:!0,configurable:!0},dx:{value:c,enumerable:!0,configurable:!0},dy:{value:l,enumerable:!0,configurable:!0},_:{value:u}})}nx.prototype.on=function(){var e=this._.on.apply(this._,arguments);return e===this._?this:e};function rx(e){return!e.ctrlKey&&!e.button}function ix(){return this.parentNode}function ax(e,t){return t??{x:e.x,y:e.y}}function ox(){return navigator.maxTouchPoints||`ontouchstart`in this}function sx(){var e=rx,t=ix,n=ax,r=ox,i={},a=Uv(`start`,`drag`,`end`),o=0,s,c,l,u,d=0;function f(e){e.on(`mousedown.drag`,p).filter(r).on(`touchstart.drag`,g).on(`touchmove.drag`,_,Yb).on(`touchend.drag touchcancel.drag`,v).style(`touch-action`,`none`).style(`-webkit-tap-highlight-color`,`rgba(0,0,0,0)`)}function p(n,r){if(!(u||!e.call(this,n,r))){var i=y(this,t.call(this,n,r),n,r,`mouse`);i&&(Kb(n.view).on(`mousemove.drag`,m,Xb).on(`mouseup.drag`,h,Xb),$b(n.view),Zb(n),l=!1,s=n.clientX,c=n.clientY,i(`start`,n))}}function m(e){if(Qb(e),!l){var t=e.clientX-s,n=e.clientY-c;l=t*t+n*n>d}i.mouse(`drag`,e)}function h(e){Kb(e.view).on(`mousemove.drag mouseup.drag`,null),ex(e.view,l),Qb(e),i.mouse(`end`,e)}function g(n,r){if(e.call(this,n,r)){var i=n.changedTouches,a=t.call(this,n,r),o=i.length,s,c;for(s=0;s>8&15|t>>4&240,t>>4&15|t&240,(t&15)<<4|t&15,1):n===8?Ax(t>>24&255,t>>16&255,t>>8&255,(t&255)/255):n===4?Ax(t>>12&15|t>>8&240,t>>8&15|t>>4&240,t>>4&15|t&240,((t&15)<<4|t&15)/255):null):(t=_x.exec(e))?new Nx(t[1],t[2],t[3],1):(t=vx.exec(e))?new Nx(t[1]*255/100,t[2]*255/100,t[3]*255/100,1):(t=yx.exec(e))?Ax(t[1],t[2],t[3],t[4]):(t=bx.exec(e))?Ax(t[1]*255/100,t[2]*255/100,t[3]*255/100,t[4]):(t=xx.exec(e))?Bx(t[1],t[2]/100,t[3]/100,1):(t=Sx.exec(e))?Bx(t[1],t[2]/100,t[3]/100,t[4]):Cx.hasOwnProperty(e)?kx(Cx[e]):e===`transparent`?new Nx(NaN,NaN,NaN,0):null}function kx(e){return new Nx(e>>16&255,e>>8&255,e&255,1)}function Ax(e,t,n,r){return r<=0&&(e=t=n=NaN),new Nx(e,t,n,r)}function jx(e){return e instanceof ux||(e=Ox(e)),e?(e=e.rgb(),new Nx(e.r,e.g,e.b,e.opacity)):new Nx}function Mx(e,t,n,r){return arguments.length===1?jx(e):new Nx(e,t,n,r??1)}function Nx(e,t,n,r){this.r=+e,this.g=+t,this.b=+n,this.opacity=+r}cx(Nx,Mx,lx(ux,{brighter(e){return e=e==null?fx:fx**+e,new Nx(this.r*e,this.g*e,this.b*e,this.opacity)},darker(e){return e=e==null?dx:dx**+e,new Nx(this.r*e,this.g*e,this.b*e,this.opacity)},rgb(){return this},clamp(){return new Nx(Rx(this.r),Rx(this.g),Rx(this.b),Lx(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Px,formatHex:Px,formatHex8:Fx,formatRgb:Ix,toString:Ix}));function Px(){return`#${zx(this.r)}${zx(this.g)}${zx(this.b)}`}function Fx(){return`#${zx(this.r)}${zx(this.g)}${zx(this.b)}${zx((isNaN(this.opacity)?1:this.opacity)*255)}`}function Ix(){let e=Lx(this.opacity);return`${e===1?`rgb(`:`rgba(`}${Rx(this.r)}, ${Rx(this.g)}, ${Rx(this.b)}${e===1?`)`:`, ${e})`}`}function Lx(e){return isNaN(e)?1:Math.max(0,Math.min(1,e))}function Rx(e){return Math.max(0,Math.min(255,Math.round(e)||0))}function zx(e){return e=Rx(e),(e<16?`0`:``)+e.toString(16)}function Bx(e,t,n,r){return r<=0?e=t=n=NaN:n<=0||n>=1?e=t=NaN:t<=0&&(e=NaN),new Ux(e,t,n,r)}function Vx(e){if(e instanceof Ux)return new Ux(e.h,e.s,e.l,e.opacity);if(e instanceof ux||(e=Ox(e)),!e)return new Ux;if(e instanceof Ux)return e;e=e.rgb();var t=e.r/255,n=e.g/255,r=e.b/255,i=Math.min(t,n,r),a=Math.max(t,n,r),o=NaN,s=a-i,c=(a+i)/2;return s?(o=t===a?(n-r)/s+(n0&&c<1?0:o,new Ux(o,s,c,e.opacity)}function Hx(e,t,n,r){return arguments.length===1?Vx(e):new Ux(e,t,n,r??1)}function Ux(e,t,n,r){this.h=+e,this.s=+t,this.l=+n,this.opacity=+r}cx(Ux,Hx,lx(ux,{brighter(e){return e=e==null?fx:fx**+e,new Ux(this.h,this.s,this.l*e,this.opacity)},darker(e){return e=e==null?dx:dx**+e,new Ux(this.h,this.s,this.l*e,this.opacity)},rgb(){var e=this.h%360+(this.h<0)*360,t=isNaN(e)||isNaN(this.s)?0:this.s,n=this.l,r=n+(n<.5?n:1-n)*t,i=2*n-r;return new Nx(Kx(e>=240?e-240:e+120,i,r),Kx(e,i,r),Kx(e<120?e+240:e-120,i,r),this.opacity)},clamp(){return new Ux(Wx(this.h),Gx(this.s),Gx(this.l),Lx(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){let e=Lx(this.opacity);return`${e===1?`hsl(`:`hsla(`}${Wx(this.h)}, ${Gx(this.s)*100}%, ${Gx(this.l)*100}%${e===1?`)`:`, ${e})`}`}}));function Wx(e){return e=(e||0)%360,e<0?e+360:e}function Gx(e){return Math.max(0,Math.min(1,e||0))}function Kx(e,t,n){return(e<60?t+(n-t)*e/60:e<180?n:e<240?t+(n-t)*(240-e)/60:t)*255}var qx=e=>()=>e;function Jx(e,t){return function(n){return e+n*t}}function Yx(e,t,n){return e**=+n,t=t**+n-e,n=1/n,function(r){return(e+r*t)**+n}}function Xx(e){return(e=+e)==1?Zx:function(t,n){return n-t?Yx(t,n,e):qx(isNaN(t)?n:t)}}function Zx(e,t){var n=t-e;return n?Jx(e,n):qx(isNaN(e)?t:e)}var Qx=(function e(t){var n=Xx(t);function r(e,t){var r=n((e=Mx(e)).r,(t=Mx(t)).r),i=n(e.g,t.g),a=n(e.b,t.b),o=Zx(e.opacity,t.opacity);return function(t){return e.r=r(t),e.g=i(t),e.b=a(t),e.opacity=o(t),e+``}}return r.gamma=e,r})(1);function $x(e,t){t||=[];var n=e?Math.min(t.length,e.length):0,r=t.slice(),i;return function(a){for(i=0;in&&(a=t.slice(n,a),s[o]?s[o]+=a:s[++o]=a),(r=r[0])===(i=i[0])?s[o]?s[o]+=i:s[++o]=i:(s[++o]=null,c.push({i:o,x:rS(r,i)})),n=oS.lastIndex;return n180?t+=360:t-e>180&&(e+=360),a.push({i:n.push(i(n)+`rotate(`,null,r)-2,x:rS(e,t)}))}function s(e,t,n,a){e===t?t&&n.push(i(n)+`skewX(`+t+r):a.push({i:n.push(i(n)+`skewX(`,null,r)-2,x:rS(e,t)})}function c(e,t,n,r,a,o){if(e!==n||t!==r){var s=a.push(i(a)+`scale(`,null,`,`,null,`)`);o.push({i:s-4,x:rS(e,n)},{i:s-2,x:rS(t,r)})}else (n!==1||r!==1)&&a.push(i(a)+`scale(`+n+`,`+r+`)`)}return function(t,n){var r=[],i=[];return t=e(t),n=e(n),a(t.translateX,t.translateY,n.translateX,n.translateY,r,i),o(t.rotate,n.rotate,r,i),s(t.skewX,n.skewX,r,i),c(t.scaleX,t.scaleY,n.scaleX,n.scaleY,r,i),t=n=null,function(e){for(var t=-1,n=i.length,a;++t=0&&e._call.call(void 0,t),e=e._next;--TS}function VS(){MS=(jS=PS.now())+NS,TS=ES=0;try{BS()}finally{TS=0,US(),MS=0}}function HS(){var e=PS.now(),t=e-jS;t>OS&&(NS-=t,jS=e)}function US(){for(var e,t=kS,n,r=1/0;t;)t._call?(r>t._time&&(r=t._time),e=t,t=t._next):(n=t._next,t._next=null,t=e?e._next=n:kS=n);AS=e,WS(r)}function WS(e){TS||(ES&&=clearTimeout(ES),e-MS>24?(e<1/0&&(ES=setTimeout(VS,e-PS.now()-NS)),DS&&=clearInterval(DS)):(DS||=(jS=PS.now(),setInterval(HS,OS)),TS=1,FS(VS)))}function GS(e,t,n){var r=new RS;return t=t==null?0:+t,r.restart(n=>{r.stop(),e(n+t)},t,n),r}var KS=Uv(`start`,`end`,`cancel`,`interrupt`),qS=[];function JS(e,t,n,r,i,a){var o=e.__transition;if(!o)e.__transition={};else if(n in o)return;QS(e,n,{name:t,index:r,group:i,on:KS,tween:qS,time:a.time,delay:a.delay,duration:a.duration,ease:a.ease,timer:null,state:0})}function YS(e,t){var n=ZS(e,t);if(n.state>0)throw Error(`too late; already scheduled`);return n}function XS(e,t){var n=ZS(e,t);if(n.state>3)throw Error(`too late; already running`);return n}function ZS(e,t){var n=e.__transition;if(!n||!(n=n[t]))throw Error(`transition not found`);return n}function QS(e,t,n){var r=e.__transition,i;r[t]=n,n.timer=zS(a,0,n.time);function a(e){n.state=1,n.timer.restart(o,n.delay,n.time),n.delay<=e&&o(e-n.delay)}function o(a){var l,u,d,f;if(n.state!==1)return c();for(l in r)if(f=r[l],f.name===n.name){if(f.state===3)return GS(o);f.state===4?(f.state=6,f.timer.stop(),f.on.call(`interrupt`,e,e.__data__,f.index,f.group),delete r[l]):+l2&&r.state<5,r.state=6,r.timer.stop(),r.on.call(i?`interrupt`:`cancel`,e,e.__data__,r.index,r.group),delete n[o]}a&&delete e.__transition}}function eC(e){return this.each(function(){$S(this,e)})}function tC(e,t){var n,r;return function(){var i=XS(this,e),a=i.tween;if(a!==n){r=n=a;for(var o=0,s=r.length;o=0&&(e=e.slice(0,t)),!e||e===`start`})}function jC(e,t,n){var r,i,a=AC(t)?YS:XS;return function(){var o=a(this,e),s=o.on;s!==r&&(i=(r=s).copy()).on(t,n),o.on=i}}function MC(e,t){var n=this._id;return arguments.length<2?ZS(this.node(),n).on.on(e):this.each(jC(n,e,t))}function NC(e){return function(){var t=this.parentNode;for(var n in this.__transition)if(+n!==e)return;t&&t.removeChild(this)}}function PC(){return this.on(`end.remove`,NC(this._id))}function FC(e){var t=this._name,n=this._id;typeof e!=`function`&&(e=ey(e));for(var r=this._groups,i=r.length,a=Array(i),o=0;o()=>e;function fw(e,{sourceEvent:t,target:n,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:e,enumerable:!0,configurable:!0},sourceEvent:{value:t,enumerable:!0,configurable:!0},target:{value:n,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function pw(e,t,n){this.k=e,this.x=t,this.y=n}pw.prototype={constructor:pw,scale:function(e){return e===1?this:new pw(this.k*e,this.x,this.y)},translate:function(e,t){return e===0&t===0?this:new pw(this.k,this.x+this.k*e,this.y+this.k*t)},apply:function(e){return[e[0]*this.k+this.x,e[1]*this.k+this.y]},applyX:function(e){return e*this.k+this.x},applyY:function(e){return e*this.k+this.y},invert:function(e){return[(e[0]-this.x)/this.k,(e[1]-this.y)/this.k]},invertX:function(e){return(e-this.x)/this.k},invertY:function(e){return(e-this.y)/this.k},rescaleX:function(e){return e.copy().domain(e.range().map(this.invertX,this).map(e.invert,e))},rescaleY:function(e){return e.copy().domain(e.range().map(this.invertY,this).map(e.invert,e))},toString:function(){return`translate(`+this.x+`,`+this.y+`) scale(`+this.k+`)`}};var mw=new pw(1,0,0);hw.prototype=pw.prototype;function hw(e){for(;!e.__zoom;)if(!(e=e.parentNode))return mw;return e.__zoom}function gw(e){e.stopImmediatePropagation()}function _w(e){e.preventDefault(),e.stopImmediatePropagation()}function vw(e){return(!e.ctrlKey||e.type===`wheel`)&&!e.button}function yw(){var e=this;return e instanceof SVGElement?(e=e.ownerSVGElement||e,e.hasAttribute(`viewBox`)?(e=e.viewBox.baseVal,[[e.x,e.y],[e.x+e.width,e.y+e.height]]):[[0,0],[e.width.baseVal.value,e.height.baseVal.value]]):[[0,0],[e.clientWidth,e.clientHeight]]}function bw(){return this.__zoom||mw}function xw(e){return-e.deltaY*(e.deltaMode===1?.05:e.deltaMode?1:.002)*(e.ctrlKey?10:1)}function Sw(){return navigator.maxTouchPoints||`ontouchstart`in this}function Cw(e,t,n){var r=e.invertX(t[0][0])-n[0][0],i=e.invertX(t[1][0])-n[1][0],a=e.invertY(t[0][1])-n[0][1],o=e.invertY(t[1][1])-n[1][1];return e.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),o>a?(a+o)/2:Math.min(0,a)||Math.max(0,o))}function ww(){var e=vw,t=yw,n=Cw,r=xw,i=Sw,a=[0,1/0],o=[[-1/0,-1/0],[1/0,1/0]],s=250,c=wS,l=Uv(`start`,`zoom`,`end`),u,d,f,p=500,m=150,h=0,g=10;function _(e){e.property(`__zoom`,bw).on(`wheel.zoom`,w,{passive:!1}).on(`mousedown.zoom`,T).on(`dblclick.zoom`,E).filter(i).on(`touchstart.zoom`,D).on(`touchmove.zoom`,O).on(`touchend.zoom touchcancel.zoom`,k).style(`-webkit-tap-highlight-color`,`rgba(0,0,0,0)`)}_.transform=function(e,t,n,r){var i=e.selection?e.selection():e;i.property(`__zoom`,bw),e===i?i.interrupt().each(function(){S(this,arguments).event(r).start().zoom(null,typeof t==`function`?t.apply(this,arguments):t).end()}):x(e,t,n,r)},_.scaleBy=function(e,t,n,r){_.scaleTo(e,function(){return this.__zoom.k*(typeof t==`function`?t.apply(this,arguments):t)},n,r)},_.scaleTo=function(e,r,i,a){_.transform(e,function(){var e=t.apply(this,arguments),a=this.__zoom,s=i==null?b(e):typeof i==`function`?i.apply(this,arguments):i,c=a.invert(s),l=typeof r==`function`?r.apply(this,arguments):r;return n(y(v(a,l),s,c),e,o)},i,a)},_.translateBy=function(e,r,i,a){_.transform(e,function(){return n(this.__zoom.translate(typeof r==`function`?r.apply(this,arguments):r,typeof i==`function`?i.apply(this,arguments):i),t.apply(this,arguments),o)},null,a)},_.translateTo=function(e,r,i,a,s){_.transform(e,function(){var e=t.apply(this,arguments),s=this.__zoom,c=a==null?b(e):typeof a==`function`?a.apply(this,arguments):a;return n(mw.translate(c[0],c[1]).scale(s.k).translate(typeof r==`function`?-r.apply(this,arguments):-r,typeof i==`function`?-i.apply(this,arguments):-i),e,o)},a,s)};function v(e,t){return t=Math.max(a[0],Math.min(a[1],t)),t===e.k?e:new pw(t,e.x,e.y)}function y(e,t,n){var r=t[0]-n[0]*e.k,i=t[1]-n[1]*e.k;return r===e.x&&i===e.y?e:new pw(e.k,r,i)}function b(e){return[(+e[0][0]+ +e[1][0])/2,(+e[0][1]+ +e[1][1])/2]}function x(e,n,r,i){e.on(`start.zoom`,function(){S(this,arguments).event(i).start()}).on(`interrupt.zoom end.zoom`,function(){S(this,arguments).event(i).end()}).tween(`zoom`,function(){var e=this,a=arguments,o=S(e,a).event(i),s=t.apply(e,a),l=r==null?b(s):typeof r==`function`?r.apply(e,a):r,u=Math.max(s[1][0]-s[0][0],s[1][1]-s[0][1]),d=e.__zoom,f=typeof n==`function`?n.apply(e,a):n,p=c(d.invert(l).concat(u/d.k),f.invert(l).concat(u/f.k));return function(e){if(e===1)e=f;else{var t=p(e),n=u/t[2];e=new pw(n,l[0]-t[0]*n,l[1]-t[1]*n)}o.zoom(null,e)}})}function S(e,t,n){return!n&&e.__zooming||new C(e,t)}function C(e,n){this.that=e,this.args=n,this.active=0,this.sourceEvent=null,this.extent=t.apply(e,n),this.taps=0}C.prototype={event:function(e){return e&&(this.sourceEvent=e),this},start:function(){return++this.active===1&&(this.that.__zooming=this,this.emit(`start`)),this},zoom:function(e,t){return this.mouse&&e!==`mouse`&&(this.mouse[1]=t.invert(this.mouse[0])),this.touch0&&e!==`touch`&&(this.touch0[1]=t.invert(this.touch0[0])),this.touch1&&e!==`touch`&&(this.touch1[1]=t.invert(this.touch1[0])),this.that.__zoom=t,this.emit(`zoom`),this},end:function(){return--this.active===0&&(delete this.that.__zooming,this.emit(`end`)),this},emit:function(e){var t=Kb(this.that).datum();l.call(e,this.that,new fw(e,{sourceEvent:this.sourceEvent,target:_,type:e,transform:this.that.__zoom,dispatch:l}),t)}};function w(t,...i){if(!e.apply(this,arguments))return;var s=S(this,i).event(t),c=this.__zoom,l=Math.max(a[0],Math.min(a[1],c.k*2**r.apply(this,arguments))),u=Jb(t);if(s.wheel)(s.mouse[0][0]!==u[0]||s.mouse[0][1]!==u[1])&&(s.mouse[1]=c.invert(s.mouse[0]=u)),clearTimeout(s.wheel);else if(c.k===l)return;else s.mouse=[u,c.invert(u)],$S(this),s.start();_w(t),s.wheel=setTimeout(d,m),s.zoom(`mouse`,n(y(v(c,l),s.mouse[0],s.mouse[1]),s.extent,o));function d(){s.wheel=null,s.end()}}function T(t,...r){if(f||!e.apply(this,arguments))return;var i=t.currentTarget,a=S(this,r,!0).event(t),s=Kb(t.view).on(`mousemove.zoom`,d,!0).on(`mouseup.zoom`,p,!0),c=Jb(t,i),l=t.clientX,u=t.clientY;$b(t.view),gw(t),a.mouse=[c,this.__zoom.invert(c)],$S(this),a.start();function d(e){if(_w(e),!a.moved){var t=e.clientX-l,r=e.clientY-u;a.moved=t*t+r*r>h}a.event(e).zoom(`mouse`,n(y(a.that.__zoom,a.mouse[0]=Jb(e,i),a.mouse[1]),a.extent,o))}function p(e){s.on(`mousemove.zoom mouseup.zoom`,null),ex(e.view,a.moved),_w(e),a.event(e).end()}}function E(r,...i){if(e.apply(this,arguments)){var a=this.__zoom,c=Jb(r.changedTouches?r.changedTouches[0]:r,this),l=a.invert(c),u=a.k*(r.shiftKey?.5:2),d=n(y(v(a,u),c,l),t.apply(this,i),o);_w(r),s>0?Kb(this).transition().duration(s).call(x,d,c,r):Kb(this).call(_.transform,d,c,r)}}function D(t,...n){if(e.apply(this,arguments)){var r=t.touches,i=r.length,a=S(this,n,t.changedTouches.length===i).event(t),o,s,c,l;for(gw(t),s=0;s`[React Flow]: Seems like you have not used zustand provider as an ancestor. Help: https://reactflow.dev/error#001`,error002:()=>`It looks like you've created a new nodeTypes or edgeTypes object. If this wasn't on purpose please define the nodeTypes/edgeTypes outside of the component or memoize them.`,error003:e=>`Node type "${e}" not found. Using fallback type "default".`,error004:()=>`The React Flow parent container needs a width and a height to render the graph.`,error005:()=>`Only child nodes can use a parent extent.`,error006:()=>`Can't create edge. An edge needs a source and a target.`,error007:e=>`The old edge with id=${e} does not exist.`,error009:e=>`Marker type "${e}" doesn't exist.`,error008:(e,{id:t,sourceHandle:n,targetHandle:r})=>`Couldn't create edge for ${e} handle id: "${e===`source`?n:r}", edge id: ${t}.`,error010:()=>`Handle: No node id found. Make sure to only use a Handle inside a custom Node.`,error011:e=>`Edge type "${e}" not found. Using fallback type "default".`,error012:e=>`Node with id "${e}" does not exist, it may have been removed. This can happen when a node is deleted before the "onNodeClick" handler is called.`,error013:(e=`react`)=>`It seems that you haven't loaded the styles. Please import '@xyflow/${e}/dist/style.css' or base.css to make sure everything is working properly.`,error014:()=>`useNodeConnections: No node ID found. Call useNodeConnections inside a custom Node or provide a node ID.`,error015:()=>`It seems that you are trying to drag a node that is not initialized. Please use onNodesChange as explained in the docs.`},Ew=[[-1/0,-1/0],[1/0,1/0]],Dw=[`Enter`,` `,`Escape`],Ow={"node.a11yDescription.default":`Press enter or space to select a node. Press delete to remove it and escape to cancel.`,"node.a11yDescription.keyboardDisabled":`Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.`,"node.a11yDescription.ariaLiveMessage":({direction:e,x:t,y:n})=>`Moved selected node ${e}. New position, x: ${t}, y: ${n}`,"edge.a11yDescription.default":`Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.`,"controls.ariaLabel":`Control Panel`,"controls.zoomIn.ariaLabel":`Zoom In`,"controls.zoomOut.ariaLabel":`Zoom Out`,"controls.fitView.ariaLabel":`Fit View`,"controls.interactive.ariaLabel":`Toggle Interactivity`,"minimap.ariaLabel":`Mini Map`,"handle.ariaLabel":`Handle`},kw;(function(e){e.Strict=`strict`,e.Loose=`loose`})(kw||={});var Aw;(function(e){e.Free=`free`,e.Vertical=`vertical`,e.Horizontal=`horizontal`})(Aw||={});var jw;(function(e){e.Partial=`partial`,e.Full=`full`})(jw||={});var Mw={inProgress:!1,isValid:null,from:null,fromHandle:null,fromPosition:null,fromNode:null,to:null,toHandle:null,toPosition:null,toNode:null},Nw;(function(e){e.Bezier=`default`,e.Straight=`straight`,e.Step=`step`,e.SmoothStep=`smoothstep`,e.SimpleBezier=`simplebezier`})(Nw||={});var Pw;(function(e){e.Arrow=`arrow`,e.ArrowClosed=`arrowclosed`})(Pw||={});var $;(function(e){e.Left=`left`,e.Top=`top`,e.Right=`right`,e.Bottom=`bottom`})($||={});var Fw={[$.Left]:$.Right,[$.Right]:$.Left,[$.Top]:$.Bottom,[$.Bottom]:$.Top};function Iw(e){return e===null?null:e?`valid`:`invalid`}var Lw=e=>`id`in e&&`source`in e&&`target`in e,Rw=e=>`id`in e&&`position`in e&&!(`source`in e)&&!(`target`in e),zw=e=>`id`in e&&`internals`in e&&!(`source`in e)&&!(`target`in e),Bw=(e,t=[0,0])=>{let{width:n,height:r}=yT(e),i=e.origin??t,a=n*i[0],o=r*i[1];return{x:e.position.x-a,y:e.position.y-o}},Vw=(e,t={nodeOrigin:[0,0]})=>e.length===0?{x:0,y:0,width:0,height:0}:nT(e.reduce((e,n)=>{let r=typeof n==`string`,i=!t.nodeLookup&&!r?n:void 0;return t.nodeLookup&&(i=r?t.nodeLookup.get(n):zw(n)?n:t.nodeLookup.get(n.id)),eT(e,i?iT(i,t.nodeOrigin):{x:0,y:0,x2:0,y2:0})},{x:1/0,y:1/0,x2:-1/0,y2:-1/0})),Hw=(e,t={})=>{if(e.size===0)return{x:0,y:0,width:0,height:0};let n={x:1/0,y:1/0,x2:-1/0,y2:-1/0};return e.forEach(e=>{if(t.filter===void 0||t.filter(e)){let t=iT(e);n=eT(n,t)}}),nT(n)},Uw=(e,t,[n,r,i]=[0,0,1],a=!1,o=!1)=>{let s={...dT(t,[n,r,i]),width:t.width/i,height:t.height/i},c=[];for(let t of e.values()){let{measured:e,selectable:n=!0,hidden:r=!1}=t;if(o&&!n||r)continue;let i=e.width??t.width??t.initialWidth??null,l=e.height??t.height??t.initialHeight??null,u=oT(s,rT(t)),d=(i??0)*(l??0),f=a&&u>0;(!t.internals.handleBounds||f||u>=d||t.dragging)&&c.push(t)}return c},Ww=(e,t)=>{let n=new Set;return e.forEach(e=>{n.add(e.id)}),t.filter(e=>n.has(e.source)||n.has(e.target))};function Gw(e,t){let n=new Map,r=t?.nodes?new Set(t.nodes.map(e=>e.id)):null;return e.forEach(e=>{e.measured.width&&e.measured.height&&(t?.includeHiddenNodes||!e.hidden)&&(!r||r.has(e.id))&&n.set(e.id,e)}),n}async function Kw({nodes:e,width:t,height:n,panZoom:r,minZoom:i,maxZoom:a},o){if(e.size===0)return Promise.resolve(!0);let s=gT(Hw(Gw(e,o)),t,n,o?.minZoom??i,o?.maxZoom??a,o?.padding??.1);return await r.setViewport(s,{duration:o?.duration,ease:o?.ease,interpolate:o?.interpolate}),Promise.resolve(!0)}function qw({nodeId:e,nextPosition:t,nodeLookup:n,nodeOrigin:r=[0,0],nodeExtent:i,onError:a}){let o=n.get(e),s=o.parentId?n.get(o.parentId):void 0,{x:c,y:l}=s?s.internals.positionAbsolute:{x:0,y:0},u=o.origin??r,d=o.extent||i;if(o.extent===`parent`&&!o.expandParent)if(!s)a?.(`005`,Tw.error005());else{let e=s.measured.width,t=s.measured.height;e&&t&&(d=[[c,l],[c+e,l+t]])}else s&&vT(o.extent)&&(d=[[o.extent[0][0]+c,o.extent[0][1]+l],[o.extent[1][0]+c,o.extent[1][1]+l]]);let f=vT(d)?Xw(t,d,o.measured):t;return(o.measured.width===void 0||o.measured.height===void 0)&&a?.(`015`,Tw.error015()),{position:{x:f.x-c+(o.measured.width??0)*u[0],y:f.y-l+(o.measured.height??0)*u[1]},positionAbsolute:f}}async function Jw({nodesToRemove:e=[],edgesToRemove:t=[],nodes:n,edges:r,onBeforeDelete:i}){let a=new Set(e.map(e=>e.id)),o=[];for(let e of n){if(e.deletable===!1)continue;let t=a.has(e.id),n=!t&&e.parentId&&o.find(t=>t.id===e.parentId);(t||n)&&o.push(e)}let s=new Set(t.map(e=>e.id)),c=r.filter(e=>e.deletable!==!1),l=Ww(o,c);for(let e of c)s.has(e.id)&&!l.find(t=>t.id===e.id)&&l.push(e);if(!i)return{edges:l,nodes:o};let u=await i({nodes:o,edges:l});return typeof u==`boolean`?u?{edges:l,nodes:o}:{edges:[],nodes:[]}:u}var Yw=(e,t=0,n=1)=>Math.min(Math.max(e,t),n),Xw=(e={x:0,y:0},t,n)=>({x:Yw(e.x,t[0][0],t[1][0]-(n?.width??0)),y:Yw(e.y,t[0][1],t[1][1]-(n?.height??0))});function Zw(e,t,n){let{width:r,height:i}=yT(n),{x:a,y:o}=n.internals.positionAbsolute;return Xw(e,[[a,o],[a+r,o+i]],t)}var Qw=(e,t,n)=>en?-Yw(Math.abs(e-n),1,t)/t:0,$w=(e,t,n=15,r=40)=>[Qw(e.x,r,t.width-r)*n,Qw(e.y,r,t.height-r)*n],eT=(e,t)=>({x:Math.min(e.x,t.x),y:Math.min(e.y,t.y),x2:Math.max(e.x2,t.x2),y2:Math.max(e.y2,t.y2)}),tT=({x:e,y:t,width:n,height:r})=>({x:e,y:t,x2:e+n,y2:t+r}),nT=({x:e,y:t,x2:n,y2:r})=>({x:e,y:t,width:n-e,height:r-t}),rT=(e,t=[0,0])=>{let{x:n,y:r}=zw(e)?e.internals.positionAbsolute:Bw(e,t);return{x:n,y:r,width:e.measured?.width??e.width??e.initialWidth??0,height:e.measured?.height??e.height??e.initialHeight??0}},iT=(e,t=[0,0])=>{let{x:n,y:r}=zw(e)?e.internals.positionAbsolute:Bw(e,t);return{x:n,y:r,x2:n+(e.measured?.width??e.width??e.initialWidth??0),y2:r+(e.measured?.height??e.height??e.initialHeight??0)}},aT=(e,t)=>nT(eT(tT(e),tT(t))),oT=(e,t)=>{let n=Math.max(0,Math.min(e.x+e.width,t.x+t.width)-Math.max(e.x,t.x)),r=Math.max(0,Math.min(e.y+e.height,t.y+t.height)-Math.max(e.y,t.y));return Math.ceil(n*r)},sT=e=>cT(e.width)&&cT(e.height)&&cT(e.x)&&cT(e.y),cT=e=>!isNaN(e)&&isFinite(e),lT=(e,t)=>{},uT=(e,t=[1,1])=>({x:t[0]*Math.round(e.x/t[0]),y:t[1]*Math.round(e.y/t[1])}),dT=({x:e,y:t},[n,r,i],a=!1,o=[1,1])=>{let s={x:(e-n)/i,y:(t-r)/i};return a?uT(s,o):s},fT=({x:e,y:t},[n,r,i])=>({x:e*i+n,y:t*i+r});function pT(e,t){if(typeof e==`number`)return Math.floor((t-t/(1+e))*.5);if(typeof e==`string`&&e.endsWith(`px`)){let t=parseFloat(e);if(!Number.isNaN(t))return Math.floor(t)}if(typeof e==`string`&&e.endsWith(`%`)){let n=parseFloat(e);if(!Number.isNaN(n))return Math.floor(t*n*.01)}return console.error(`[React Flow] The padding value "${e}" is invalid. Please provide a number or a string with a valid unit (px or %).`),0}function mT(e,t,n){if(typeof e==`string`||typeof e==`number`){let r=pT(e,n),i=pT(e,t);return{top:r,right:i,bottom:r,left:i,x:i*2,y:r*2}}if(typeof e==`object`){let r=pT(e.top??e.y??0,n),i=pT(e.bottom??e.y??0,n),a=pT(e.left??e.x??0,t),o=pT(e.right??e.x??0,t);return{top:r,right:o,bottom:i,left:a,x:a+o,y:r+i}}return{top:0,right:0,bottom:0,left:0,x:0,y:0}}function hT(e,t,n,r,i,a){let{x:o,y:s}=fT(e,[t,n,r]),{x:c,y:l}=fT({x:e.x+e.width,y:e.y+e.height},[t,n,r]),u=i-c,d=a-l;return{left:Math.floor(o),top:Math.floor(s),right:Math.floor(u),bottom:Math.floor(d)}}var gT=(e,t,n,r,i,a)=>{let o=mT(a,t,n),s=(t-o.x)/e.width,c=(n-o.y)/e.height,l=Yw(Math.min(s,c),r,i),u=e.x+e.width/2,d=e.y+e.height/2,f=t/2-u*l,p=n/2-d*l,m=hT(e,f,p,l,t,n),h={left:Math.min(m.left-o.left,0),top:Math.min(m.top-o.top,0),right:Math.min(m.right-o.right,0),bottom:Math.min(m.bottom-o.bottom,0)};return{x:f-h.left+h.right,y:p-h.top+h.bottom,zoom:l}},_T=()=>typeof navigator<`u`&&navigator?.userAgent?.indexOf(`Mac`)>=0;function vT(e){return e!=null&&e!==`parent`}function yT(e){return{width:e.measured?.width??e.width??e.initialWidth??0,height:e.measured?.height??e.height??e.initialHeight??0}}function bT(e){return(e.measured?.width??e.width??e.initialWidth)!==void 0&&(e.measured?.height??e.height??e.initialHeight)!==void 0}function xT(e,t={width:0,height:0},n,r,i){let a={...e},o=r.get(n);if(o){let e=o.origin||i;a.x+=o.internals.positionAbsolute.x-(t.width??0)*e[0],a.y+=o.internals.positionAbsolute.y-(t.height??0)*e[1]}return a}function ST(e,t){if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0}function CT(){let e,t;return{promise:new Promise((n,r)=>{e=n,t=r}),resolve:e,reject:t}}function wT(e){return{...Ow,...e||{}}}function TT(e,{snapGrid:t=[0,0],snapToGrid:n=!1,transform:r,containerBounds:i}){let{x:a,y:o}=jT(e),s=dT({x:a-(i?.left??0),y:o-(i?.top??0)},r),{x:c,y:l}=n?uT(s,t):s;return{xSnapped:c,ySnapped:l,...s}}var ET=e=>({width:e.offsetWidth,height:e.offsetHeight}),DT=e=>e?.getRootNode?.()||window?.document,OT=[`INPUT`,`SELECT`,`TEXTAREA`];function kT(e){let t=e.composedPath?.()?.[0]||e.target;return t?.nodeType===1?OT.includes(t.nodeName)||t.hasAttribute(`contenteditable`)||!!t.closest(`.nokey`):!1}var AT=e=>`clientX`in e,jT=(e,t)=>{let n=AT(e),r=n?e.clientX:e.touches?.[0].clientX,i=n?e.clientY:e.touches?.[0].clientY;return{x:r-(t?.left??0),y:i-(t?.top??0)}},MT=(e,t,n,r,i)=>{let a=t.querySelectorAll(`.${e}`);return!a||!a.length?null:Array.from(a).map(t=>{let a=t.getBoundingClientRect();return{id:t.getAttribute(`data-handleid`),type:e,nodeId:i,position:t.getAttribute(`data-handlepos`),x:(a.left-n.left)/r,y:(a.top-n.top)/r,...ET(t)}})};function NT({sourceX:e,sourceY:t,targetX:n,targetY:r,sourceControlX:i,sourceControlY:a,targetControlX:o,targetControlY:s}){let c=e*.125+i*.375+o*.375+n*.125,l=t*.125+a*.375+s*.375+r*.125;return[c,l,Math.abs(c-e),Math.abs(l-t)]}function PT(e,t){return e>=0?.5*e:t*25*Math.sqrt(-e)}function FT({pos:e,x1:t,y1:n,x2:r,y2:i,c:a}){switch(e){case $.Left:return[t-PT(t-r,a),n];case $.Right:return[t+PT(r-t,a),n];case $.Top:return[t,n-PT(n-i,a)];case $.Bottom:return[t,n+PT(i-n,a)]}}function IT({sourceX:e,sourceY:t,sourcePosition:n=$.Bottom,targetX:r,targetY:i,targetPosition:a=$.Top,curvature:o=.25}){let[s,c]=FT({pos:n,x1:e,y1:t,x2:r,y2:i,c:o}),[l,u]=FT({pos:a,x1:r,y1:i,x2:e,y2:t,c:o}),[d,f,p,m]=NT({sourceX:e,sourceY:t,targetX:r,targetY:i,sourceControlX:s,sourceControlY:c,targetControlX:l,targetControlY:u});return[`M${e},${t} C${s},${c} ${l},${u} ${r},${i}`,d,f,p,m]}function LT({sourceX:e,sourceY:t,targetX:n,targetY:r}){let i=Math.abs(n-e)/2,a=n0}var BT=({source:e,sourceHandle:t,target:n,targetHandle:r})=>`xy-edge__${e}${t||``}-${n}${r||``}`,VT=(e,t)=>t.some(t=>t.source===e.source&&t.target===e.target&&(t.sourceHandle===e.sourceHandle||!t.sourceHandle&&!e.sourceHandle)&&(t.targetHandle===e.targetHandle||!t.targetHandle&&!e.targetHandle)),HT=(e,t)=>{if(!e.source||!e.target)return Tw.error006(),t;let n;return n=Lw(e)?{...e}:{...e,id:BT(e)},VT(n,t)?t:(n.sourceHandle===null&&delete n.sourceHandle,n.targetHandle===null&&delete n.targetHandle,t.concat(n))};function UT({sourceX:e,sourceY:t,targetX:n,targetY:r}){let[i,a,o,s]=LT({sourceX:e,sourceY:t,targetX:n,targetY:r});return[`M ${e},${t}L ${n},${r}`,i,a,o,s]}var WT={[$.Left]:{x:-1,y:0},[$.Right]:{x:1,y:0},[$.Top]:{x:0,y:-1},[$.Bottom]:{x:0,y:1}},GT=({source:e,sourcePosition:t=$.Bottom,target:n})=>t===$.Left||t===$.Right?e.xMath.sqrt((t.x-e.x)**2+(t.y-e.y)**2);function qT({source:e,sourcePosition:t=$.Bottom,target:n,targetPosition:r=$.Top,center:i,offset:a,stepPosition:o}){let s=WT[t],c=WT[r],l={x:e.x+s.x*a,y:e.y+s.y*a},u={x:n.x+c.x*a,y:n.y+c.y*a},d=GT({source:l,sourcePosition:t,target:u}),f=d.x===0?`y`:`x`,p=d[f],m=[],h,g,_={x:0,y:0},v={x:0,y:0},[,,y,b]=LT({sourceX:e.x,sourceY:e.y,targetX:n.x,targetY:n.y});if(s[f]*c[f]===-1){f===`x`?(h=i.x??l.x+(u.x-l.x)*o,g=i.y??(l.y+u.y)/2):(h=i.x??(l.x+u.x)/2,g=i.y??l.y+(u.y-l.y)*o);let e=[{x:h,y:l.y},{x:h,y:u.y}],t=[{x:l.x,y:g},{x:u.x,y:g}];m=s[f]===p?f===`x`?e:t:f===`x`?t:e}else{let i=[{x:l.x,y:u.y}],o=[{x:u.x,y:l.y}];if(m=f===`x`?s.x===p?o:i:s.y===p?i:o,t===r){let t=Math.abs(e[f]-n[f]);if(t<=a){let r=Math.min(a-1,a-t);s[f]===p?_[f]=(l[f]>e[f]?-1:1)*r:v[f]=(u[f]>n[f]?-1:1)*r}}if(t!==r){let e=f===`x`?`y`:`x`,t=s[f]===c[e],n=l[e]>u[e],r=l[e]=Math.max(Math.abs(d.y-m[0].y),Math.abs(y.y-m[0].y))?(h=(d.x+y.x)/2,g=m[0].y):(h=m[0].x,g=(d.y+y.y)/2)}return[[e,{x:l.x+_.x,y:l.y+_.y},...m,{x:u.x+v.x,y:u.y+v.y},n],h,g,y,b]}function JT(e,t,n,r){let i=Math.min(KT(e,t)/2,KT(t,n)/2,r),{x:a,y:o}=t;if(e.x===a&&a===n.x||e.y===o&&o===n.y)return`L${a} ${o}`;if(e.y===o){let t=e.x{let r=``;return r=n>0&&ne.id===t):e[0])||null}function tE(e,t){return e?typeof e==`string`?e:`${t?`${t}__`:``}${Object.keys(e).sort().map(t=>`${t}=${e[t]}`).join(`&`)}`:``}function nE(e,{id:t,defaultColor:n,defaultMarkerStart:r,defaultMarkerEnd:i}){let a=new Set;return e.reduce((e,o)=>([o.markerStart||r,o.markerEnd||i].forEach(r=>{if(r&&typeof r==`object`){let i=tE(r,t);a.has(i)||(e.push({id:i,color:r.color||n,...r}),a.add(i))}}),e),[]).sort((e,t)=>e.id.localeCompare(t.id))}var rE={nodeOrigin:[0,0],nodeExtent:Ew,elevateNodesOnSelect:!0,defaults:{}},iE={...rE,checkEquality:!0};function aE(e,t){let n={...e};for(let e in t)t[e]!==void 0&&(n[e]=t[e]);return n}function oE(e,t,n){let r=aE(rE,n);for(let n of e.values())if(n.parentId)lE(n,e,t,r);else{let e=Xw(Bw(n,r.nodeOrigin),vT(n.extent)?n.extent:r.nodeExtent,yT(n));n.internals.positionAbsolute=e}}function sE(e,t,n,r){let i=aE(iE,r),a=e.length>0,o=new Map(t),s=i?.elevateNodesOnSelect?1e3:0;t.clear(),n.clear();for(let c of e){let e=o.get(c.id);if(i.checkEquality&&c===e?.internals.userNode)t.set(c.id,e);else{let n=Xw(Bw(c,i.nodeOrigin),vT(c.extent)?c.extent:i.nodeExtent,yT(c));e={...i.defaults,...c,measured:{width:c.measured?.width,height:c.measured?.height},internals:{positionAbsolute:n,handleBounds:c.measured?e?.internals.handleBounds:void 0,z:uE(c,s),userNode:c}},t.set(c.id,e)}(e.measured===void 0||e.measured.width===void 0||e.measured.height===void 0)&&!e.hidden&&(a=!1),c.parentId&&lE(e,t,n,r)}return a}function cE(e,t){if(!e.parentId)return;let n=t.get(e.parentId);n?n.set(e.id,e):t.set(e.parentId,new Map([[e.id,e]]))}function lE(e,t,n,r){let{elevateNodesOnSelect:i,nodeOrigin:a,nodeExtent:o}=aE(rE,r),s=e.parentId,c=t.get(s);if(!c){console.warn(`Parent node ${s} not found. Please make sure that parent nodes are in front of their child nodes in the nodes array.`);return}cE(e,n);let{x:l,y:u,z:d}=dE(e,c,a,o,i?1e3:0),{positionAbsolute:f}=e.internals,p=l!==f.x||u!==f.y;(p||d!==e.internals.z)&&t.set(e.id,{...e,internals:{...e.internals,positionAbsolute:p?{x:l,y:u}:f,z:d}})}function uE(e,t){return(cT(e.zIndex)?e.zIndex:0)+(e.selected?t:0)}function dE(e,t,n,r,i){let{x:a,y:o}=t.internals.positionAbsolute,s=yT(e),c=Bw(e,n),l=vT(e.extent)?Xw(c,e.extent,s):c,u=Xw({x:a+l.x,y:o+l.y},r,s);e.extent===`parent`&&(u=Zw(u,s,t));let d=uE(e,i),f=t.internals.z??0;return{x:u.x,y:u.y,z:f>=d?f+1:d}}function fE(e,t,n,r=[0,0]){let i=[],a=new Map;for(let n of e){let e=t.get(n.parentId);if(!e)continue;let r=aT(a.get(n.parentId)?.expandedRect??rT(e),n.rect);a.set(n.parentId,{expandedRect:r,parent:e})}return a.size>0&&a.forEach(({expandedRect:t,parent:a},o)=>{let s=a.internals.positionAbsolute,c=yT(a),l=a.origin??r,u=t.x0||d>0||m||h)&&(i.push({id:o,type:`position`,position:{x:a.position.x-u+m,y:a.position.y-d+h}}),n.get(o)?.forEach(t=>{e.some(e=>e.id===t.id)||i.push({id:t.id,type:`position`,position:{x:t.position.x+u,y:t.position.y+d}})})),(c.width0){let e=fE(d,t,n,i);c.push(...e)}return{changes:c,updatedInternals:s}}async function mE({delta:e,panZoom:t,transform:n,translateExtent:r,width:i,height:a}){if(!t||!e.x&&!e.y)return Promise.resolve(!1);let o=await t.setViewportConstrained({x:n[0]+e.x,y:n[1]+e.y,zoom:n[2]},[[0,0],[i,a]],r),s=!!o&&(o.x!==n[0]||o.y!==n[1]||o.k!==n[2]);return Promise.resolve(s)}function hE(e,t,n,r,i,a){let o=i,s=r.get(o)||new Map;r.set(o,s.set(n,t)),o=`${i}-${e}`;let c=r.get(o)||new Map;if(r.set(o,c.set(n,t)),a){o=`${i}-${e}-${a}`;let s=r.get(o)||new Map;r.set(o,s.set(n,t))}}function gE(e,t,n){e.clear(),t.clear();for(let r of n){let{source:n,target:i,sourceHandle:a=null,targetHandle:o=null}=r,s={edgeId:r.id,source:n,target:i,sourceHandle:a,targetHandle:o},c=`${n}-${a}--${i}-${o}`;hE(`source`,s,`${i}-${o}--${n}-${a}`,e,n,a),hE(`target`,s,c,e,i,o),t.set(r.id,r)}}function _E(e,t){if(!e.parentId)return!1;let n=t.get(e.parentId);return n?n.selected?!0:_E(n,t):!1}function vE(e,t,n){let r=e;do{if(r?.matches?.(t))return!0;if(r===n)return!1;r=r?.parentElement}while(r);return!1}function yE(e,t,n,r){let i=new Map;for(let[a,o]of e)if((o.selected||o.id===r)&&(!o.parentId||!_E(o,e))&&(o.draggable||t&&o.draggable===void 0)){let t=e.get(a);t&&i.set(a,{id:a,position:t.position||{x:0,y:0},distance:{x:n.x-t.internals.positionAbsolute.x,y:n.y-t.internals.positionAbsolute.y},extent:t.extent,parentId:t.parentId,origin:t.origin,expandParent:t.expandParent,internals:{positionAbsolute:t.internals.positionAbsolute||{x:0,y:0}},measured:{width:t.measured.width??0,height:t.measured.height??0}})}return i}function bE({nodeId:e,dragItems:t,nodeLookup:n,dragging:r=!0}){let i=[];for(let[e,a]of t){let t=n.get(e)?.internals.userNode;t&&i.push({...t,position:a.position,dragging:r})}if(!e)return[i[0],i];let a=n.get(e)?.internals.userNode;return[a?{...a,position:t.get(e)?.position||a.position,dragging:r}:i[0],i]}function xE({dragItems:e,snapGrid:t,x:n,y:r}){let i=e.values().next().value;if(!i)return null;let a={x:n-i.distance.x,y:r-i.distance.y},o=uT(a,t);return{x:o.x-a.x,y:o.y-a.y}}function SE({onNodeMouseDown:e,getStoreItems:t,onDragStart:n,onDrag:r,onDragStop:i}){let a={x:null,y:null},o=0,s=new Map,c=!1,l={x:0,y:0},u=null,d=!1,f=null,p=!1,m=!1,h=null;function g({noDragClassName:g,handleSelector:_,domNode:v,isSelectable:y,nodeId:b,nodeClickDistance:x=0}){f=Kb(v);function S({x:e,y:n}){let{nodeLookup:i,nodeExtent:o,snapGrid:c,snapToGrid:l,nodeOrigin:u,onNodeDrag:d,onSelectionDrag:f,onError:p,updateNodePositions:g}=t();a={x:e,y:n};let _=!1,v=s.size>1,y=v&&o?tT(Hw(s)):null,x=v&&l?xE({dragItems:s,snapGrid:c,x:e,y:n}):null;for(let[t,r]of s){if(!i.has(t))continue;let a={x:e-r.distance.x,y:n-r.distance.y};l&&(a=x?{x:Math.round(a.x+x.x),y:Math.round(a.y+x.y)}:uT(a,c));let s=null;if(v&&o&&!r.extent&&y){let{positionAbsolute:e}=r.internals,t=e.x-y.x+o[0][0],n=e.x+r.measured.width-y.x2+o[1][0],i=e.y-y.y+o[0][1],a=e.y+r.measured.height-y.y2+o[1][1];s=[[t,i],[n,a]]}let{position:d,positionAbsolute:f}=qw({nodeId:t,nextPosition:a,nodeLookup:i,nodeExtent:s||o,nodeOrigin:u,onError:p});_=_||r.position.x!==d.x||r.position.y!==d.y,r.position=d,r.internals.positionAbsolute=f}if(m||=_,_&&(g(s,!0),h&&(r||d||!b&&f))){let[e,t]=bE({nodeId:b,dragItems:s,nodeLookup:i});r?.(h,s,e,t),d?.(h,e,t),b||f?.(h,t)}}async function C(){if(!u)return;let{transform:e,panBy:n,autoPanSpeed:r,autoPanOnNodeDrag:i}=t();if(!i){c=!1,cancelAnimationFrame(o);return}let[s,d]=$w(l,u,r);(s!==0||d!==0)&&(a.x=(a.x??0)-s/e[2],a.y=(a.y??0)-d/e[2],await n({x:s,y:d})&&S(a)),o=requestAnimationFrame(C)}function w(r){let{nodeLookup:i,multiSelectionActive:o,nodesDraggable:c,transform:l,snapGrid:f,snapToGrid:p,selectNodesOnDrag:m,onNodeDragStart:h,onSelectionDragStart:g,unselectNodesAndEdges:_}=t();d=!0,(!m||!y)&&!o&&b&&(i.get(b)?.selected||_()),y&&m&&b&&e?.(b);let v=TT(r.sourceEvent,{transform:l,snapGrid:f,snapToGrid:p,containerBounds:u});if(a=v,s=yE(i,c,v,b),s.size>0&&(n||h||!b&&g)){let[e,t]=bE({nodeId:b,dragItems:s,nodeLookup:i});n?.(r.sourceEvent,s,e,t),h?.(r.sourceEvent,e,t),b||g?.(r.sourceEvent,t)}}let T=sx().clickDistance(x).on(`start`,e=>{let{domNode:n,nodeDragThreshold:r,transform:i,snapGrid:o,snapToGrid:s}=t();u=n?.getBoundingClientRect()||null,p=!1,m=!1,h=e.sourceEvent,r===0&&w(e),a=TT(e.sourceEvent,{transform:i,snapGrid:o,snapToGrid:s,containerBounds:u}),l=jT(e.sourceEvent,u)}).on(`drag`,e=>{let{autoPanOnNodeDrag:n,transform:r,snapGrid:i,snapToGrid:o,nodeDragThreshold:f,nodeLookup:m}=t(),g=TT(e.sourceEvent,{transform:r,snapGrid:i,snapToGrid:o,containerBounds:u});if(h=e.sourceEvent,(e.sourceEvent.type===`touchmove`&&e.sourceEvent.touches.length>1||b&&!m.has(b))&&(p=!0),!p){if(!c&&n&&d&&(c=!0,C()),!d){let t=g.xSnapped-(a.x??0),n=g.ySnapped-(a.y??0);Math.sqrt(t*t+n*n)>f&&w(e)}(a.x!==g.xSnapped||a.y!==g.ySnapped)&&s&&d&&(l=jT(e.sourceEvent,u),S(g))}}).on(`end`,e=>{if(!(!d||p)&&(c=!1,d=!1,cancelAnimationFrame(o),s.size>0)){let{nodeLookup:n,updateNodePositions:r,onNodeDragStop:a,onSelectionDragStop:o}=t();if(m&&=(r(s,!1),!1),i||a||!b&&o){let[t,r]=bE({nodeId:b,dragItems:s,nodeLookup:n,dragging:!1});i?.(e.sourceEvent,s,t,r),a?.(e.sourceEvent,t,r),b||o?.(e.sourceEvent,r)}}}).filter(e=>{let t=e.target;return!e.button&&(!g||!vE(t,`.${g}`,v))&&(!_||vE(t,_,v))});f.call(T)}function _(){f?.on(`.drag`,null)}return{update:g,destroy:_}}function CE(e,t,n){let r=[],i={x:e.x-n,y:e.y-n,width:n*2,height:n*2};for(let e of t.values())oT(i,rT(e))>0&&r.push(e);return r}var wE=250;function TE(e,t,n,r){let i=[],a=1/0,o=CE(e,n,t+wE);for(let n of o){let o=[...n.internals.handleBounds?.source??[],...n.internals.handleBounds?.target??[]];for(let s of o){if(r.nodeId===s.nodeId&&r.type===s.type&&r.id===s.id)continue;let{x:o,y:c}=$T(n,s,s.position,!0),l=Math.sqrt((o-e.x)**2+(c-e.y)**2);l>t||(l1){let e=r.type===`source`?`target`:`source`;return i.find(t=>t.type===e)??i[0]}return i[0]}function EE(e,t,n,r,i,a=!1){let o=r.get(e);if(!o)return null;let s=i===`strict`?o.internals.handleBounds?.[t]:[...o.internals.handleBounds?.source??[],...o.internals.handleBounds?.target??[]],c=(n?s?.find(e=>e.id===n):s?.[0])??null;return c&&a?{...c,...$T(o,c,c.position,!0)}:c}function DE(e,t){return e||(t?.classList.contains(`target`)?`target`:t?.classList.contains(`source`)?`source`:null)}function OE(e,t){let n=null;return t?n=!0:e&&!t&&(n=!1),n}var kE=()=>!0;function AE(e,{connectionMode:t,connectionRadius:n,handleId:r,nodeId:i,edgeUpdaterType:a,isTarget:o,domNode:s,nodeLookup:c,lib:l,autoPanOnConnect:u,flowId:d,panBy:f,cancelConnection:p,onConnectStart:m,onConnect:h,onConnectEnd:g,isValidConnection:_=kE,onReconnectEnd:v,updateConnection:y,getTransform:b,getFromHandle:x,autoPanSpeed:S,dragThreshold:C=1,handleDomNode:w}){let T=DT(e.target),E=0,D,{x:O,y:k}=jT(e),A=DE(a,w),j=s?.getBoundingClientRect(),M=!1;if(!j||!A)return;let N=EE(i,A,r,c,t);if(!N)return;let P=jT(e,j),ee=!1,F=null,I=!1,L=null;function R(){if(!u||!j)return;let[e,t]=$w(P,j,S);f({x:e,y:t}),E=requestAnimationFrame(R)}let z={...N,nodeId:i,type:A,position:N.position},B=c.get(i),V={inProgress:!0,isValid:null,from:$T(B,z,$.Left,!0),fromHandle:z,fromPosition:z.position,fromNode:B,to:P,toHandle:null,toPosition:Fw[z.position],toNode:null};function H(){M=!0,y(V),m?.(e,{nodeId:i,handleId:r,handleType:A})}C===0&&H();function U(e){if(!M){let{x:t,y:n}=jT(e),r=t-O,i=n-k;if(!(r*r+i*i>C*C))return;H()}if(!x()||!z){te(e);return}let a=b();P=jT(e,j),D=TE(dT(P,a,!1,[1,1]),n,c,z),ee||=(R(),!0);let s=jE(e,{handle:D,connectionMode:t,fromNodeId:i,fromHandleId:r,fromType:o?`target`:`source`,isValidConnection:_,doc:T,lib:l,flowId:d,nodeLookup:c});L=s.handleDomNode,F=s.connection,I=OE(!!D,s.isValid);let u={...V,isValid:I,to:s.toHandle&&I?fT({x:s.toHandle.x,y:s.toHandle.y},a):P,toHandle:s.toHandle,toPosition:I&&s.toHandle?s.toHandle.position:Fw[z.position],toNode:s.toHandle?c.get(s.toHandle.nodeId):null};I&&D&&V.toHandle&&u.toHandle&&V.toHandle.type===u.toHandle.type&&V.toHandle.nodeId===u.toHandle.nodeId&&V.toHandle.id===u.toHandle.id&&V.to.x===u.to.x&&V.to.y===u.to.y||(y(u),V=u)}function te(e){if(M){(D||L)&&F&&I&&h?.(F);let{inProgress:t,...n}=V,r={...n,toPosition:V.toHandle?V.toPosition:null};g?.(e,r),a&&v?.(e,r)}p(),cancelAnimationFrame(E),ee=!1,I=!1,F=null,L=null,T.removeEventListener(`mousemove`,U),T.removeEventListener(`mouseup`,te),T.removeEventListener(`touchmove`,U),T.removeEventListener(`touchend`,te)}T.addEventListener(`mousemove`,U),T.addEventListener(`mouseup`,te),T.addEventListener(`touchmove`,U),T.addEventListener(`touchend`,te)}function jE(e,{handle:t,connectionMode:n,fromNodeId:r,fromHandleId:i,fromType:a,doc:o,lib:s,flowId:c,isValidConnection:l=kE,nodeLookup:u}){let d=a===`target`,f=t?o.querySelector(`.${s}-flow__handle[data-id="${c}-${t?.nodeId}-${t?.id}-${t?.type}"]`):null,{x:p,y:m}=jT(e),h=o.elementFromPoint(p,m),g=h?.classList.contains(`${s}-flow__handle`)?h:f,_={handleDomNode:g,isValid:!1,connection:null,toHandle:null};if(g){let e=DE(void 0,g),t=g.getAttribute(`data-nodeid`),a=g.getAttribute(`data-handleid`),o=g.classList.contains(`connectable`),s=g.classList.contains(`connectableend`);if(!t||!e)return _;let c={source:d?t:r,sourceHandle:d?a:i,target:d?r:t,targetHandle:d?i:a};_.connection=c,_.isValid=o&&s&&(n===kw.Strict?d&&e===`source`||!d&&e===`target`:t!==r||a!==i)&&l(c),_.toHandle=EE(t,e,a,u,n,!0)}return _}var ME={onPointerDown:AE,isValid:jE};function NE({domNode:e,panZoom:t,getTransform:n,getViewScale:r}){let i=Kb(e);function a({translateExtent:e,width:a,height:o,zoomStep:s=1,pannable:c=!0,zoomable:l=!0,inversePan:u=!1}){let d=e=>{if(e.sourceEvent.type!==`wheel`||!t)return;let r=n(),i=e.sourceEvent.ctrlKey&&_T()?10:1,a=-e.sourceEvent.deltaY*(e.sourceEvent.deltaMode===1?.05:e.sourceEvent.deltaMode?1:.002)*s,o=r[2]*2**(a*i);t.scaleTo(o)},f=[0,0],p=ww().on(`start`,e=>{(e.sourceEvent.type===`mousedown`||e.sourceEvent.type===`touchstart`)&&(f=[e.sourceEvent.clientX??e.sourceEvent.touches[0].clientX,e.sourceEvent.clientY??e.sourceEvent.touches[0].clientY])}).on(`zoom`,c?i=>{let s=n();if(i.sourceEvent.type!==`mousemove`&&i.sourceEvent.type!==`touchmove`||!t)return;let c=[i.sourceEvent.clientX??i.sourceEvent.touches[0].clientX,i.sourceEvent.clientY??i.sourceEvent.touches[0].clientY],l=[c[0]-f[0],c[1]-f[1]];f=c;let d=r()*Math.max(s[2],Math.log(s[2]))*(u?-1:1),p={x:s[0]-l[0]*d,y:s[1]-l[1]*d},m=[[0,0],[a,o]];t.setViewportConstrained({x:p.x,y:p.y,zoom:s[2]},m,e)}:null).on(`zoom.wheel`,l?d:null);i.call(p,{})}function o(){i.on(`zoom`,null)}return{update:a,destroy:o,pointer:Jb}}var PE=(e,t)=>e.x!==t.x||e.y!==t.y||e.zoom!==t.k,FE=e=>({x:e.x,y:e.y,zoom:e.k}),IE=({x:e,y:t,zoom:n})=>mw.translate(e,t).scale(n),LE=(e,t)=>e.target.closest(`.${t}`),RE=(e,t)=>t===2&&Array.isArray(e)&&e.includes(2),zE=e=>((e*=2)<=1?e*e*e:(e-=2)*e*e+2)/2,BE=(e,t=0,n=zE,r=()=>{})=>{let i=typeof t==`number`&&t>0;return i||r(),i?e.transition().duration(t).ease(n).on(`end`,r):e},VE=e=>{let t=e.ctrlKey&&_T()?10:1;return-e.deltaY*(e.deltaMode===1?.05:e.deltaMode?1:.002)*t};function HE({zoomPanValues:e,noWheelClassName:t,d3Selection:n,d3Zoom:r,panOnScrollMode:i,panOnScrollSpeed:a,zoomOnPinch:o,onPanZoomStart:s,onPanZoom:c,onPanZoomEnd:l}){return u=>{if(LE(u,t))return!1;u.preventDefault(),u.stopImmediatePropagation();let d=n.property(`__zoom`).k||1;if(u.ctrlKey&&o){let e=Jb(u),t=d*2**VE(u);r.scaleTo(n,t,e,u);return}let f=u.deltaMode===1?20:1,p=i===Aw.Vertical?0:u.deltaX*f,m=i===Aw.Horizontal?0:u.deltaY*f;!_T()&&u.shiftKey&&i!==Aw.Vertical&&(p=u.deltaY*f,m=0),r.translateBy(n,-(p/d)*a,-(m/d)*a,{internal:!0});let h=FE(n.property(`__zoom`));clearTimeout(e.panScrollTimeout),e.isPanScrolling||(e.isPanScrolling=!0,s?.(u,h)),e.isPanScrolling&&(c?.(u,h),e.panScrollTimeout=setTimeout(()=>{l?.(u,h),e.isPanScrolling=!1},150))}}function UE({noWheelClassName:e,preventScrolling:t,d3ZoomHandler:n}){return function(r,i){let a=r.type===`wheel`,o=!t&&a&&!r.ctrlKey,s=LE(r,e);if(r.ctrlKey&&a&&s&&r.preventDefault(),o||s)return null;r.preventDefault(),n.call(this,r,i)}}function WE({zoomPanValues:e,onDraggingChange:t,onPanZoomStart:n}){return r=>{if(r.sourceEvent?.internal)return;let i=FE(r.transform);e.mouseButton=r.sourceEvent?.button||0,e.isZoomingOrPanning=!0,e.prevViewport=i,r.sourceEvent?.type===`mousedown`&&t(!0),n&&n?.(r.sourceEvent,i)}}function GE({zoomPanValues:e,panOnDrag:t,onPaneContextMenu:n,onTransformChange:r,onPanZoom:i}){return a=>{e.usedRightMouseButton=!!(n&&RE(t,e.mouseButton??0)),a.sourceEvent?.sync||r([a.transform.x,a.transform.y,a.transform.k]),i&&!a.sourceEvent?.internal&&i?.(a.sourceEvent,FE(a.transform))}}function KE({zoomPanValues:e,panOnDrag:t,panOnScroll:n,onDraggingChange:r,onPanZoomEnd:i,onPaneContextMenu:a}){return o=>{if(!o.sourceEvent?.internal&&(e.isZoomingOrPanning=!1,a&&RE(t,e.mouseButton??0)&&!e.usedRightMouseButton&&o.sourceEvent&&a(o.sourceEvent),e.usedRightMouseButton=!1,r(!1),i&&PE(e.prevViewport,o.transform))){let t=FE(o.transform);e.prevViewport=t,clearTimeout(e.timerId),e.timerId=setTimeout(()=>{i?.(o.sourceEvent,t)},n?150:0)}}}function qE({zoomActivationKeyPressed:e,zoomOnScroll:t,zoomOnPinch:n,panOnDrag:r,panOnScroll:i,zoomOnDoubleClick:a,userSelectionActive:o,noWheelClassName:s,noPanClassName:c,lib:l}){return u=>{let d=e||t,f=n&&u.ctrlKey;if(u.button===1&&u.type===`mousedown`&&(LE(u,`${l}-flow__node`)||LE(u,`${l}-flow__edge`)))return!0;if(!r&&!d&&!i&&!a&&!n||o||LE(u,s)&&u.type===`wheel`||LE(u,c)&&(u.type!==`wheel`||i&&u.type===`wheel`&&!e)||!n&&u.ctrlKey&&u.type===`wheel`)return!1;if(!n&&u.type===`touchstart`&&u.touches?.length>1)return u.preventDefault(),!1;if(!d&&!i&&!f&&u.type===`wheel`||!r&&(u.type===`mousedown`||u.type===`touchstart`)||Array.isArray(r)&&!r.includes(u.button)&&u.type===`mousedown`)return!1;let p=Array.isArray(r)&&r.includes(u.button)||!u.button||u.button<=1;return(!u.ctrlKey||u.type===`wheel`)&&p}}function JE({domNode:e,minZoom:t,maxZoom:n,paneClickDistance:r,translateExtent:i,viewport:a,onPanZoom:o,onPanZoomStart:s,onPanZoomEnd:c,onDraggingChange:l}){let u={isZoomingOrPanning:!1,usedRightMouseButton:!1,prevViewport:{x:0,y:0,zoom:0},mouseButton:0,timerId:void 0,panScrollTimeout:void 0,isPanScrolling:!1},d=e.getBoundingClientRect(),f=ww().clickDistance(!cT(r)||r<0?0:r).scaleExtent([t,n]).translateExtent(i),p=Kb(e).call(f);y({x:a.x,y:a.y,zoom:Yw(a.zoom,t,n)},[[0,0],[d.width,d.height]],i);let m=p.on(`wheel.zoom`),h=p.on(`dblclick.zoom`);f.wheelDelta(VE);function g(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?uS:wS).transform(BE(p,t?.duration,t?.ease,()=>n(!0)),e)}):Promise.resolve(!1)}function _({noWheelClassName:e,noPanClassName:t,onPaneContextMenu:n,userSelectionActive:r,panOnScroll:i,panOnDrag:a,panOnScrollMode:d,panOnScrollSpeed:g,preventScrolling:_,zoomOnPinch:y,zoomOnScroll:b,zoomOnDoubleClick:x,zoomActivationKeyPressed:S,lib:C,onTransformChange:w}){r&&!u.isZoomingOrPanning&&v();let T=i&&!S&&!r?HE({zoomPanValues:u,noWheelClassName:e,d3Selection:p,d3Zoom:f,panOnScrollMode:d,panOnScrollSpeed:g,zoomOnPinch:y,onPanZoomStart:s,onPanZoom:o,onPanZoomEnd:c}):UE({noWheelClassName:e,preventScrolling:_,d3ZoomHandler:m});if(p.on(`wheel.zoom`,T,{passive:!1}),!r){let e=WE({zoomPanValues:u,onDraggingChange:l,onPanZoomStart:s});f.on(`start`,e);let t=GE({zoomPanValues:u,panOnDrag:a,onPaneContextMenu:!!n,onPanZoom:o,onTransformChange:w});f.on(`zoom`,t);let r=KE({zoomPanValues:u,panOnDrag:a,panOnScroll:i,onPaneContextMenu:n,onPanZoomEnd:c,onDraggingChange:l});f.on(`end`,r)}let E=qE({zoomActivationKeyPressed:S,panOnDrag:a,zoomOnScroll:b,panOnScroll:i,zoomOnDoubleClick:x,zoomOnPinch:y,userSelectionActive:r,noPanClassName:t,noWheelClassName:e,lib:C});f.filter(E),x?p.on(`dblclick.zoom`,h):p.on(`dblclick.zoom`,null)}function v(){f.on(`zoom`,null)}async function y(e,t,n){let r=IE(e),i=f?.constrain()(r,t,n);return i&&await g(i),new Promise(e=>e(i))}async function b(e,t){let n=IE(e);return await g(n,t),new Promise(e=>e(n))}function x(e){if(p){let t=IE(e),n=p.property(`__zoom`);(n.k!==e.zoom||n.x!==e.x||n.y!==e.y)&&f?.transform(p,t,null,{sync:!0})}}function S(){let e=p?hw(p.node()):{x:0,y:0,k:1};return{x:e.x,y:e.y,zoom:e.k}}function C(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?uS:wS).scaleTo(BE(p,t?.duration,t?.ease,()=>n(!0)),e)}):Promise.resolve(!1)}function w(e,t){return p?new Promise(n=>{f?.interpolate(t?.interpolate===`linear`?uS:wS).scaleBy(BE(p,t?.duration,t?.ease,()=>n(!0)),e)}):Promise.resolve(!1)}function T(e){f?.scaleExtent(e)}function E(e){f?.translateExtent(e)}function D(e){let t=!cT(e)||e<0?0:e;f?.clickDistance(t)}return{update:_,destroy:v,setViewport:b,setViewportConstrained:y,getViewport:S,scaleTo:C,scaleBy:w,setScaleExtent:T,setTranslateExtent:E,syncViewport:x,setClickDistance:D}}var YE;(function(e){e.Line=`line`,e.Handle=`handle`})(YE||={});function XE({width:e,prevWidth:t,height:n,prevHeight:r,affectsX:i,affectsY:a}){let o=e-t,s=n-r,c=[o>0?1:o<0?-1:0,s>0?1:s<0?-1:0];return o&&i&&(c[0]*=-1),s&&a&&(c[1]*=-1),c}function ZE(e){return{isHorizontal:e.includes(`right`)||e.includes(`left`),isVertical:e.includes(`bottom`)||e.includes(`top`),affectsX:e.includes(`left`),affectsY:e.includes(`top`)}}function QE(e,t){return Math.max(0,t-e)}function $E(e,t){return Math.max(0,e-t)}function eD(e,t,n){return Math.max(0,t-e,e-n)}function tD(e,t){return e?!t:t}function nD(e,t,n,r,i,a,o,s){let{affectsX:c,affectsY:l}=t,{isHorizontal:u,isVertical:d}=t,f=u&&d,{xSnapped:p,ySnapped:m}=n,{minWidth:h,maxWidth:g,minHeight:_,maxHeight:v}=r,{x:y,y:b,width:x,height:S,aspectRatio:C}=e,w=Math.floor(u?p-e.pointerX:0),T=Math.floor(d?m-e.pointerY:0),E=x+(c?-w:w),D=S+(l?-T:T),O=-a[0]*x,k=-a[1]*S,A=eD(E,h,g),j=eD(D,_,v);if(o){let e=0,t=0;c&&w<0?e=QE(y+w+O,o[0][0]):!c&&w>0&&(e=$E(y+E+O,o[1][0])),l&&T<0?t=QE(b+T+k,o[0][1]):!l&&T>0&&(t=$E(b+D+k,o[1][1])),A=Math.max(A,e),j=Math.max(j,t)}if(s){let e=0,t=0;c&&w>0?e=$E(y+w,s[0][0]):!c&&w<0&&(e=QE(y+E,s[1][0])),l&&T>0?t=$E(b+T,s[0][1]):!l&&T<0&&(t=QE(b+D,s[1][1])),A=Math.max(A,e),j=Math.max(j,t)}if(i){if(u){let e=eD(E/C,_,v)*C;if(A=Math.max(A,e),o){let e=0;e=!c&&!l||c&&!l&&f?$E(b+k+E/C,o[1][1])*C:QE(b+k+(c?w:-w)/C,o[0][1])*C,A=Math.max(A,e)}if(s){let e=0;e=!c&&!l||c&&!l&&f?QE(b+E/C,s[1][1])*C:$E(b+(c?w:-w)/C,s[0][1])*C,A=Math.max(A,e)}}if(d){let e=eD(D*C,h,g)/C;if(j=Math.max(j,e),o){let e=0;e=!c&&!l||l&&!c&&f?$E(y+D*C+O,o[1][0])/C:QE(y+(l?T:-T)*C+O,o[0][0])/C,j=Math.max(j,e)}if(s){let e=0;e=!c&&!l||l&&!c&&f?QE(y+D*C,s[1][0])/C:$E(y+(l?T:-T)*C,s[0][0])/C,j=Math.max(j,e)}}}T+=T<0?j:-j,w+=w<0?A:-A,i&&(f?E>D*C?T=(tD(c,l)?-w:w)/C:w=(tD(c,l)?-T:T)*C:u?(T=w/C,l=c):(w=T*C,c=l));let M=c?y+w:y,N=l?b+T:b;return{width:x+(c?-w:w),height:S+(l?-T:T),x:a[0]*w*(c?-1:1)+M,y:a[1]*T*(l?-1:1)+N}}var rD={width:0,height:0,x:0,y:0},iD={...rD,pointerX:0,pointerY:0,aspectRatio:1};function aD(e){return[[0,0],[e.measured.width,e.measured.height]]}function oD(e,t,n){let r=t.position.x+e.position.x,i=t.position.y+e.position.y,a=e.measured.width??0,o=e.measured.height??0,s=n[0]*a,c=n[1]*o;return[[r-s,i-c],[r+a-s,i+o-c]]}function sD({domNode:e,nodeId:t,getStoreItems:n,onChange:r,onEnd:i}){let a=Kb(e);function o({controlPosition:e,boundaries:o,keepAspectRatio:s,resizeDirection:c,onResizeStart:l,onResize:u,onResizeEnd:d,shouldResize:f}){let p={...rD},m={...iD},h=ZE(e),g,_=null,v=[],y,b,x,S=sx().on(`start`,e=>{let{nodeLookup:r,transform:i,snapGrid:a,snapToGrid:o,nodeOrigin:s,paneDomNode:c}=n();if(g=r.get(t),!g)return;_=c?.getBoundingClientRect()??null;let{xSnapped:u,ySnapped:d}=TT(e.sourceEvent,{transform:i,snapGrid:a,snapToGrid:o,containerBounds:_});p={width:g.measured.width??0,height:g.measured.height??0,x:g.position.x??0,y:g.position.y??0},m={...p,pointerX:u,pointerY:d,aspectRatio:p.width/p.height},y=void 0,g.parentId&&(g.extent===`parent`||g.expandParent)&&(y=r.get(g.parentId),b=y&&g.extent===`parent`?aD(y):void 0),v=[],x=void 0;for(let[e,n]of r)if(n.parentId===t&&(v.push({id:e,position:{...n.position},extent:n.extent}),n.extent===`parent`||n.expandParent)){let e=oD(n,g,n.origin??s);x=x?[[Math.min(e[0][0],x[0][0]),Math.min(e[0][1],x[0][1])],[Math.max(e[1][0],x[1][0]),Math.max(e[1][1],x[1][1])]]:e}l?.(e,{...p})}).on(`drag`,e=>{let{transform:t,snapGrid:i,snapToGrid:a,nodeOrigin:l}=n(),d=TT(e.sourceEvent,{transform:t,snapGrid:i,snapToGrid:a,containerBounds:_}),S=[];if(!g)return;let{x:C,y:w,width:T,height:E}=p,D={},O=g.origin??l,{width:k,height:A,x:j,y:M}=nD(m,h,d,o,s,O,b,x),N=k!==T,P=A!==E,ee=j!==C&&N,F=M!==w&&P;if(!ee&&!F&&!N&&!P)return;if((ee||F||O[0]===1||O[1]===1)&&(D.x=ee?j:p.x,D.y=F?M:p.y,p.x=D.x,p.y=D.y,v.length>0)){let e=j-C,t=M-w;for(let n of v)n.position={x:n.position.x-e+O[0]*(k-T),y:n.position.y-t+O[1]*(A-E)},S.push(n)}if((N||P)&&(D.width=N&&(!c||c===`horizontal`)?k:p.width,D.height=P&&(!c||c===`vertical`)?A:p.height,p.width=D.width,p.height=D.height),y&&g.expandParent){let e=O[0]*(D.width??0);D.x&&D.x{d?.(e,{...p}),i?.({...p})});a.call(S)}function s(){a.on(`.drag`,null)}return{update:o,destroy:s}}var cD=s((e=>{var t=f();function n(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var r=typeof Object.is==`function`?Object.is:n,i=t.useState,a=t.useEffect,o=t.useLayoutEffect,s=t.useDebugValue;function c(e,t){var n=t(),r=i({inst:{value:n,getSnapshot:t}}),c=r[0].inst,u=r[1];return o(function(){c.value=n,c.getSnapshot=t,l(c)&&u({inst:c})},[e,n,t]),a(function(){return l(c)&&u({inst:c}),e(function(){l(c)&&u({inst:c})})},[e]),s(n),n}function l(e){var t=e.getSnapshot;e=e.value;try{var n=t();return!r(e,n)}catch{return!0}}function u(e,t){return t()}var d=typeof window>`u`||window.document===void 0||window.document.createElement===void 0?u:c;e.useSyncExternalStore=t.useSyncExternalStore===void 0?d:t.useSyncExternalStore})),lD=s(((e,t)=>{t.exports=cD()})),uD=s((e=>{var t=f(),n=lD();function r(e,t){return e===t&&(e!==0||1/e==1/t)||e!==e&&t!==t}var i=typeof Object.is==`function`?Object.is:r,a=n.useSyncExternalStore,o=t.useRef,s=t.useEffect,c=t.useMemo,l=t.useDebugValue;e.useSyncExternalStoreWithSelector=function(e,t,n,r,u){var d=o(null);if(d.current===null){var f={hasValue:!1,value:null};d.current=f}else f=d.current;d=c(function(){function e(e){if(!a){if(a=!0,o=e,e=r(e),u!==void 0&&f.hasValue){var t=f.value;if(u(t,e))return s=t}return s=e}if(t=s,i(o,e))return t;var n=r(e);return u!==void 0&&u(t,n)?(o=e,t):(o=e,s=n)}var a=!1,o,s,c=n===void 0?null:n;return[function(){return e(t())},c===null?void 0:function(){return e(c())}]},[t,n,r,u]);var p=a(e,d[0],d[1]);return s(function(){f.hasValue=!0,f.value=p},[p]),l(p),p}})),dD=u(s(((e,t)=>{t.exports=uD()}))(),1),fD=e=>{let t,n=new Set,r=(e,r)=>{let i=typeof e==`function`?e(t):e;if(!Object.is(i,t)){let e=t;t=r??(typeof i!=`object`||!i)?i:Object.assign({},t,i),n.forEach(n=>n(t,e))}},i=()=>t,a={setState:r,getState:i,getInitialState:()=>o,subscribe:e=>(n.add(e),()=>n.delete(e)),destroy:()=>{n.clear()}},o=t=e(r,i,a);return a},pD=e=>e?fD(e):fD,{useDebugValue:mD}=y.default,{useSyncExternalStoreWithSelector:hD}=dD.default,gD=e=>e;function _D(e,t=gD,n){let r=hD(e.subscribe,e.getState,e.getServerState||e.getInitialState,t,n);return mD(r),r}var vD=(e,t)=>{let n=pD(e),r=(e,r=t)=>_D(n,e,r);return Object.assign(r,n),r},yD=(e,t)=>e?vD(e,t):vD;function bD(e,t){if(Object.is(e,t))return!0;if(typeof e!=`object`||!e||typeof t!=`object`||!t)return!1;if(e instanceof Map&&t instanceof Map){if(e.size!==t.size)return!1;for(let[n,r]of e)if(!Object.is(r,t.get(n)))return!1;return!0}if(e instanceof Set&&t instanceof Set){if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0}let n=Object.keys(e);if(n.length!==Object.keys(t).length)return!1;for(let r of n)if(!Object.prototype.hasOwnProperty.call(t,r)||!Object.is(e[r],t[r]))return!1;return!0}var xD=(0,y.createContext)(null),SD=xD.Provider,CD=Tw.error001();function wD(e,t){let n=(0,y.useContext)(xD);if(n===null)throw Error(CD);return _D(n,e,t)}function TD(){let e=(0,y.useContext)(xD);if(e===null)throw Error(CD);return(0,y.useMemo)(()=>({getState:e.getState,setState:e.setState,subscribe:e.subscribe}),[e])}var ED={display:`none`},DD={position:`absolute`,width:1,height:1,margin:-1,border:0,padding:0,overflow:`hidden`,clip:`rect(0px, 0px, 0px, 0px)`,clipPath:`inset(100%)`},OD=`react-flow__node-desc`,kD=`react-flow__edge-desc`,AD=`react-flow__aria-live`,jD=e=>e.ariaLiveMessage,MD=e=>e.ariaLabelConfig;function ND({rfId:e}){let t=wD(jD);return(0,T.jsx)(`div`,{id:`${AD}-${e}`,"aria-live":`assertive`,"aria-atomic":`true`,style:DD,children:t})}function PD({rfId:e,disableKeyboardA11y:t}){let n=wD(MD);return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`div`,{id:`${OD}-${e}`,style:ED,children:t?n[`node.a11yDescription.default`]:n[`node.a11yDescription.keyboardDisabled`]}),(0,T.jsx)(`div`,{id:`${kD}-${e}`,style:ED,children:n[`edge.a11yDescription.default`]}),!t&&(0,T.jsx)(ND,{rfId:e})]})}var FD=(0,y.forwardRef)(({position:e=`top-left`,children:t,className:n,style:r,...i},a)=>(0,T.jsx)(`div`,{className:Vv([`react-flow__panel`,n,...`${e}`.split(`-`)]),style:r,ref:a,...i,children:t}));FD.displayName=`Panel`;function ID({proOptions:e,position:t=`bottom-right`}){return e?.hideAttribution?null:(0,T.jsx)(FD,{position:t,className:`react-flow__attribution`,"data-message":`Please only hide this attribution when you are subscribed to React Flow Pro: https://pro.reactflow.dev`,children:(0,T.jsx)(`a`,{href:`https://reactflow.dev`,target:`_blank`,rel:`noopener noreferrer`,"aria-label":`React Flow attribution`,children:`React Flow`})})}var LD=e=>{let t=[],n=[];for(let[,n]of e.nodeLookup)n.selected&&t.push(n.internals.userNode);for(let[,t]of e.edgeLookup)t.selected&&n.push(t);return{selectedNodes:t,selectedEdges:n}},RD=e=>e.id;function zD(e,t){return bD(e.selectedNodes.map(RD),t.selectedNodes.map(RD))&&bD(e.selectedEdges.map(RD),t.selectedEdges.map(RD))}function BD({onSelectionChange:e}){let t=TD(),{selectedNodes:n,selectedEdges:r}=wD(LD,zD);return(0,y.useEffect)(()=>{let i={nodes:n,edges:r};e?.(i),t.getState().onSelectionChangeHandlers.forEach(e=>e(i))},[n,r,e]),null}var VD=e=>!!e.onSelectionChangeHandlers;function HD({onSelectionChange:e}){let t=wD(VD);return e||t?(0,T.jsx)(BD,{onSelectionChange:e}):null}var UD=[0,0],WD={x:0,y:0,zoom:1},GD=[...`nodes.edges.defaultNodes.defaultEdges.onConnect.onConnectStart.onConnectEnd.onClickConnectStart.onClickConnectEnd.nodesDraggable.autoPanOnNodeFocus.nodesConnectable.nodesFocusable.edgesFocusable.edgesReconnectable.elevateNodesOnSelect.elevateEdgesOnSelect.minZoom.maxZoom.nodeExtent.onNodesChange.onEdgesChange.elementsSelectable.connectionMode.snapGrid.snapToGrid.translateExtent.connectOnClick.defaultEdgeOptions.fitView.fitViewOptions.onNodesDelete.onEdgesDelete.onDelete.onNodeDrag.onNodeDragStart.onNodeDragStop.onSelectionDrag.onSelectionDragStart.onSelectionDragStop.onMoveStart.onMove.onMoveEnd.noPanClassName.nodeOrigin.autoPanOnConnect.autoPanOnNodeDrag.onError.connectionRadius.isValidConnection.selectNodesOnDrag.nodeDragThreshold.connectionDragThreshold.onBeforeDelete.debug.autoPanSpeed.paneClickDistance.ariaLabelConfig`.split(`.`),`rfId`],KD=e=>({setNodes:e.setNodes,setEdges:e.setEdges,setMinZoom:e.setMinZoom,setMaxZoom:e.setMaxZoom,setTranslateExtent:e.setTranslateExtent,setNodeExtent:e.setNodeExtent,reset:e.reset,setDefaultNodesAndEdges:e.setDefaultNodesAndEdges,setPaneClickDistance:e.setPaneClickDistance}),qD={translateExtent:Ew,nodeOrigin:UD,minZoom:.5,maxZoom:2,elementsSelectable:!0,noPanClassName:`nopan`,rfId:`1`,paneClickDistance:0};function JD(e){let{setNodes:t,setEdges:n,setMinZoom:r,setMaxZoom:i,setTranslateExtent:a,setNodeExtent:o,reset:s,setDefaultNodesAndEdges:c,setPaneClickDistance:l}=wD(KD,bD),u=TD();(0,y.useEffect)(()=>(c(e.defaultNodes,e.defaultEdges),()=>{d.current=qD,s()}),[]);let d=(0,y.useRef)(qD);return(0,y.useEffect)(()=>{for(let s of GD){let c=e[s];c!==d.current[s]&&e[s]!==void 0&&(s===`nodes`?t(c):s===`edges`?n(c):s===`minZoom`?r(c):s===`maxZoom`?i(c):s===`translateExtent`?a(c):s===`nodeExtent`?o(c):s===`paneClickDistance`?l(c):s===`ariaLabelConfig`?u.setState({ariaLabelConfig:wT(c)}):s===`fitView`?u.setState({fitViewQueued:c}):s===`fitViewOptions`?u.setState({fitViewOptions:c}):u.setState({[s]:c}))}d.current=e},GD.map(t=>e[t])),null}function YD(){return typeof window>`u`||!window.matchMedia?null:window.matchMedia(`(prefers-color-scheme: dark)`)}function XD(e){let[t,n]=(0,y.useState)(e===`system`?null:e);return(0,y.useEffect)(()=>{if(e!==`system`){n(e);return}let t=YD(),r=()=>n(t?.matches?`dark`:`light`);return r(),t?.addEventListener(`change`,r),()=>{t?.removeEventListener(`change`,r)}},[e]),t===null?YD()?.matches?`dark`:`light`:t}var ZD=typeof document<`u`?document:null;function QD(e=null,t={target:ZD,actInsideInputWithModifier:!0}){let[n,r]=(0,y.useState)(!1),i=(0,y.useRef)(!1),a=(0,y.useRef)(new Set([])),[o,s]=(0,y.useMemo)(()=>{if(e!==null){let t=(Array.isArray(e)?e:[e]).filter(e=>typeof e==`string`).map(e=>e.replace(`+`,` `).replace(` `,` +`).split(` -`));return[t,t.reduce((e,t)=>e.concat(...t),[])]}return[[],[]]},[e]);return(0,y.useEffect)(()=>{let n=t?.target??qD,c=t?.actInsideInputWithModifier??!0;if(e!==null){let e=e=>{if(i.current=e.ctrlKey||e.metaKey||e.shiftKey||e.altKey,(!i.current||i.current&&!c)&&TT(e))return!1;let n=XD(e.code,s);if(a.current.add(e[n]),YD(o,a.current,!1)){let n=e.composedPath?.()?.[0]||e.target,a=n?.nodeName===`BUTTON`||n?.nodeName===`A`;t.preventDefault!==!1&&(i.current||!a)&&e.preventDefault(),r(!0)}},l=e=>{let t=XD(e.code,s);YD(o,a.current,!0)?(r(!1),a.current.clear()):a.current.delete(e[t]),e.key===`Meta`&&a.current.clear(),i.current=!1},u=()=>{a.current.clear(),r(!1)};return n?.addEventListener(`keydown`,e),n?.addEventListener(`keyup`,l),window.addEventListener(`blur`,u),window.addEventListener(`contextmenu`,u),()=>{n?.removeEventListener(`keydown`,e),n?.removeEventListener(`keyup`,l),window.removeEventListener(`blur`,u),window.removeEventListener(`contextmenu`,u)}}},[e,r]),n}function YD(e,t,n){return e.filter(e=>n||e.length===t.size).some(e=>e.every(e=>t.has(e)))}function XD(e,t){return t.includes(e)?`code`:`key`}var ZD=()=>{let e=xD();return(0,y.useMemo)(()=>({zoomIn:t=>{let{panZoom:n}=e.getState();return n?n.scaleBy(1.2,{duration:t?.duration}):Promise.resolve(!1)},zoomOut:t=>{let{panZoom:n}=e.getState();return n?n.scaleBy(1/1.2,{duration:t?.duration}):Promise.resolve(!1)},zoomTo:(t,n)=>{let{panZoom:r}=e.getState();return r?r.scaleTo(t,{duration:n?.duration}):Promise.resolve(!1)},getZoom:()=>e.getState().transform[2],setViewport:async(t,n)=>{let{transform:[r,i,a],panZoom:o}=e.getState();return o?(await o.setViewport({x:t.x??r,y:t.y??i,zoom:t.zoom??a},n),Promise.resolve(!0)):Promise.resolve(!1)},getViewport:()=>{let[t,n,r]=e.getState().transform;return{x:t,y:n,zoom:r}},setCenter:async(t,n,r)=>e.getState().setCenter(t,n,r),fitBounds:async(t,n)=>{let{width:r,height:i,minZoom:a,maxZoom:o,panZoom:s}=e.getState(),c=fT(t,r,i,a,o,n?.padding??.1);return s?(await s.setViewport(c,{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),Promise.resolve(!0)):Promise.resolve(!1)},screenToFlowPosition:(t,n={})=>{let{transform:r,snapGrid:i,snapToGrid:a,domNode:o}=e.getState();if(!o)return t;let{x:s,y:c}=o.getBoundingClientRect(),l={x:t.x-s,y:t.y-c},u=n.snapGrid??i;return sT(l,r,n.snapToGrid??a,u)},flowToScreenPosition:t=>{let{transform:n,domNode:r}=e.getState();if(!r)return t;let{x:i,y:a}=r.getBoundingClientRect(),o=cT(t,n);return{x:o.x+i,y:o.y+a}}}),[])};function QD(e,t){let n=[],r=new Map,i=[];for(let t of e)if(t.type===`add`){i.push(t);continue}else if(t.type===`remove`||t.type===`replace`)r.set(t.id,[t]);else{let e=r.get(t.id);e?e.push(t):r.set(t.id,[t])}for(let e of t){let t=r.get(e.id);if(!t){n.push(e);continue}if(t[0].type===`remove`)continue;if(t[0].type===`replace`){n.push({...t[0].item});continue}let i={...e};for(let e of t)$D(e,i);n.push(i)}return i.length&&i.forEach(e=>{e.index===void 0?n.push({...e.item}):n.splice(e.index,0,{...e.item})}),n}function $D(e,t){switch(e.type){case`select`:t.selected=e.selected;break;case`position`:e.position!==void 0&&(t.position=e.position),e.dragging!==void 0&&(t.dragging=e.dragging);break;case`dimensions`:e.dimensions!==void 0&&(t.measured??={},t.measured.width=e.dimensions.width,t.measured.height=e.dimensions.height,e.setAttributes&&((e.setAttributes===!0||e.setAttributes===`width`)&&(t.width=e.dimensions.width),(e.setAttributes===!0||e.setAttributes===`height`)&&(t.height=e.dimensions.height))),typeof e.resizing==`boolean`&&(t.resizing=e.resizing);break}}function eO(e,t){return QD(e,t)}function tO(e,t){return QD(e,t)}function nO(e,t){return{id:e,type:`select`,selected:t}}function rO(e,t=new Set,n=!1){let r=[];for(let[i,a]of e){let e=t.has(i);!(a.selected===void 0&&!e)&&a.selected!==e&&(n&&(a.selected=e),r.push(nO(a.id,e)))}return r}function iO({items:e=[],lookup:t}){let n=[],r=new Map(e.map(e=>[e.id,e]));for(let[r,i]of e.entries()){let e=t.get(i.id),a=e?.internals?.userNode??e;a!==void 0&&a!==i&&n.push({id:i.id,item:i,type:`replace`}),a===void 0&&n.push({item:i,type:`add`,index:r})}for(let[e]of t)r.get(e)===void 0&&n.push({id:e,type:`remove`});return n}function aO(e){return{id:e.id,type:`remove`}}var oO=e=>Pw(e),sO=e=>Nw(e);function cO(e){return(0,y.forwardRef)(e)}var lO=typeof window<`u`?y.useLayoutEffect:y.useEffect;function uO(e){let[t,n]=(0,y.useState)(BigInt(0)),[r]=(0,y.useState)(()=>dO(()=>n(e=>e+BigInt(1))));return lO(()=>{let t=r.get();t.length&&(e(t),r.reset())},[t]),r}function dO(e){let t=[];return{get:()=>t,reset:()=>{t=[]},push:n=>{t.push(n),e()}}}var fO=(0,y.createContext)(null);function pO({children:e}){let t=xD(),n=uO((0,y.useCallback)(e=>{let{nodes:n=[],setNodes:r,hasDefaultNodes:i,onNodesChange:a,nodeLookup:o,fitViewQueued:s}=t.getState(),c=n;for(let t of e)c=typeof t==`function`?t(c):t;let l=iO({items:c,lookup:o});i&&r(c),l.length>0?a?.(l):s&&window.requestAnimationFrame(()=>{let{fitViewQueued:e,nodes:n,setNodes:r}=t.getState();e&&r(n)})},[])),r=uO((0,y.useCallback)(e=>{let{edges:n=[],setEdges:r,hasDefaultEdges:i,onEdgesChange:a,edgeLookup:o}=t.getState(),s=n;for(let t of e)s=typeof t==`function`?t(s):t;i?r(s):a&&a(iO({items:s,lookup:o}))},[])),i=(0,y.useMemo)(()=>({nodeQueue:n,edgeQueue:r}),[]);return(0,T.jsx)(fO.Provider,{value:i,children:e})}function mO(){let e=(0,y.useContext)(fO);if(!e)throw Error(`useBatchContext must be used within a BatchProvider`);return e}var hO=e=>!!e.panZoom;function gO(){let e=ZD(),t=xD(),n=mO(),r=bD(hO),i=(0,y.useMemo)(()=>{let e=e=>t.getState().nodeLookup.get(e),r=e=>{n.nodeQueue.push(e)},i=e=>{n.edgeQueue.push(e)},a=e=>{let{nodeLookup:n,nodeOrigin:r}=t.getState(),i=oO(e)?e:n.get(e.id),a=i.parentId?_T(i.position,i.measured,i.parentId,n,r):i.position;return $w({...i,position:a,width:i.measured?.width??i.width,height:i.measured?.height??i.height})},o=(e,t,n={replace:!1})=>{r(r=>r.map(r=>{if(r.id===e){let e=typeof t==`function`?t(r):t;return n.replace&&oO(e)?e:{...r,...e}}return r}))},s=(e,t,n={replace:!1})=>{i(r=>r.map(r=>{if(r.id===e){let e=typeof t==`function`?t(r):t;return n.replace&&sO(e)?e:{...r,...e}}return r}))};return{getNodes:()=>t.getState().nodes.map(e=>({...e})),getNode:t=>e(t)?.internals.userNode,getInternalNode:e,getEdges:()=>{let{edges:e=[]}=t.getState();return e.map(e=>({...e}))},getEdge:e=>t.getState().edgeLookup.get(e),setNodes:r,setEdges:i,addNodes:e=>{let t=Array.isArray(e)?e:[e];n.nodeQueue.push(e=>[...e,...t])},addEdges:e=>{let t=Array.isArray(e)?e:[e];n.edgeQueue.push(e=>[...e,...t])},toObject:()=>{let{nodes:e=[],edges:n=[],transform:r}=t.getState(),[i,a,o]=r;return{nodes:e.map(e=>({...e})),edges:n.map(e=>({...e})),viewport:{x:i,y:a,zoom:o}}},deleteElements:async({nodes:e=[],edges:n=[]})=>{let{nodes:r,edges:i,onNodesDelete:a,onEdgesDelete:o,triggerNodeChanges:s,triggerEdgeChanges:c,onDelete:l,onBeforeDelete:u}=t.getState(),{nodes:d,edges:f}=await Ww({nodesToRemove:e,edgesToRemove:n,nodes:r,edges:i,onBeforeDelete:u}),p=f.length>0,m=d.length>0;if(p){let e=f.map(aO);o?.(f),c(e)}if(m){let e=d.map(aO);a?.(d),s(e)}return(m||p)&&l?.({nodes:d,edges:f}),{deletedNodes:d,deletedEdges:f}},getIntersectingNodes:(e,n=!0,r)=>{let i=rT(e),o=i?e:a(e),s=r!==void 0;return o?(r||t.getState().nodes).filter(r=>{let a=t.getState().nodeLookup.get(r.id);if(a&&!i&&(r.id===e.id||!a.internals.positionAbsolute))return!1;let c=$w(s?r:a),l=nT(c,o);return n&&l>0||l>=c.width*c.height||l>=o.width*o.height}):[]},isNodeIntersecting:(e,t,n=!0)=>{let r=rT(e)?e:a(e);if(!r)return!1;let i=nT(r,t);return n&&i>0||i>=r.width*r.height},updateNode:o,updateNodeData:(e,t,n={replace:!1})=>{o(e,e=>{let r=typeof t==`function`?t(e):t;return n.replace?{...e,data:r}:{...e,data:{...e.data,...r}}},n)},updateEdge:s,updateEdgeData:(e,t,n={replace:!1})=>{s(e,e=>{let r=typeof t==`function`?t(e):t;return n.replace?{...e,data:r}:{...e,data:{...e.data,...r}}},n)},getNodesBounds:e=>{let{nodeLookup:n,nodeOrigin:r}=t.getState();return Lw(e,{nodeLookup:n,nodeOrigin:r})},getHandleConnections:({type:e,id:n,nodeId:r})=>Array.from(t.getState().connectionLookup.get(`${r}-${e}${n?`-${n}`:``}`)?.values()??[]),getNodeConnections:({type:e,handleId:n,nodeId:r})=>Array.from(t.getState().connectionLookup.get(`${r}${e?n?`-${e}-${n}`:`-${e}`:``}`)?.values()??[]),fitView:async e=>{let r=t.getState().fitViewResolver??yT();return t.setState({fitViewQueued:!0,fitViewOptions:e,fitViewResolver:r}),n.nodeQueue.push(e=>[...e]),r.promise}}},[]);return(0,y.useMemo)(()=>({...i,...e,viewportInitialized:r}),[r])}var _O=e=>e.selected,vO=typeof window<`u`?window:void 0;function yO({deleteKeyCode:e,multiSelectionKeyCode:t}){let n=xD(),{deleteElements:r}=gO(),i=JD(e,{actInsideInputWithModifier:!1}),a=JD(t,{target:vO});(0,y.useEffect)(()=>{if(i){let{edges:e,nodes:t}=n.getState();r({nodes:t.filter(_O),edges:e.filter(_O)}),n.setState({nodesSelectionActive:!1})}},[i]),(0,y.useEffect)(()=>{n.setState({multiSelectionActive:a})},[a])}function bO(e){let t=xD();(0,y.useEffect)(()=>{let n=()=>{if(!e.current)return!1;let n=ST(e.current);(n.height===0||n.width===0)&&t.getState().onError?.(`004`,xw.error004()),t.setState({width:n.width||500,height:n.height||500})};if(e.current){n(),window.addEventListener(`resize`,n);let t=new ResizeObserver(()=>n());return t.observe(e.current),()=>{window.removeEventListener(`resize`,n),t&&e.current&&t.unobserve(e.current)}}},[])}var xO={position:`absolute`,width:`100%`,height:`100%`,top:0,left:0},SO=e=>({userSelectionActive:e.userSelectionActive,lib:e.lib});function CO({onPaneContextMenu:e,zoomOnScroll:t=!0,zoomOnPinch:n=!0,panOnScroll:r=!1,panOnScrollSpeed:i=.5,panOnScrollMode:a=Ew.Free,zoomOnDoubleClick:o=!0,panOnDrag:s=!0,defaultViewport:c,translateExtent:l,minZoom:u,maxZoom:d,zoomActivationKeyCode:f,preventScrolling:p=!0,children:m,noWheelClassName:h,noPanClassName:g,onViewportChange:_,isControlledViewport:v,paneClickDistance:b}){let x=xD(),S=(0,y.useRef)(null),{userSelectionActive:C,lib:w}=bD(SO,gD),E=JD(f),D=(0,y.useRef)();bO(S);let O=(0,y.useCallback)(e=>{_?.({x:e[0],y:e[1],zoom:e[2]}),v||x.setState({transform:e})},[_,v]);return(0,y.useEffect)(()=>{if(S.current){D.current=WE({domNode:S.current,minZoom:u,maxZoom:d,translateExtent:l,viewport:c,paneClickDistance:b,onDraggingChange:e=>x.setState({paneDragging:e}),onPanZoomStart:(e,t)=>{let{onViewportChangeStart:n,onMoveStart:r}=x.getState();r?.(e,t),n?.(t)},onPanZoom:(e,t)=>{let{onViewportChange:n,onMove:r}=x.getState();r?.(e,t),n?.(t)},onPanZoomEnd:(e,t)=>{let{onViewportChangeEnd:n,onMoveEnd:r}=x.getState();r?.(e,t),n?.(t)}});let{x:e,y:t,zoom:n}=D.current.getViewport();return x.setState({panZoom:D.current,transform:[e,t,n],domNode:S.current.closest(`.react-flow`)}),()=>{D.current?.destroy()}}},[]),(0,y.useEffect)(()=>{D.current?.update({onPaneContextMenu:e,zoomOnScroll:t,zoomOnPinch:n,panOnScroll:r,panOnScrollSpeed:i,panOnScrollMode:a,zoomOnDoubleClick:o,panOnDrag:s,zoomActivationKeyPressed:E,preventScrolling:p,noPanClassName:g,userSelectionActive:C,noWheelClassName:h,lib:w,onTransformChange:O})},[e,t,n,r,i,a,o,s,E,p,g,C,h,w,O]),(0,T.jsx)(`div`,{className:`react-flow__renderer`,ref:S,style:xO,children:m})}var wO=e=>({userSelectionActive:e.userSelectionActive,userSelectionRect:e.userSelectionRect});function TO(){let{userSelectionActive:e,userSelectionRect:t}=bD(wO,gD);return e&&t?(0,T.jsx)(`div`,{className:`react-flow__selection react-flow__container`,style:{width:t.width,height:t.height,transform:`translate(${t.x}px, ${t.y}px)`}}):null}var EO=(e,t)=>n=>{n.target===t.current&&e?.(n)},DO=e=>({userSelectionActive:e.userSelectionActive,elementsSelectable:e.elementsSelectable,connectionInProgress:e.connection.inProgress,dragging:e.paneDragging});function OO({isSelecting:e,selectionKeyPressed:t,selectionMode:n=Dw.Full,panOnDrag:r,selectionOnDrag:i,onSelectionStart:a,onSelectionEnd:o,onPaneClick:s,onPaneContextMenu:c,onPaneScroll:l,onPaneMouseEnter:u,onPaneMouseMove:d,onPaneMouseLeave:f,children:p}){let m=xD(),{userSelectionActive:h,elementsSelectable:g,dragging:_,connectionInProgress:v}=bD(DO,gD),b=g&&(e||h),x=(0,y.useRef)(null),S=(0,y.useRef)(),C=(0,y.useRef)(new Set),w=(0,y.useRef)(new Set),E=(0,y.useRef)(!1),D=(0,y.useRef)(!1),O=e=>{if(E.current||v){E.current=!1;return}s?.(e),m.getState().resetSelectedElements(),m.setState({nodesSelectionActive:!1})},k=e=>{if(Array.isArray(r)&&r?.includes(2)){e.preventDefault();return}c?.(e)},A=l?e=>l(e):void 0;return(0,T.jsxs)(`div`,{className:Lv([`react-flow__pane`,{draggable:r===!0||Array.isArray(r)&&r.includes(0),dragging:_,selection:e}]),onClick:b?void 0:EO(O,x),onContextMenu:EO(k,x),onWheel:EO(A,x),onPointerEnter:b?void 0:u,onPointerDown:b?t=>{let{resetSelectedElements:n,domNode:r}=m.getState();if(S.current=r?.getBoundingClientRect(),!g||!e||t.button!==0||t.target!==x.current||!S.current)return;t.target?.setPointerCapture?.(t.pointerId),D.current=!0,E.current=!1;let{x:i,y:o}=DT(t.nativeEvent,S.current);n(),m.setState({userSelectionRect:{width:0,height:0,startX:i,startY:o,x:i,y:o}}),a?.(t)}:d,onPointerMove:b?e=>{let{userSelectionRect:t,transform:r,nodeLookup:i,edgeLookup:a,connectionLookup:o,triggerNodeChanges:s,triggerEdgeChanges:c,defaultEdgeOptions:l}=m.getState();if(!S.current||!t)return;E.current=!0;let{x:u,y:d}=DT(e.nativeEvent,S.current),{startX:f,startY:p}=t,h={startX:f,startY:p,x:ue.id)),w.current=new Set;let v=l?.selectable??!0;for(let e of C.current){let t=o.get(e);if(t)for(let{edgeId:e}of t.values()){let t=a.get(e);t&&(t.selectable??v)&&w.current.add(e)}}vT(g,C.current)||s(rO(i,C.current,!0)),vT(_,w.current)||c(rO(a,w.current)),m.setState({userSelectionRect:h,userSelectionActive:!0,nodesSelectionActive:!1})}:d,onPointerUp:b?e=>{if(e.button!==0||!D.current)return;e.target?.releasePointerCapture?.(e.pointerId);let{userSelectionRect:n}=m.getState();!h&&n&&e.target===x.current&&O?.(e),m.setState({userSelectionActive:!1,userSelectionRect:null,nodesSelectionActive:C.current.size>0}),o?.(e),(t||i)&&(E.current=!1),D.current=!1}:void 0,onPointerLeave:f,ref:x,style:xO,children:[p,(0,T.jsx)(TO,{})]})}function kO({id:e,store:t,unselect:n=!1,nodeRef:r}){let{addSelectedNodes:i,unselectNodesAndEdges:a,multiSelectionActive:o,nodeLookup:s,onError:c}=t.getState(),l=s.get(e);if(!l){c?.(`012`,xw.error012(e));return}t.setState({nodesSelectionActive:!1}),l.selected?(n||l.selected&&o)&&(a({nodes:[l],edges:[]}),requestAnimationFrame(()=>r?.current?.blur())):i([e])}function AO({nodeRef:e,disabled:t=!1,noDragClassName:n,handleSelector:r,nodeId:i,isSelectable:a,nodeClickDistance:o}){let s=xD(),[c,l]=(0,y.useState)(!1),u=(0,y.useRef)();return(0,y.useEffect)(()=>{u.current=vE({getStoreItems:()=>s.getState(),onNodeMouseDown:t=>{kO({id:t,store:s,nodeRef:e})},onDragStart:()=>{l(!0)},onDragStop:()=>{l(!1)}})},[]),(0,y.useEffect)(()=>{if(t)u.current?.destroy();else if(e.current)return u.current?.update({noDragClassName:n,handleSelector:r,domNode:e.current,isSelectable:a,nodeId:i,nodeClickDistance:o}),()=>{u.current?.destroy()}},[n,r,t,a,e,i]),c}var jO=e=>t=>t.selected&&(t.draggable||e&&t.draggable===void 0);function MO(){let e=xD();return(0,y.useCallback)(t=>{let{nodeExtent:n,snapToGrid:r,snapGrid:i,nodesDraggable:a,onError:o,updateNodePositions:s,nodeLookup:c,nodeOrigin:l}=e.getState(),u=new Map,d=jO(a),f=r?i[0]:5,p=r?i[1]:5,m=t.direction.x*f*t.factor,h=t.direction.y*p*t.factor;for(let[,e]of c){if(!d(e))continue;let t={x:e.internals.positionAbsolute.x+m,y:e.internals.positionAbsolute.y+h};r&&(t=oT(t,i));let{position:a,positionAbsolute:s}=Uw({nodeId:e.id,nextPosition:t,nodeLookup:c,nodeExtent:n,nodeOrigin:l,onError:o});e.position=a,e.internals.positionAbsolute=s,u.set(e.id,e)}s(u)},[])}var NO=(0,y.createContext)(null),PO=NO.Provider;NO.Consumer;var FO=()=>(0,y.useContext)(NO),IO=e=>({connectOnClick:e.connectOnClick,noPanClassName:e.noPanClassName,rfId:e.rfId}),LO=(e,t,n)=>r=>{let{connectionClickStartHandle:i,connectionMode:a,connection:o}=r,{fromHandle:s,toHandle:c,isValid:l}=o,u=c?.nodeId===e&&c?.id===t&&c?.type===n;return{connectingFrom:s?.nodeId===e&&s?.id===t&&s?.type===n,connectingTo:u,clickConnecting:i?.nodeId===e&&i?.id===t&&i?.type===n,isPossibleEndHandle:a===Tw.Strict?s?.type!==n:e!==s?.nodeId||t!==s?.id,connectionInProcess:!!s,clickConnectionInProcess:!!i,valid:u&&l}};function RO({type:e=`source`,position:t=$.Top,isValidConnection:n,isConnectable:r=!0,isConnectableStart:i=!0,isConnectableEnd:a=!0,id:o,onConnect:s,children:c,className:l,onMouseDown:u,onTouchStart:d,...f},p){let m=o||null,h=e===`target`,g=xD(),_=FO(),{connectOnClick:v,noPanClassName:y,rfId:b}=bD(IO,gD),{connectingFrom:x,connectingTo:S,clickConnecting:C,isPossibleEndHandle:w,connectionInProcess:E,clickConnectionInProcess:D,valid:O}=bD(LO(_,m,e),gD);_||g.getState().onError?.(`010`,xw.error010());let k=e=>{let{defaultEdgeOptions:t,onConnect:n,hasDefaultEdges:r}=g.getState(),i={...t,...e};if(r){let{edges:e,setEdges:t}=g.getState();t(RT(i,e))}n?.(i),s?.(i)},A=e=>{if(!_)return;let t=ET(e.nativeEvent);if(i&&(t&&e.button===0||!t)){let t=g.getState();OE.onPointerDown(e.nativeEvent,{handleDomNode:e.currentTarget,autoPanOnConnect:t.autoPanOnConnect,connectionMode:t.connectionMode,connectionRadius:t.connectionRadius,domNode:t.domNode,nodeLookup:t.nodeLookup,lib:t.lib,isTarget:h,handleId:m,nodeId:_,flowId:t.rfId,panBy:t.panBy,cancelConnection:t.cancelConnection,onConnectStart:t.onConnectStart,onConnectEnd:t.onConnectEnd,updateConnection:t.updateConnection,onConnect:k,isValidConnection:n||t.isValidConnection,getTransform:()=>g.getState().transform,getFromHandle:()=>g.getState().connection.fromHandle,autoPanSpeed:t.autoPanSpeed,dragThreshold:t.connectionDragThreshold})}t?u?.(e):d?.(e)};return(0,T.jsx)(`div`,{"data-handleid":m,"data-nodeid":_,"data-handlepos":t,"data-id":`${b}-${_}-${m}-${e}`,className:Lv([`react-flow__handle`,`react-flow__handle-${t}`,`nodrag`,y,l,{source:!h,target:h,connectable:r,connectablestart:i,connectableend:a,clickconnecting:C,connectingfrom:x,connectingto:S,valid:O,connectionindicator:r&&(!E||w)&&(E||D?a:i)}]),onMouseDown:A,onTouchStart:A,onClick:v?t=>{let{onClickConnectStart:r,onClickConnectEnd:a,connectionClickStartHandle:o,connectionMode:s,isValidConnection:c,lib:l,rfId:u,nodeLookup:d,connection:f}=g.getState();if(!_||!o&&!i)return;if(!o){r?.(t.nativeEvent,{nodeId:_,handleId:m,handleType:e}),g.setState({connectionClickStartHandle:{nodeId:_,type:e,id:m}});return}let p=CT(t.target),h=n||c,{connection:v,isValid:y}=OE.isValid(t.nativeEvent,{handle:{nodeId:_,id:m,type:e},connectionMode:s,fromNodeId:o.nodeId,fromHandleId:o.id||null,fromType:o.type,isValidConnection:h,flowId:u,doc:p,lib:l,nodeLookup:d});y&&v&&k(v);let b=structuredClone(f);delete b.inProgress,b.toPosition=b.toHandle?b.toHandle.position:null,a?.(t,b),g.setState({connectionClickStartHandle:null})}:void 0,ref:p,...f,children:c})}var zO=(0,y.memo)(cO(RO));function BO({data:e,isConnectable:t,sourcePosition:n=$.Bottom}){return(0,T.jsxs)(T.Fragment,{children:[e?.label,(0,T.jsx)(zO,{type:`source`,position:n,isConnectable:t})]})}function VO({data:e,isConnectable:t,targetPosition:n=$.Top,sourcePosition:r=$.Bottom}){return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(zO,{type:`target`,position:n,isConnectable:t}),e?.label,(0,T.jsx)(zO,{type:`source`,position:r,isConnectable:t})]})}function HO(){return null}function UO({data:e,isConnectable:t,targetPosition:n=$.Top}){return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(zO,{type:`target`,position:n,isConnectable:t}),e?.label]})}var WO={ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0}},GO={input:BO,default:VO,output:UO,group:HO};function KO(e){return e.internals.handleBounds===void 0?{width:e.width??e.initialWidth??e.style?.width,height:e.height??e.initialHeight??e.style?.height}:{width:e.width??e.style?.width,height:e.height??e.style?.height}}var qO=e=>{let{width:t,height:n,x:r,y:i}=Rw(e.nodeLookup,{filter:e=>!!e.selected});return{width:iT(t)?t:null,height:iT(n)?n:null,userSelectionActive:e.userSelectionActive,transformString:`translate(${e.transform[0]}px,${e.transform[1]}px) scale(${e.transform[2]}) translate(${r}px,${i}px)`}};function JO({onSelectionContextMenu:e,noPanClassName:t,disableKeyboardA11y:n}){let r=xD(),{width:i,height:a,transformString:o,userSelectionActive:s}=bD(qO,gD),c=MO(),l=(0,y.useRef)(null);if((0,y.useEffect)(()=>{n||l.current?.focus({preventScroll:!0})},[n]),AO({nodeRef:l}),s||!i||!a)return null;let u=e?t=>{e(t,r.getState().nodes.filter(e=>e.selected))}:void 0;return(0,T.jsx)(`div`,{className:Lv([`react-flow__nodesselection`,`react-flow__container`,t]),style:{transform:o},children:(0,T.jsx)(`div`,{ref:l,className:`react-flow__nodesselection-rect`,onContextMenu:u,tabIndex:n?void 0:-1,onKeyDown:n?void 0:e=>{Object.prototype.hasOwnProperty.call(WO,e.key)&&(e.preventDefault(),c({direction:WO[e.key],factor:e.shiftKey?4:1}))},style:{width:i,height:a}})})}var YO=typeof window<`u`?window:void 0,XO=e=>({nodesSelectionActive:e.nodesSelectionActive,userSelectionActive:e.userSelectionActive});function ZO({children:e,onPaneClick:t,onPaneMouseEnter:n,onPaneMouseMove:r,onPaneMouseLeave:i,onPaneContextMenu:a,onPaneScroll:o,paneClickDistance:s,deleteKeyCode:c,selectionKeyCode:l,selectionOnDrag:u,selectionMode:d,onSelectionStart:f,onSelectionEnd:p,multiSelectionKeyCode:m,panActivationKeyCode:h,zoomActivationKeyCode:g,elementsSelectable:_,zoomOnScroll:v,zoomOnPinch:y,panOnScroll:b,panOnScrollSpeed:x,panOnScrollMode:S,zoomOnDoubleClick:C,panOnDrag:w,defaultViewport:E,translateExtent:D,minZoom:O,maxZoom:k,preventScrolling:A,onSelectionContextMenu:j,noWheelClassName:M,noPanClassName:N,disableKeyboardA11y:P,onViewportChange:ee,isControlledViewport:F}){let{nodesSelectionActive:I,userSelectionActive:L}=bD(XO),R=JD(l,{target:YO}),z=JD(h,{target:YO}),B=z||w,V=z||b,H=u&&B!==!0,U=R||L||H;return yO({deleteKeyCode:c,multiSelectionKeyCode:m}),(0,T.jsx)(CO,{onPaneContextMenu:a,elementsSelectable:_,zoomOnScroll:v,zoomOnPinch:y,panOnScroll:V,panOnScrollSpeed:x,panOnScrollMode:S,zoomOnDoubleClick:C,panOnDrag:!R&&B,defaultViewport:E,translateExtent:D,minZoom:O,maxZoom:k,zoomActivationKeyCode:g,preventScrolling:A,noWheelClassName:M,noPanClassName:N,onViewportChange:ee,isControlledViewport:F,paneClickDistance:s,children:(0,T.jsxs)(OO,{onSelectionStart:f,onSelectionEnd:p,onPaneClick:t,onPaneMouseEnter:n,onPaneMouseMove:r,onPaneMouseLeave:i,onPaneContextMenu:a,onPaneScroll:o,panOnDrag:B,isSelecting:!!U,selectionMode:d,selectionKeyPressed:R,selectionOnDrag:H,children:[e,I&&(0,T.jsx)(JO,{onSelectionContextMenu:j,noPanClassName:N,disableKeyboardA11y:P})]})})}ZO.displayName=`FlowRenderer`;var QO=(0,y.memo)(ZO),$O=e=>t=>e?zw(t.nodeLookup,{x:0,y:0,width:t.width,height:t.height},t.transform,!0).map(e=>e.id):Array.from(t.nodeLookup.keys());function ek(e){return bD((0,y.useCallback)($O(e),[e]),gD)}var tk=e=>e.updateNodeInternals;function nk(){let e=bD(tk),[t]=(0,y.useState)(()=>typeof ResizeObserver>`u`?null:new ResizeObserver(t=>{let n=new Map;t.forEach(e=>{let t=e.target.getAttribute(`data-id`);n.set(t,{id:t,nodeElement:e.target,force:!0})}),e(n)}));return(0,y.useEffect)(()=>()=>{t?.disconnect()},[t]),t}function rk({node:e,nodeType:t,hasDimensions:n,resizeObserver:r}){let i=xD(),a=(0,y.useRef)(null),o=(0,y.useRef)(null),s=(0,y.useRef)(e.sourcePosition),c=(0,y.useRef)(e.targetPosition),l=(0,y.useRef)(t),u=n&&!!e.internals.handleBounds;return(0,y.useEffect)(()=>{a.current&&!e.hidden&&(!u||o.current!==a.current)&&(o.current&&r?.unobserve(o.current),r?.observe(a.current),o.current=a.current)},[u,e.hidden]),(0,y.useEffect)(()=>()=>{o.current&&=(r?.unobserve(o.current),null)},[]),(0,y.useEffect)(()=>{if(a.current){let n=l.current!==t,r=s.current!==e.sourcePosition,o=c.current!==e.targetPosition;(n||r||o)&&(l.current=t,s.current=e.sourcePosition,c.current=e.targetPosition,i.getState().updateNodeInternals(new Map([[e.id,{id:e.id,nodeElement:a.current,force:!0}]])))}},[e.id,t,e.sourcePosition,e.targetPosition]),a}function ik({id:e,onClick:t,onMouseEnter:n,onMouseMove:r,onMouseLeave:i,onContextMenu:a,onDoubleClick:o,nodesDraggable:s,elementsSelectable:c,nodesConnectable:l,nodesFocusable:u,resizeObserver:d,noDragClassName:f,noPanClassName:p,disableKeyboardA11y:m,rfId:h,nodeTypes:g,nodeClickDistance:_,onError:v}){let{node:y,internals:b,isParent:x}=bD(t=>{let n=t.nodeLookup.get(e),r=t.parentLookup.has(e);return{node:n,internals:n.internals,isParent:r}},gD),S=y.type||`default`,C=g?.[S]||GO[S];C===void 0&&(v?.(`003`,xw.error003(S)),S=`default`,C=g?.default||GO.default);let w=!!(y.draggable||s&&y.draggable===void 0),E=!!(y.selectable||c&&y.selectable===void 0),D=!!(y.connectable||l&&y.connectable===void 0),O=!!(y.focusable||u&&y.focusable===void 0),k=xD(),A=gT(y),j=rk({node:y,nodeType:S,hasDimensions:A,resizeObserver:d}),M=AO({nodeRef:j,disabled:y.hidden||!w,noDragClassName:f,handleSelector:y.dragHandle,nodeId:e,isSelectable:E,nodeClickDistance:_}),N=MO();if(y.hidden)return null;let P=hT(y),ee=KO(y),F=E||w||t||n||r||i,I=n?e=>n(e,{...b.userNode}):void 0,L=r?e=>r(e,{...b.userNode}):void 0,R=i?e=>i(e,{...b.userNode}):void 0,z=a?e=>a(e,{...b.userNode}):void 0,B=o?e=>o(e,{...b.userNode}):void 0,V=n=>{let{selectNodesOnDrag:r,nodeDragThreshold:i}=k.getState();E&&(!r||!w||i>0)&&kO({id:e,store:k,nodeRef:j}),t&&t(n,{...b.userNode})},H=t=>{if(!(TT(t.nativeEvent)||m)){if(Cw.includes(t.key)&&E)kO({id:e,store:k,unselect:t.key===`Escape`,nodeRef:j});else if(w&&y.selected&&Object.prototype.hasOwnProperty.call(WO,t.key)){t.preventDefault();let{ariaLabelConfig:e}=k.getState();k.setState({ariaLiveMessage:e[`node.a11yDescription.ariaLiveMessage`]({direction:t.key.replace(`Arrow`,``).toLowerCase(),x:~~b.positionAbsolute.x,y:~~b.positionAbsolute.y})}),N({direction:WO[t.key],factor:t.shiftKey?4:1})}}},U=()=>{if(m||!j.current?.matches(`:focus-visible`))return;let{transform:t,width:n,height:r,autoPanOnNodeFocus:i,setCenter:a}=k.getState();i&&(zw(new Map([[e,y]]),{x:0,y:0,width:n,height:r},t,!0).length>0||a(y.position.x+P.width/2,y.position.y+P.height/2,{zoom:t[2]}))};return(0,T.jsx)(`div`,{className:Lv([`react-flow__node`,`react-flow__node-${S}`,{[p]:w},y.className,{selected:y.selected,selectable:E,parent:x,draggable:w,dragging:M}]),ref:j,style:{zIndex:b.z,transform:`translate(${b.positionAbsolute.x}px,${b.positionAbsolute.y}px)`,pointerEvents:F?`all`:`none`,visibility:A?`visible`:`hidden`,...y.style,...ee},"data-id":e,"data-testid":`rf__node-${e}`,onMouseEnter:I,onMouseMove:L,onMouseLeave:R,onContextMenu:z,onClick:V,onDoubleClick:B,onKeyDown:O?H:void 0,tabIndex:O?0:void 0,onFocus:O?U:void 0,role:y.ariaRole??(O?`group`:void 0),"aria-roledescription":`node`,"aria-describedby":m?void 0:`${wD}-${h}`,"aria-label":y.ariaLabel,...y.domAttributes,children:(0,T.jsx)(PO,{value:e,children:(0,T.jsx)(C,{id:e,data:y.data,type:S,positionAbsoluteX:b.positionAbsolute.x,positionAbsoluteY:b.positionAbsolute.y,selected:y.selected??!1,selectable:E,draggable:w,deletable:y.deletable??!0,isConnectable:D,sourcePosition:y.sourcePosition,targetPosition:y.targetPosition,dragging:M,dragHandle:y.dragHandle,zIndex:b.z,parentId:y.parentId,...P})})})}var ak=e=>({nodesDraggable:e.nodesDraggable,nodesConnectable:e.nodesConnectable,nodesFocusable:e.nodesFocusable,elementsSelectable:e.elementsSelectable,onError:e.onError});function ok(e){let{nodesDraggable:t,nodesConnectable:n,nodesFocusable:r,elementsSelectable:i,onError:a}=bD(ak,gD),o=ek(e.onlyRenderVisibleElements),s=nk();return(0,T.jsx)(`div`,{className:`react-flow__nodes`,style:xO,children:o.map(o=>(0,T.jsx)(ik,{id:o,nodeTypes:e.nodeTypes,nodeExtent:e.nodeExtent,onClick:e.onNodeClick,onMouseEnter:e.onNodeMouseEnter,onMouseMove:e.onNodeMouseMove,onMouseLeave:e.onNodeMouseLeave,onContextMenu:e.onNodeContextMenu,onDoubleClick:e.onNodeDoubleClick,noDragClassName:e.noDragClassName,noPanClassName:e.noPanClassName,rfId:e.rfId,disableKeyboardA11y:e.disableKeyboardA11y,resizeObserver:s,nodesDraggable:t,nodesConnectable:n,nodesFocusable:r,elementsSelectable:i,nodeClickDistance:e.nodeClickDistance,onError:a},o))})}ok.displayName=`NodeRenderer`;var sk=(0,y.memo)(ok);function ck(e){return bD((0,y.useCallback)(t=>{if(!e)return t.edges.map(e=>e.id);let n=[];if(t.width&&t.height)for(let e of t.edges){let r=t.nodeLookup.get(e.source),i=t.nodeLookup.get(e.target);r&&i&&FT({sourceNode:r,targetNode:i,width:t.width,height:t.height,transform:t.transform})&&n.push(e.id)}return n},[e]),gD)}var lk=({color:e=`none`,strokeWidth:t=1})=>(0,T.jsx)(`polyline`,{className:`arrow`,style:{strokeWidth:t,...e&&{stroke:e}},strokeLinecap:`round`,fill:`none`,strokeLinejoin:`round`,points:`-5,-4 0,0 -5,4`}),uk=({color:e=`none`,strokeWidth:t=1})=>(0,T.jsx)(`polyline`,{className:`arrowclosed`,style:{strokeWidth:t,...e&&{stroke:e,fill:e}},strokeLinecap:`round`,strokeLinejoin:`round`,points:`-5,-4 0,0 -5,4 -5,-4`}),dk={[Aw.Arrow]:lk,[Aw.ArrowClosed]:uk};function fk(e){let t=xD();return(0,y.useMemo)(()=>Object.prototype.hasOwnProperty.call(dk,e)?dk[e]:(t.getState().onError?.(`009`,xw.error009(e)),null),[e])}var pk=({id:e,type:t,color:n,width:r=12.5,height:i=12.5,markerUnits:a=`strokeWidth`,strokeWidth:o,orient:s=`auto-start-reverse`})=>{let c=fk(t);return c?(0,T.jsx)(`marker`,{className:`react-flow__arrowhead`,id:e,markerWidth:`${r}`,markerHeight:`${i}`,viewBox:`-10 -10 20 20`,markerUnits:a,orient:s,refX:`0`,refY:`0`,children:(0,T.jsx)(c,{color:n,strokeWidth:o})}):null},mk=({defaultColor:e,rfId:t})=>{let n=bD(e=>e.edges),r=bD(e=>e.defaultEdgeOptions),i=(0,y.useMemo)(()=>QT(n,{id:t,defaultColor:e,defaultMarkerStart:r?.markerStart,defaultMarkerEnd:r?.markerEnd}),[n,r,t,e]);return i.length?(0,T.jsx)(`svg`,{className:`react-flow__marker`,"aria-hidden":`true`,children:(0,T.jsx)(`defs`,{children:i.map(e=>(0,T.jsx)(pk,{id:e.id,type:e.type,color:e.color,width:e.width,height:e.height,markerUnits:e.markerUnits,strokeWidth:e.strokeWidth,orient:e.orient},e.id))})}):null};mk.displayName=`MarkerDefinitions`;var hk=(0,y.memo)(mk);function gk({x:e,y:t,label:n,labelStyle:r,labelShowBg:i=!0,labelBgStyle:a,labelBgPadding:o=[2,4],labelBgBorderRadius:s=2,children:c,className:l,...u}){let[d,f]=(0,y.useState)({x:1,y:0,width:0,height:0}),p=Lv([`react-flow__edge-textwrapper`,l]),m=(0,y.useRef)(null);return(0,y.useEffect)(()=>{if(m.current){let e=m.current.getBBox();f({x:e.x,y:e.y,width:e.width,height:e.height})}},[n]),n?(0,T.jsxs)(`g`,{transform:`translate(${e-d.width/2} ${t-d.height/2})`,className:p,visibility:d.width?`visible`:`hidden`,...u,children:[i&&(0,T.jsx)(`rect`,{width:d.width+2*o[0],x:-o[0],y:-o[1],height:d.height+2*o[1],className:`react-flow__edge-textbg`,style:a,rx:s,ry:s}),(0,T.jsx)(`text`,{className:`react-flow__edge-text`,y:d.height/2,dy:`0.3em`,ref:m,style:r,children:n}),c]}):null}gk.displayName=`EdgeText`;var _k=(0,y.memo)(gk);function vk({path:e,labelX:t,labelY:n,label:r,labelStyle:i,labelShowBg:a,labelBgStyle:o,labelBgPadding:s,labelBgBorderRadius:c,interactionWidth:l=20,...u}){return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`path`,{...u,d:e,fill:`none`,className:Lv([`react-flow__edge-path`,u.className])}),l?(0,T.jsx)(`path`,{d:e,fill:`none`,strokeOpacity:0,strokeWidth:l,className:`react-flow__edge-interaction`}):null,r&&iT(t)&&iT(n)?(0,T.jsx)(_k,{x:t,y:n,label:r,labelStyle:i,labelShowBg:a,labelBgStyle:o,labelBgPadding:s,labelBgBorderRadius:c}):null]})}function yk({pos:e,x1:t,y1:n,x2:r,y2:i}){return e===$.Left||e===$.Right?[.5*(t+r),n]:[t,.5*(n+i)]}function bk({sourceX:e,sourceY:t,sourcePosition:n=$.Bottom,targetX:r,targetY:i,targetPosition:a=$.Top}){let[o,s]=yk({pos:n,x1:e,y1:t,x2:r,y2:i}),[c,l]=yk({pos:a,x1:r,y1:i,x2:e,y2:t}),[u,d,f,p]=kT({sourceX:e,sourceY:t,targetX:r,targetY:i,sourceControlX:o,sourceControlY:s,targetControlX:c,targetControlY:l});return[`M${e},${t} C${o},${s} ${c},${l} ${r},${i}`,u,d,f,p]}function xk(e){return(0,y.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,sourcePosition:o,targetPosition:s,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:_})=>{let[v,y,b]=bk({sourceX:n,sourceY:r,sourcePosition:o,targetX:i,targetY:a,targetPosition:s});return(0,T.jsx)(vk,{id:e.isInternal?void 0:t,path:v,labelX:y,labelY:b,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:_})})}var Sk=xk({isInternal:!1}),Ck=xk({isInternal:!0});Sk.displayName=`SimpleBezierEdge`,Ck.displayName=`SimpleBezierEdgeInternal`;function wk(e){return(0,y.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,sourcePosition:p=$.Bottom,targetPosition:m=$.Top,markerEnd:h,markerStart:g,pathOptions:_,interactionWidth:v})=>{let[y,b,x]=GT({sourceX:n,sourceY:r,sourcePosition:p,targetX:i,targetY:a,targetPosition:m,borderRadius:_?.borderRadius,offset:_?.offset,stepPosition:_?.stepPosition});return(0,T.jsx)(vk,{id:e.isInternal?void 0:t,path:y,labelX:b,labelY:x,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:h,markerStart:g,interactionWidth:v})})}var Tk=wk({isInternal:!1}),Ek=wk({isInternal:!0});Tk.displayName=`SmoothStepEdge`,Ek.displayName=`SmoothStepEdgeInternal`;function Dk(e){return(0,y.memo)(({id:t,...n})=>{let r=e.isInternal?void 0:t;return(0,T.jsx)(Tk,{...n,id:r,pathOptions:(0,y.useMemo)(()=>({borderRadius:0,offset:n.pathOptions?.offset}),[n.pathOptions?.offset])})})}var Ok=Dk({isInternal:!1}),kk=Dk({isInternal:!0});Ok.displayName=`StepEdge`,kk.displayName=`StepEdgeInternal`;function Ak(e){return(0,y.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:p,markerStart:m,interactionWidth:h})=>{let[g,_,v]=zT({sourceX:n,sourceY:r,targetX:i,targetY:a});return(0,T.jsx)(vk,{id:e.isInternal?void 0:t,path:g,labelX:_,labelY:v,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:p,markerStart:m,interactionWidth:h})})}var jk=Ak({isInternal:!1}),Mk=Ak({isInternal:!0});jk.displayName=`StraightEdge`,Mk.displayName=`StraightEdgeInternal`;function Nk(e){return(0,y.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,sourcePosition:o=$.Bottom,targetPosition:s=$.Top,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,pathOptions:_,interactionWidth:v})=>{let[y,b,x]=MT({sourceX:n,sourceY:r,sourcePosition:o,targetX:i,targetY:a,targetPosition:s,curvature:_?.curvature});return(0,T.jsx)(vk,{id:e.isInternal?void 0:t,path:y,labelX:b,labelY:x,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:v})})}var Pk=Nk({isInternal:!1}),Fk=Nk({isInternal:!0});Pk.displayName=`BezierEdge`,Fk.displayName=`BezierEdgeInternal`;var Ik={default:Fk,straight:Mk,step:kk,smoothstep:Ek,simplebezier:Ck},Lk={sourceX:null,sourceY:null,targetX:null,targetY:null,sourcePosition:null,targetPosition:null},Rk=(e,t,n)=>n===$.Left?e-t:n===$.Right?e+t:e,zk=(e,t,n)=>n===$.Top?e-t:n===$.Bottom?e+t:e,Bk=`react-flow__edgeupdater`;function Vk({position:e,centerX:t,centerY:n,radius:r=10,onMouseDown:i,onMouseEnter:a,onMouseOut:o,type:s}){return(0,T.jsx)(`circle`,{onMouseDown:i,onMouseEnter:a,onMouseOut:o,className:Lv([Bk,`${Bk}-${s}`]),cx:Rk(t,r,e),cy:zk(n,r,e),r,stroke:`transparent`,fill:`transparent`})}function Hk({isReconnectable:e,reconnectRadius:t,edge:n,sourceX:r,sourceY:i,targetX:a,targetY:o,sourcePosition:s,targetPosition:c,onReconnect:l,onReconnectStart:u,onReconnectEnd:d,setReconnecting:f,setUpdateHover:p}){let m=xD(),h=(e,t)=>{if(e.button!==0)return;let{autoPanOnConnect:r,domNode:i,isValidConnection:a,connectionMode:o,connectionRadius:s,lib:c,onConnectStart:p,onConnectEnd:h,cancelConnection:g,nodeLookup:_,rfId:v,panBy:y,updateConnection:b}=m.getState(),x=t.type===`target`;OE.onPointerDown(e.nativeEvent,{autoPanOnConnect:r,connectionMode:o,connectionRadius:s,domNode:i,handleId:t.id,nodeId:t.nodeId,nodeLookup:_,isTarget:x,edgeUpdaterType:t.type,lib:c,flowId:v,cancelConnection:g,panBy:y,isValidConnection:a,onConnect:e=>l?.(n,e),onConnectStart:(r,i)=>{f(!0),u?.(e,n,t.type),p?.(r,i)},onConnectEnd:h,onReconnectEnd:(e,r)=>{f(!1),d?.(e,n,t.type,r)},updateConnection:b,getTransform:()=>m.getState().transform,getFromHandle:()=>m.getState().connection.fromHandle,dragThreshold:m.getState().connectionDragThreshold,handleDomNode:e.currentTarget})},g=e=>h(e,{nodeId:n.target,id:n.targetHandle??null,type:`target`}),_=e=>h(e,{nodeId:n.source,id:n.sourceHandle??null,type:`source`}),v=()=>p(!0),y=()=>p(!1);return(0,T.jsxs)(T.Fragment,{children:[(e===!0||e===`source`)&&(0,T.jsx)(Vk,{position:s,centerX:r,centerY:i,radius:t,onMouseDown:g,onMouseEnter:v,onMouseOut:y,type:`source`}),(e===!0||e===`target`)&&(0,T.jsx)(Vk,{position:c,centerX:a,centerY:o,radius:t,onMouseDown:_,onMouseEnter:v,onMouseOut:y,type:`target`})]})}function Uk({id:e,edgesFocusable:t,edgesReconnectable:n,elementsSelectable:r,onClick:i,onDoubleClick:a,onContextMenu:o,onMouseEnter:s,onMouseMove:c,onMouseLeave:l,reconnectRadius:u,onReconnect:d,onReconnectStart:f,onReconnectEnd:p,rfId:m,edgeTypes:h,noPanClassName:g,onError:_,disableKeyboardA11y:v}){let b=bD(t=>t.edgeLookup.get(e)),x=bD(e=>e.defaultEdgeOptions);b=x?{...x,...b}:b;let S=b.type||`default`,C=h?.[S]||Ik[S];C===void 0&&(_?.(`011`,xw.error011(S)),S=`default`,C=h?.default||Ik.default);let w=!!(b.focusable||t&&b.focusable===void 0),E=d!==void 0&&(b.reconnectable||n&&b.reconnectable===void 0),D=!!(b.selectable||r&&b.selectable===void 0),O=(0,y.useRef)(null),[k,A]=(0,y.useState)(!1),[j,M]=(0,y.useState)(!1),N=xD(),{zIndex:P,sourceX:ee,sourceY:F,targetX:I,targetY:L,sourcePosition:R,targetPosition:z}=bD((0,y.useCallback)(t=>{let n=t.nodeLookup.get(b.source),r=t.nodeLookup.get(b.target);if(!n||!r)return{zIndex:b.zIndex,...Lk};let i=qT({id:e,sourceNode:n,targetNode:r,sourceHandle:b.sourceHandle||null,targetHandle:b.targetHandle||null,connectionMode:t.connectionMode,onError:_});return{zIndex:PT({selected:b.selected,zIndex:b.zIndex,sourceNode:n,targetNode:r,elevateOnSelect:t.elevateEdgesOnSelect}),...i||Lk}},[b.source,b.target,b.sourceHandle,b.targetHandle,b.selected,b.zIndex]),gD),B=(0,y.useMemo)(()=>b.markerStart?`url('#${ZT(b.markerStart,m)}')`:void 0,[b.markerStart,m]),V=(0,y.useMemo)(()=>b.markerEnd?`url('#${ZT(b.markerEnd,m)}')`:void 0,[b.markerEnd,m]);if(b.hidden||ee===null||F===null||I===null||L===null)return null;let H=t=>{let{addSelectedEdges:n,unselectNodesAndEdges:r,multiSelectionActive:a}=N.getState();D&&(N.setState({nodesSelectionActive:!1}),b.selected&&a?(r({nodes:[],edges:[b]}),O.current?.blur()):n([e])),i&&i(t,b)},U=a?e=>{a(e,{...b})}:void 0,te=o?e=>{o(e,{...b})}:void 0,ne=s?e=>{s(e,{...b})}:void 0,re=c?e=>{c(e,{...b})}:void 0,ie=l?e=>{l(e,{...b})}:void 0;return(0,T.jsx)(`svg`,{style:{zIndex:P},children:(0,T.jsxs)(`g`,{className:Lv([`react-flow__edge`,`react-flow__edge-${S}`,b.className,g,{selected:b.selected,animated:b.animated,inactive:!D&&!i,updating:k,selectable:D}]),onClick:H,onDoubleClick:U,onContextMenu:te,onMouseEnter:ne,onMouseMove:re,onMouseLeave:ie,onKeyDown:w?t=>{if(!v&&Cw.includes(t.key)&&D){let{unselectNodesAndEdges:n,addSelectedEdges:r}=N.getState();t.key===`Escape`?(O.current?.blur(),n({edges:[b]})):r([e])}}:void 0,tabIndex:w?0:void 0,role:b.ariaRole??(w?`group`:`img`),"aria-roledescription":`edge`,"data-id":e,"data-testid":`rf__edge-${e}`,"aria-label":b.ariaLabel===null?void 0:b.ariaLabel||`Edge from ${b.source} to ${b.target}`,"aria-describedby":w?`${TD}-${m}`:void 0,ref:O,...b.domAttributes,children:[!j&&(0,T.jsx)(C,{id:e,source:b.source,target:b.target,type:b.type,selected:b.selected,animated:b.animated,selectable:D,deletable:b.deletable??!0,label:b.label,labelStyle:b.labelStyle,labelShowBg:b.labelShowBg,labelBgStyle:b.labelBgStyle,labelBgPadding:b.labelBgPadding,labelBgBorderRadius:b.labelBgBorderRadius,sourceX:ee,sourceY:F,targetX:I,targetY:L,sourcePosition:R,targetPosition:z,data:b.data,style:b.style,sourceHandleId:b.sourceHandle,targetHandleId:b.targetHandle,markerStart:B,markerEnd:V,pathOptions:`pathOptions`in b?b.pathOptions:void 0,interactionWidth:b.interactionWidth}),E&&(0,T.jsx)(Hk,{edge:b,isReconnectable:E,reconnectRadius:u,onReconnect:d,onReconnectStart:f,onReconnectEnd:p,sourceX:ee,sourceY:F,targetX:I,targetY:L,sourcePosition:R,targetPosition:z,setUpdateHover:A,setReconnecting:M})]})})}var Wk=e=>({edgesFocusable:e.edgesFocusable,edgesReconnectable:e.edgesReconnectable,elementsSelectable:e.elementsSelectable,connectionMode:e.connectionMode,onError:e.onError});function Gk({defaultMarkerColor:e,onlyRenderVisibleElements:t,rfId:n,edgeTypes:r,noPanClassName:i,onReconnect:a,onEdgeContextMenu:o,onEdgeMouseEnter:s,onEdgeMouseMove:c,onEdgeMouseLeave:l,onEdgeClick:u,reconnectRadius:d,onEdgeDoubleClick:f,onReconnectStart:p,onReconnectEnd:m,disableKeyboardA11y:h}){let{edgesFocusable:g,edgesReconnectable:_,elementsSelectable:v,onError:y}=bD(Wk,gD),b=ck(t);return(0,T.jsxs)(`div`,{className:`react-flow__edges`,children:[(0,T.jsx)(hk,{defaultColor:e,rfId:n}),b.map(e=>(0,T.jsx)(Uk,{id:e,edgesFocusable:g,edgesReconnectable:_,elementsSelectable:v,noPanClassName:i,onReconnect:a,onContextMenu:o,onMouseEnter:s,onMouseMove:c,onMouseLeave:l,onClick:u,reconnectRadius:d,onDoubleClick:f,onReconnectStart:p,onReconnectEnd:m,rfId:n,onError:y,edgeTypes:r,disableKeyboardA11y:h},e))]})}Gk.displayName=`EdgeRenderer`;var Kk=(0,y.memo)(Gk),qk=e=>`translate(${e.transform[0]}px,${e.transform[1]}px) scale(${e.transform[2]})`;function Jk({children:e}){return(0,T.jsx)(`div`,{className:`react-flow__viewport xyflow__viewport react-flow__container`,style:{transform:bD(qk)},children:e})}function Yk(e){let t=gO(),n=(0,y.useRef)(!1);(0,y.useEffect)(()=>{!n.current&&t.viewportInitialized&&e&&(setTimeout(()=>e(t),1),n.current=!0)},[e,t.viewportInitialized])}var Xk=e=>e.panZoom?.syncViewport;function Zk(e){let t=bD(Xk),n=xD();return(0,y.useEffect)(()=>{e&&(t?.(e),n.setState({transform:[e.x,e.y,e.zoom]}))},[e,t]),null}function Qk(e){return e.connection.inProgress?{...e.connection,to:sT(e.connection.to,e.transform)}:{...e.connection}}function $k(e){return e?t=>e(Qk(t)):Qk}function eA(e){return bD($k(e),gD)}var tA=e=>({nodesConnectable:e.nodesConnectable,isValid:e.connection.isValid,inProgress:e.connection.inProgress,width:e.width,height:e.height});function nA({containerStyle:e,style:t,type:n,component:r}){let{nodesConnectable:i,width:a,height:o,isValid:s,inProgress:c}=bD(tA,gD);return a&&i&&c?(0,T.jsx)(`svg`,{style:e,width:a,height:o,className:`react-flow__connectionline react-flow__container`,children:(0,T.jsx)(`g`,{className:Lv([`react-flow__connection`,Mw(s)]),children:(0,T.jsx)(rA,{style:t,type:n,CustomComponent:r,isValid:s})})}):null}var rA=({style:e,type:t=kw.Bezier,CustomComponent:n,isValid:r})=>{let{inProgress:i,from:a,fromNode:o,fromHandle:s,fromPosition:c,to:l,toNode:u,toHandle:d,toPosition:f}=eA();if(!i)return;if(n)return(0,T.jsx)(n,{connectionLineType:t,connectionLineStyle:e,fromNode:o,fromHandle:s,fromX:a.x,fromY:a.y,toX:l.x,toY:l.y,fromPosition:c,toPosition:f,connectionStatus:Mw(r),toNode:u,toHandle:d});let p=``,m={sourceX:a.x,sourceY:a.y,sourcePosition:c,targetX:l.x,targetY:l.y,targetPosition:f};switch(t){case kw.Bezier:[p]=MT(m);break;case kw.SimpleBezier:[p]=bk(m);break;case kw.Step:[p]=GT({...m,borderRadius:0});break;case kw.SmoothStep:[p]=GT(m);break;default:[p]=zT(m)}return(0,T.jsx)(`path`,{d:p,fill:`none`,className:`react-flow__connection-path`,style:e})};rA.displayName=`ConnectionLine`;var iA={};function aA(e=iA){(0,y.useRef)(e),xD(),(0,y.useEffect)(()=>{},[e])}function oA(){xD(),(0,y.useRef)(!1),(0,y.useEffect)(()=>{},[])}function sA({nodeTypes:e,edgeTypes:t,onInit:n,onNodeClick:r,onEdgeClick:i,onNodeDoubleClick:a,onEdgeDoubleClick:o,onNodeMouseEnter:s,onNodeMouseMove:c,onNodeMouseLeave:l,onNodeContextMenu:u,onSelectionContextMenu:d,onSelectionStart:f,onSelectionEnd:p,connectionLineType:m,connectionLineStyle:h,connectionLineComponent:g,connectionLineContainerStyle:_,selectionKeyCode:v,selectionOnDrag:y,selectionMode:b,multiSelectionKeyCode:x,panActivationKeyCode:S,zoomActivationKeyCode:C,deleteKeyCode:w,onlyRenderVisibleElements:E,elementsSelectable:D,defaultViewport:O,translateExtent:k,minZoom:A,maxZoom:j,preventScrolling:M,defaultMarkerColor:N,zoomOnScroll:P,zoomOnPinch:ee,panOnScroll:F,panOnScrollSpeed:I,panOnScrollMode:L,zoomOnDoubleClick:R,panOnDrag:z,onPaneClick:B,onPaneMouseEnter:V,onPaneMouseMove:H,onPaneMouseLeave:U,onPaneScroll:te,onPaneContextMenu:ne,paneClickDistance:re,nodeClickDistance:ie,onEdgeContextMenu:ae,onEdgeMouseEnter:oe,onEdgeMouseMove:se,onEdgeMouseLeave:ce,reconnectRadius:le,onReconnect:ue,onReconnectStart:de,onReconnectEnd:fe,noDragClassName:pe,noWheelClassName:me,noPanClassName:he,disableKeyboardA11y:ge,nodeExtent:_e,rfId:ve,viewport:ye,onViewportChange:be}){return aA(e),aA(t),oA(),Yk(n),Zk(ye),(0,T.jsx)(QO,{onPaneClick:B,onPaneMouseEnter:V,onPaneMouseMove:H,onPaneMouseLeave:U,onPaneContextMenu:ne,onPaneScroll:te,paneClickDistance:re,deleteKeyCode:w,selectionKeyCode:v,selectionOnDrag:y,selectionMode:b,onSelectionStart:f,onSelectionEnd:p,multiSelectionKeyCode:x,panActivationKeyCode:S,zoomActivationKeyCode:C,elementsSelectable:D,zoomOnScroll:P,zoomOnPinch:ee,zoomOnDoubleClick:R,panOnScroll:F,panOnScrollSpeed:I,panOnScrollMode:L,panOnDrag:z,defaultViewport:O,translateExtent:k,minZoom:A,maxZoom:j,onSelectionContextMenu:d,preventScrolling:M,noDragClassName:pe,noWheelClassName:me,noPanClassName:he,disableKeyboardA11y:ge,onViewportChange:be,isControlledViewport:!!ye,children:(0,T.jsxs)(Jk,{children:[(0,T.jsx)(Kk,{edgeTypes:t,onEdgeClick:i,onEdgeDoubleClick:o,onReconnect:ue,onReconnectStart:de,onReconnectEnd:fe,onlyRenderVisibleElements:E,onEdgeContextMenu:ae,onEdgeMouseEnter:oe,onEdgeMouseMove:se,onEdgeMouseLeave:ce,reconnectRadius:le,defaultMarkerColor:N,noPanClassName:he,disableKeyboardA11y:ge,rfId:ve}),(0,T.jsx)(nA,{style:h,type:m,component:g,containerStyle:_}),(0,T.jsx)(`div`,{className:`react-flow__edgelabel-renderer`}),(0,T.jsx)(sk,{nodeTypes:e,onNodeClick:r,onNodeDoubleClick:a,onNodeMouseEnter:s,onNodeMouseMove:c,onNodeMouseLeave:l,onNodeContextMenu:u,nodeClickDistance:ie,onlyRenderVisibleElements:E,noPanClassName:he,noDragClassName:pe,disableKeyboardA11y:ge,nodeExtent:_e,rfId:ve}),(0,T.jsx)(`div`,{className:`react-flow__viewport-portal`})]})})}sA.displayName=`GraphView`;var cA=(0,y.memo)(sA),lA=({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c=.5,maxZoom:l=2,nodeOrigin:u,nodeExtent:d}={})=>{let f=new Map,p=new Map,m=new Map,h=new Map,g=r??t??[],_=n??e??[],v=u??[0,0],y=d??Sw;fE(m,h,g);let b=rE(_,f,p,{nodeOrigin:v,nodeExtent:y,elevateNodesOnSelect:!1}),x=[0,0,1];if(o&&i&&a){let{x:e,y:t,zoom:n}=fT(Rw(f,{filter:e=>!!((e.width||e.initialWidth)&&(e.height||e.initialHeight))}),i,a,c,l,s?.padding??.1);x=[e,t,n]}return{rfId:`1`,width:0,height:0,transform:x,nodes:_,nodesInitialized:b,nodeLookup:f,parentLookup:p,edges:g,edgeLookup:h,connectionLookup:m,onNodesChange:null,onEdgesChange:null,hasDefaultNodes:n!==void 0,hasDefaultEdges:r!==void 0,panZoom:null,minZoom:c,maxZoom:l,translateExtent:Sw,nodeExtent:y,nodesSelectionActive:!1,userSelectionActive:!1,userSelectionRect:null,connectionMode:Tw.Strict,domNode:null,paneDragging:!1,noPanClassName:`nopan`,nodeOrigin:v,nodeDragThreshold:1,connectionDragThreshold:1,snapGrid:[15,15],snapToGrid:!1,nodesDraggable:!0,nodesConnectable:!0,nodesFocusable:!0,edgesFocusable:!0,edgesReconnectable:!0,elementsSelectable:!0,elevateNodesOnSelect:!0,elevateEdgesOnSelect:!1,selectNodesOnDrag:!0,multiSelectionActive:!1,fitViewQueued:o??!1,fitViewOptions:s,fitViewResolver:null,connection:{...Ow},connectionClickStartHandle:null,connectOnClick:!0,ariaLiveMessage:``,autoPanOnConnect:!0,autoPanOnNodeDrag:!0,autoPanOnNodeFocus:!0,autoPanSpeed:15,connectionRadius:20,onError:aT,isValidConnection:void 0,onSelectionChangeHandlers:[],lib:`react`,debug:!1,ariaLabelConfig:ww}},uA=({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c,maxZoom:l,nodeOrigin:u,nodeExtent:d})=>hD((f,p)=>{async function m(){let{nodeLookup:e,panZoom:t,fitViewOptions:n,fitViewResolver:r,width:i,height:a,minZoom:o,maxZoom:s}=p();t&&(await Hw({nodes:e,width:i,height:a,panZoom:t,minZoom:o,maxZoom:s},n),r?.resolve(!0),f({fitViewResolver:null}))}return{...lA({nodes:e,edges:t,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c,maxZoom:l,nodeOrigin:u,nodeExtent:d,defaultNodes:n,defaultEdges:r}),setNodes:e=>{let{nodeLookup:t,parentLookup:n,nodeOrigin:r,elevateNodesOnSelect:i,fitViewQueued:a}=p(),o=rE(e,t,n,{nodeOrigin:r,nodeExtent:d,elevateNodesOnSelect:i,checkEquality:!0});a&&o?(m(),f({nodes:e,nodesInitialized:o,fitViewQueued:!1,fitViewOptions:void 0})):f({nodes:e,nodesInitialized:o})},setEdges:e=>{let{connectionLookup:t,edgeLookup:n}=p();fE(t,n,e),f({edges:e})},setDefaultNodesAndEdges:(e,t)=>{if(e){let{setNodes:t}=p();t(e),f({hasDefaultNodes:!0})}if(t){let{setEdges:e}=p();e(t),f({hasDefaultEdges:!0})}},updateNodeInternals:e=>{let{triggerNodeChanges:t,nodeLookup:n,parentLookup:r,domNode:i,nodeOrigin:a,nodeExtent:o,debug:s,fitViewQueued:c}=p(),{changes:l,updatedInternals:u}=lE(e,n,r,i,a,o);u&&(nE(n,r,{nodeOrigin:a,nodeExtent:o}),c?(m(),f({fitViewQueued:!1,fitViewOptions:void 0})):f({}),l?.length>0&&(s&&console.log(`React Flow: trigger node changes`,l),t?.(l)))},updateNodePositions:(e,t=!1)=>{let n=[],r=[],{nodeLookup:i,triggerNodeChanges:a}=p();for(let[a,o]of e){let e=i.get(a),s=!!(e?.expandParent&&e?.parentId&&o?.position),c={id:a,type:`position`,position:s?{x:Math.max(0,o.position.x),y:Math.max(0,o.position.y)}:o.position,dragging:t};s&&e.parentId&&n.push({id:a,parentId:e.parentId,rect:{...o.internals.positionAbsolute,width:o.measured.width??0,height:o.measured.height??0}}),r.push(c)}if(n.length>0){let{parentLookup:e,nodeOrigin:t}=p(),a=cE(n,i,e,t);r.push(...a)}a(r)},triggerNodeChanges:e=>{let{onNodesChange:t,setNodes:n,nodes:r,hasDefaultNodes:i,debug:a}=p();e?.length&&(i&&n(eO(e,r)),a&&console.log(`React Flow: trigger node changes`,e),t?.(e))},triggerEdgeChanges:e=>{let{onEdgesChange:t,setEdges:n,edges:r,hasDefaultEdges:i,debug:a}=p();e?.length&&(i&&n(tO(e,r)),a&&console.log(`React Flow: trigger edge changes`,e),t?.(e))},addSelectedNodes:e=>{let{multiSelectionActive:t,edgeLookup:n,nodeLookup:r,triggerNodeChanges:i,triggerEdgeChanges:a}=p();if(t){i(e.map(e=>nO(e,!0)));return}i(rO(r,new Set([...e]),!0)),a(rO(n))},addSelectedEdges:e=>{let{multiSelectionActive:t,edgeLookup:n,nodeLookup:r,triggerNodeChanges:i,triggerEdgeChanges:a}=p();if(t){a(e.map(e=>nO(e,!0)));return}a(rO(n,new Set([...e]))),i(rO(r,new Set,!0))},unselectNodesAndEdges:({nodes:e,edges:t}={})=>{let{edges:n,nodes:r,nodeLookup:i,triggerNodeChanges:a,triggerEdgeChanges:o}=p(),s=e||r,c=t||n,l=s.map(e=>{let t=i.get(e.id);return t&&(t.selected=!1),nO(e.id,!1)}),u=c.map(e=>nO(e.id,!1));a(l),o(u)},setMinZoom:e=>{let{panZoom:t,maxZoom:n}=p();t?.setScaleExtent([e,n]),f({minZoom:e})},setMaxZoom:e=>{let{panZoom:t,minZoom:n}=p();t?.setScaleExtent([n,e]),f({maxZoom:e})},setTranslateExtent:e=>{p().panZoom?.setTranslateExtent(e),f({translateExtent:e})},setPaneClickDistance:e=>{p().panZoom?.setClickDistance(e)},resetSelectedElements:()=>{let{edges:e,nodes:t,triggerNodeChanges:n,triggerEdgeChanges:r,elementsSelectable:i}=p();if(!i)return;let a=t.reduce((e,t)=>t.selected?[...e,nO(t.id,!1)]:e,[]),o=e.reduce((e,t)=>t.selected?[...e,nO(t.id,!1)]:e,[]);n(a),r(o)},setNodeExtent:e=>{let{nodes:t,nodeLookup:n,parentLookup:r,nodeOrigin:i,elevateNodesOnSelect:a,nodeExtent:o}=p();e[0][0]===o[0][0]&&e[0][1]===o[0][1]&&e[1][0]===o[1][0]&&e[1][1]===o[1][1]||(rE(t,n,r,{nodeOrigin:i,nodeExtent:e,elevateNodesOnSelect:a,checkEquality:!1}),f({nodeExtent:e}))},panBy:e=>{let{transform:t,width:n,height:r,panZoom:i,translateExtent:a}=p();return uE({delta:e,panZoom:i,transform:t,translateExtent:a,width:n,height:r})},setCenter:async(e,t,n)=>{let{width:r,height:i,maxZoom:a,panZoom:o}=p();if(!o)return Promise.resolve(!1);let s=n?.zoom===void 0?a:n.zoom;return await o.setViewport({x:r/2-e*s,y:i/2-t*s,zoom:s},{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),Promise.resolve(!0)},cancelConnection:()=>{f({connection:{...Ow}})},updateConnection:e=>{f({connection:e})},reset:()=>f({...lA()})}},Object.is);function dA({initialNodes:e,initialEdges:t,defaultNodes:n,defaultEdges:r,initialWidth:i,initialHeight:a,initialMinZoom:o,initialMaxZoom:s,initialFitViewOptions:c,fitView:l,nodeOrigin:u,nodeExtent:d,children:f}){let[p]=(0,y.useState)(()=>uA({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:l,minZoom:o,maxZoom:s,fitViewOptions:c,nodeOrigin:u,nodeExtent:d}));return(0,T.jsx)(vD,{value:p,children:(0,T.jsx)(pO,{children:f})})}function fA({children:e,nodes:t,edges:n,defaultNodes:r,defaultEdges:i,width:a,height:o,fitView:s,fitViewOptions:c,minZoom:l,maxZoom:u,nodeOrigin:d,nodeExtent:f}){return(0,y.useContext)(_D)?(0,T.jsx)(T.Fragment,{children:e}):(0,T.jsx)(dA,{initialNodes:t,initialEdges:n,defaultNodes:r,defaultEdges:i,initialWidth:a,initialHeight:o,fitView:s,initialFitViewOptions:c,initialMinZoom:l,initialMaxZoom:u,nodeOrigin:d,nodeExtent:f,children:e})}var pA={width:`100%`,height:`100%`,overflow:`hidden`,position:`relative`,zIndex:0};function mA({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,className:i,nodeTypes:a,edgeTypes:o,onNodeClick:s,onEdgeClick:c,onInit:l,onMove:u,onMoveStart:d,onMoveEnd:f,onConnect:p,onConnectStart:m,onConnectEnd:h,onClickConnectStart:g,onClickConnectEnd:_,onNodeMouseEnter:v,onNodeMouseMove:b,onNodeMouseLeave:x,onNodeContextMenu:S,onNodeDoubleClick:C,onNodeDragStart:w,onNodeDrag:E,onNodeDragStop:D,onNodesDelete:O,onEdgesDelete:k,onDelete:A,onSelectionChange:j,onSelectionDragStart:M,onSelectionDrag:N,onSelectionDragStop:P,onSelectionContextMenu:ee,onSelectionStart:F,onSelectionEnd:I,onBeforeDelete:L,connectionMode:R,connectionLineType:z=kw.Bezier,connectionLineStyle:B,connectionLineComponent:V,connectionLineContainerStyle:H,deleteKeyCode:U=`Backspace`,selectionKeyCode:te=`Shift`,selectionOnDrag:ne=!1,selectionMode:re=Dw.Full,panActivationKeyCode:ie=`Space`,multiSelectionKeyCode:ae=pT()?`Meta`:`Control`,zoomActivationKeyCode:oe=pT()?`Meta`:`Control`,snapToGrid:se,snapGrid:ce,onlyRenderVisibleElements:le=!1,selectNodesOnDrag:ue,nodesDraggable:de,autoPanOnNodeFocus:fe,nodesConnectable:pe,nodesFocusable:me,nodeOrigin:he=zD,edgesFocusable:ge,edgesReconnectable:_e,elementsSelectable:ve=!0,defaultViewport:ye=BD,minZoom:be=.5,maxZoom:xe=2,translateExtent:Se=Sw,preventScrolling:Ce=!0,nodeExtent:we,defaultMarkerColor:Te=`#b1b1b7`,zoomOnScroll:Ee=!0,zoomOnPinch:De=!0,panOnScroll:Oe=!1,panOnScrollSpeed:ke=.5,panOnScrollMode:Ae=Ew.Free,zoomOnDoubleClick:je=!0,panOnDrag:Me=!0,onPaneClick:Ne,onPaneMouseEnter:Pe,onPaneMouseMove:Fe,onPaneMouseLeave:Ie,onPaneScroll:Le,onPaneContextMenu:W,paneClickDistance:Re=0,nodeClickDistance:ze=0,children:Be,onReconnect:Ve,onReconnectStart:He,onReconnectEnd:G,onEdgeContextMenu:Ue,onEdgeDoubleClick:We,onEdgeMouseEnter:Ge,onEdgeMouseMove:Ke,onEdgeMouseLeave:qe,reconnectRadius:Je=10,onNodesChange:Ye,onEdgesChange:Xe,noDragClassName:Ze=`nodrag`,noWheelClassName:Qe=`nowheel`,noPanClassName:$e=`nopan`,fitView:et,fitViewOptions:tt,connectOnClick:nt,attributionPosition:rt,proOptions:it,defaultEdgeOptions:at,elevateNodesOnSelect:ot,elevateEdgesOnSelect:K,disableKeyboardA11y:st=!1,autoPanOnConnect:ct,autoPanOnNodeDrag:q,autoPanSpeed:lt,connectionRadius:ut,isValidConnection:dt,onError:ft,style:pt,id:mt,nodeDragThreshold:ht,connectionDragThreshold:gt,viewport:J,onViewportChange:_t,width:vt,height:yt,colorMode:bt=`light`,debug:xt,onScroll:St,ariaLabelConfig:Ct,...wt},Tt){let Et=mt||`1`,Dt=KD(bt),Ot=(0,y.useCallback)(e=>{e.currentTarget.scrollTo({top:0,left:0,behavior:`instant`}),St?.(e)},[St]);return(0,T.jsx)(`div`,{"data-testid":`rf__wrapper`,...wt,onScroll:Ot,style:{...pt,...pA},ref:Tt,className:Lv([`react-flow`,i,Dt]),id:mt,role:`application`,children:(0,T.jsxs)(fA,{nodes:e,edges:t,width:vt,height:yt,fitView:et,fitViewOptions:tt,minZoom:be,maxZoom:xe,nodeOrigin:he,nodeExtent:we,children:[(0,T.jsx)(cA,{onInit:l,onNodeClick:s,onEdgeClick:c,onNodeMouseEnter:v,onNodeMouseMove:b,onNodeMouseLeave:x,onNodeContextMenu:S,onNodeDoubleClick:C,nodeTypes:a,edgeTypes:o,connectionLineType:z,connectionLineStyle:B,connectionLineComponent:V,connectionLineContainerStyle:H,selectionKeyCode:te,selectionOnDrag:ne,selectionMode:re,deleteKeyCode:U,multiSelectionKeyCode:ae,panActivationKeyCode:ie,zoomActivationKeyCode:oe,onlyRenderVisibleElements:le,defaultViewport:ye,translateExtent:Se,minZoom:be,maxZoom:xe,preventScrolling:Ce,zoomOnScroll:Ee,zoomOnPinch:De,zoomOnDoubleClick:je,panOnScroll:Oe,panOnScrollSpeed:ke,panOnScrollMode:Ae,panOnDrag:Me,onPaneClick:Ne,onPaneMouseEnter:Pe,onPaneMouseMove:Fe,onPaneMouseLeave:Ie,onPaneScroll:Le,onPaneContextMenu:W,paneClickDistance:Re,nodeClickDistance:ze,onSelectionContextMenu:ee,onSelectionStart:F,onSelectionEnd:I,onReconnect:Ve,onReconnectStart:He,onReconnectEnd:G,onEdgeContextMenu:Ue,onEdgeDoubleClick:We,onEdgeMouseEnter:Ge,onEdgeMouseMove:Ke,onEdgeMouseLeave:qe,reconnectRadius:Je,defaultMarkerColor:Te,noDragClassName:Ze,noWheelClassName:Qe,noPanClassName:$e,rfId:Et,disableKeyboardA11y:st,nodeExtent:we,viewport:J,onViewportChange:_t}),(0,T.jsx)(WD,{nodes:e,edges:t,defaultNodes:n,defaultEdges:r,onConnect:p,onConnectStart:m,onConnectEnd:h,onClickConnectStart:g,onClickConnectEnd:_,nodesDraggable:de,autoPanOnNodeFocus:fe,nodesConnectable:pe,nodesFocusable:me,edgesFocusable:ge,edgesReconnectable:_e,elementsSelectable:ve,elevateNodesOnSelect:ot,elevateEdgesOnSelect:K,minZoom:be,maxZoom:xe,nodeExtent:we,onNodesChange:Ye,onEdgesChange:Xe,snapToGrid:se,snapGrid:ce,connectionMode:R,translateExtent:Se,connectOnClick:nt,defaultEdgeOptions:at,fitView:et,fitViewOptions:tt,onNodesDelete:O,onEdgesDelete:k,onDelete:A,onNodeDragStart:w,onNodeDrag:E,onNodeDragStop:D,onSelectionDrag:N,onSelectionDragStart:M,onSelectionDragStop:P,onMove:u,onMoveStart:d,onMoveEnd:f,noPanClassName:$e,nodeOrigin:he,rfId:Et,autoPanOnConnect:ct,autoPanOnNodeDrag:q,autoPanSpeed:lt,onError:ft,connectionRadius:ut,isValidConnection:dt,selectNodesOnDrag:ue,nodeDragThreshold:ht,connectionDragThreshold:gt,onBeforeDelete:L,paneClickDistance:Re,debug:xt,ariaLabelConfig:Ct}),(0,T.jsx)(RD,{onSelectionChange:j}),Be,(0,T.jsx)(MD,{proOptions:it,position:rt}),(0,T.jsx)(AD,{rfId:Et,disableKeyboardA11y:st})]})})}var hA=cO(mA);function gA(e){let[t,n]=(0,y.useState)(e);return[t,n,(0,y.useCallback)(e=>n(t=>eO(e,t)),[])]}function _A(e){let[t,n]=(0,y.useState)(e);return[t,n,(0,y.useCallback)(e=>n(t=>tO(e,t)),[])]}xw.error014();function vA(e){return bD((0,y.useCallback)(t=>t.nodeLookup.get(e),[e]),gD)}function yA({dimensions:e,lineWidth:t,variant:n,className:r}){return(0,T.jsx)(`path`,{strokeWidth:t,d:`M${e[0]/2} 0 V${e[1]} M0 ${e[1]/2} H${e[0]}`,className:Lv([`react-flow__background-pattern`,n,r])})}function bA({radius:e,className:t}){return(0,T.jsx)(`circle`,{cx:e,cy:e,r:e,className:Lv([`react-flow__background-pattern`,`dots`,t])})}var xA;(function(e){e.Lines=`lines`,e.Dots=`dots`,e.Cross=`cross`})(xA||={});var SA={[xA.Dots]:1,[xA.Lines]:1,[xA.Cross]:6},CA=e=>({transform:e.transform,patternId:`pattern-${e.rfId}`});function wA({id:e,variant:t=xA.Dots,gap:n=20,size:r,lineWidth:i=1,offset:a=0,color:o,bgColor:s,style:c,className:l,patternClassName:u}){let d=(0,y.useRef)(null),{transform:f,patternId:p}=bD(CA,gD),m=r||SA[t],h=t===xA.Dots,g=t===xA.Cross,_=Array.isArray(n)?n:[n,n],v=[_[0]*f[2]||1,_[1]*f[2]||1],b=m*f[2],x=Array.isArray(a)?a:[a,a],S=g?[b,b]:v,C=[x[0]*f[2]||1+S[0]/2,x[1]*f[2]||1+S[1]/2],w=`${p}${e||``}`;return(0,T.jsxs)(`svg`,{className:Lv([`react-flow__background`,l]),style:{...c,...xO,"--xy-background-color-props":s,"--xy-background-pattern-color-props":o},ref:d,"data-testid":`rf__background`,children:[(0,T.jsx)(`pattern`,{id:w,x:f[0]%v[0],y:f[1]%v[1],width:v[0],height:v[1],patternUnits:`userSpaceOnUse`,patternTransform:`translate(-${C[0]},-${C[1]})`,children:h?(0,T.jsx)(bA,{radius:b/2,className:u}):(0,T.jsx)(yA,{dimensions:S,lineWidth:i,variant:t,className:u})}),(0,T.jsx)(`rect`,{x:`0`,y:`0`,width:`100%`,height:`100%`,fill:`url(#${w})`})]})}wA.displayName=`Background`;var TA=(0,y.memo)(wA);function EA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 32`,children:(0,T.jsx)(`path`,{d:`M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z`})})}function DA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 5`,children:(0,T.jsx)(`path`,{d:`M0 0h32v4.2H0z`})})}function OA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 30`,children:(0,T.jsx)(`path`,{d:`M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z`})})}function kA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 25 32`,children:(0,T.jsx)(`path`,{d:`M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0 8 0 4.571 3.429 4.571 7.619v3.048H3.048A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047zm4.724-13.866H7.467V7.619c0-2.59 2.133-4.724 4.723-4.724 2.591 0 4.724 2.133 4.724 4.724v3.048z`})})}function AA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 25 32`,children:(0,T.jsx)(`path`,{d:`M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047z`})})}function jA({children:e,className:t,...n}){return(0,T.jsx)(`button`,{type:`button`,className:Lv([`react-flow__controls-button`,t]),...n,children:e})}var MA=e=>({isInteractive:e.nodesDraggable||e.nodesConnectable||e.elementsSelectable,minZoomReached:e.transform[2]<=e.minZoom,maxZoomReached:e.transform[2]>=e.maxZoom,ariaLabelConfig:e.ariaLabelConfig});function NA({style:e,showZoom:t=!0,showFitView:n=!0,showInteractive:r=!0,fitViewOptions:i,onZoomIn:a,onZoomOut:o,onFitView:s,onInteractiveChange:c,className:l,children:u,position:d=`bottom-left`,orientation:f=`vertical`,"aria-label":p}){let m=xD(),{isInteractive:h,minZoomReached:g,maxZoomReached:_,ariaLabelConfig:v}=bD(MA,gD),{zoomIn:y,zoomOut:b,fitView:x}=gO();return(0,T.jsxs)(jD,{className:Lv([`react-flow__controls`,f===`horizontal`?`horizontal`:`vertical`,l]),position:d,style:e,"data-testid":`rf__controls`,"aria-label":p??v[`controls.ariaLabel`],children:[t&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(jA,{onClick:()=>{y(),a?.()},className:`react-flow__controls-zoomin`,title:v[`controls.zoomIn.ariaLabel`],"aria-label":v[`controls.zoomIn.ariaLabel`],disabled:_,children:(0,T.jsx)(EA,{})}),(0,T.jsx)(jA,{onClick:()=>{b(),o?.()},className:`react-flow__controls-zoomout`,title:v[`controls.zoomOut.ariaLabel`],"aria-label":v[`controls.zoomOut.ariaLabel`],disabled:g,children:(0,T.jsx)(DA,{})})]}),n&&(0,T.jsx)(jA,{className:`react-flow__controls-fitview`,onClick:()=>{x(i),s?.()},title:v[`controls.fitView.ariaLabel`],"aria-label":v[`controls.fitView.ariaLabel`],children:(0,T.jsx)(OA,{})}),r&&(0,T.jsx)(jA,{className:`react-flow__controls-interactive`,onClick:()=>{m.setState({nodesDraggable:!h,nodesConnectable:!h,elementsSelectable:!h}),c?.(!h)},title:v[`controls.interactive.ariaLabel`],"aria-label":v[`controls.interactive.ariaLabel`],children:h?(0,T.jsx)(AA,{}):(0,T.jsx)(kA,{})}),u]})}NA.displayName=`Controls`;var PA=(0,y.memo)(NA);function FA({id:e,x:t,y:n,width:r,height:i,style:a,color:o,strokeColor:s,strokeWidth:c,className:l,borderRadius:u,shapeRendering:d,selected:f,onClick:p}){let{background:m,backgroundColor:h}=a||{},g=o||m||h;return(0,T.jsx)(`rect`,{className:Lv([`react-flow__minimap-node`,{selected:f},l]),x:t,y:n,rx:u,ry:u,width:r,height:i,style:{fill:g,stroke:s,strokeWidth:c},shapeRendering:d,onClick:p?t=>p(t,e):void 0})}var IA=(0,y.memo)(FA),LA=e=>e.nodes.map(e=>e.id),RA=e=>e instanceof Function?e:()=>e;function zA({nodeStrokeColor:e,nodeColor:t,nodeClassName:n=``,nodeBorderRadius:r=5,nodeStrokeWidth:i,nodeComponent:a=IA,onClick:o}){let s=bD(LA,gD),c=RA(t),l=RA(e),u=RA(n),d=typeof window>`u`||window.chrome?`crispEdges`:`geometricPrecision`;return(0,T.jsx)(T.Fragment,{children:s.map(e=>(0,T.jsx)(VA,{id:e,nodeColorFunc:c,nodeStrokeColorFunc:l,nodeClassNameFunc:u,nodeBorderRadius:r,nodeStrokeWidth:i,NodeComponent:a,onClick:o,shapeRendering:d},e))})}function BA({id:e,nodeColorFunc:t,nodeStrokeColorFunc:n,nodeClassNameFunc:r,nodeBorderRadius:i,nodeStrokeWidth:a,shapeRendering:o,NodeComponent:s,onClick:c}){let{node:l,x:u,y:d,width:f,height:p}=bD(t=>{let{internals:n}=t.nodeLookup.get(e),r=n.userNode,{x:i,y:a}=n.positionAbsolute,{width:o,height:s}=hT(r);return{node:r,x:i,y:a,width:o,height:s}},gD);return!l||l.hidden||!gT(l)?null:(0,T.jsx)(s,{x:u,y:d,width:f,height:p,style:l.style,selected:!!l.selected,className:r(l),color:t(l),borderRadius:i,strokeColor:n(l),strokeWidth:a,shapeRendering:o,onClick:c,id:l.id})}var VA=(0,y.memo)(BA),HA=(0,y.memo)(zA),UA=200,WA=150,GA=e=>!e.hidden,KA=e=>{let t={x:-e.transform[0]/e.transform[2],y:-e.transform[1]/e.transform[2],width:e.width/e.transform[2],height:e.height/e.transform[2]};return{viewBB:t,boundingRect:e.nodeLookup.size>0?tT(Rw(e.nodeLookup,{filter:GA}),t):t,rfId:e.rfId,panZoom:e.panZoom,translateExtent:e.translateExtent,flowWidth:e.width,flowHeight:e.height,ariaLabelConfig:e.ariaLabelConfig}},qA=`react-flow__minimap-desc`;function JA({style:e,className:t,nodeStrokeColor:n,nodeColor:r,nodeClassName:i=``,nodeBorderRadius:a=5,nodeStrokeWidth:o,nodeComponent:s,bgColor:c,maskColor:l,maskStrokeColor:u,maskStrokeWidth:d,position:f=`bottom-right`,onClick:p,onNodeClick:m,pannable:h=!1,zoomable:g=!1,ariaLabel:_,inversePan:v,zoomStep:b=1,offsetScale:x=5}){let S=xD(),C=(0,y.useRef)(null),{boundingRect:w,viewBB:E,rfId:D,panZoom:O,translateExtent:k,flowWidth:A,flowHeight:j,ariaLabelConfig:M}=bD(KA,gD),N=e?.width??UA,P=e?.height??WA,ee=w.width/N,F=w.height/P,I=Math.max(ee,F),L=I*N,R=I*P,z=x*I,B=w.x-(L-w.width)/2-z,V=w.y-(R-w.height)/2-z,H=L+z*2,U=R+z*2,te=`${qA}-${D}`,ne=(0,y.useRef)(0),re=(0,y.useRef)();ne.current=I,(0,y.useEffect)(()=>{if(C.current&&O)return re.current=kE({domNode:C.current,panZoom:O,getTransform:()=>S.getState().transform,getViewScale:()=>ne.current}),()=>{re.current?.destroy()}},[O]),(0,y.useEffect)(()=>{re.current?.update({translateExtent:k,width:A,height:j,inversePan:v,pannable:h,zoomStep:b,zoomable:g})},[h,g,v,b,k,A,j]);let ie=p?e=>{let[t,n]=re.current?.pointer(e)||[0,0];p(e,{x:t,y:n})}:void 0,ae=m?(0,y.useCallback)((e,t)=>{let n=S.getState().nodeLookup.get(t).internals.userNode;m(e,n)},[]):void 0,oe=_??M[`minimap.ariaLabel`];return(0,T.jsx)(jD,{position:f,style:{...e,"--xy-minimap-background-color-props":typeof c==`string`?c:void 0,"--xy-minimap-mask-background-color-props":typeof l==`string`?l:void 0,"--xy-minimap-mask-stroke-color-props":typeof u==`string`?u:void 0,"--xy-minimap-mask-stroke-width-props":typeof d==`number`?d*I:void 0,"--xy-minimap-node-background-color-props":typeof r==`string`?r:void 0,"--xy-minimap-node-stroke-color-props":typeof n==`string`?n:void 0,"--xy-minimap-node-stroke-width-props":typeof o==`number`?o:void 0},className:Lv([`react-flow__minimap`,t]),"data-testid":`rf__minimap`,children:(0,T.jsxs)(`svg`,{width:N,height:P,viewBox:`${B} ${V} ${H} ${U}`,className:`react-flow__minimap-svg`,role:`img`,"aria-labelledby":te,ref:C,onClick:ie,children:[oe&&(0,T.jsx)(`title`,{id:te,children:oe}),(0,T.jsx)(HA,{onClick:ae,nodeColor:r,nodeStrokeColor:n,nodeBorderRadius:a,nodeClassName:i,nodeStrokeWidth:o,nodeComponent:s}),(0,T.jsx)(`path`,{className:`react-flow__minimap-mask`,d:`M${B-z},${V-z}h${H+z*2}v${U+z*2}h${-H-z*2}z - M${E.x},${E.y}h${E.width}v${E.height}h${-E.width}z`,fillRule:`evenodd`,pointerEvents:`none`})]})})}JA.displayName=`MiniMap`;var YA=(0,y.memo)(JA),XA=e=>t=>e?`${Math.max(1/t.transform[2],1)}`:void 0,ZA={[GE.Line]:`right`,[GE.Handle]:`bottom-right`};function QA({nodeId:e,position:t,variant:n=GE.Handle,className:r,style:i=void 0,children:a,color:o,minWidth:s=10,minHeight:c=10,maxWidth:l=Number.MAX_VALUE,maxHeight:u=Number.MAX_VALUE,keepAspectRatio:d=!1,resizeDirection:f,autoScale:p=!0,shouldResize:m,onResizeStart:h,onResize:g,onResizeEnd:_}){let v=FO(),b=typeof e==`string`?e:v,x=xD(),S=(0,y.useRef)(null),C=n===GE.Handle,w=bD((0,y.useCallback)(XA(C&&p),[C,p]),gD),E=(0,y.useRef)(null),D=t??ZA[n];return(0,y.useEffect)(()=>{if(!(!S.current||!b))return E.current||=rD({domNode:S.current,nodeId:b,getStoreItems:()=>{let{nodeLookup:e,transform:t,snapGrid:n,snapToGrid:r,nodeOrigin:i,domNode:a}=x.getState();return{nodeLookup:e,transform:t,snapGrid:n,snapToGrid:r,nodeOrigin:i,paneDomNode:a}},onChange:(e,t)=>{let{triggerNodeChanges:n,nodeLookup:r,parentLookup:i,nodeOrigin:a}=x.getState(),o=[],s={x:e.x,y:e.y},c=r.get(b);if(c&&c.expandParent&&c.parentId){let t=c.origin??a,n=e.width??c.measured.width??0,l=e.height??c.measured.height??0,u=cE([{id:c.id,parentId:c.parentId,rect:{width:n,height:l,..._T({x:e.x??c.position.x,y:e.y??c.position.y},{width:n,height:l},c.parentId,r,t)}}],r,i,a);o.push(...u),s.x=e.x?Math.max(t[0]*n,e.x):void 0,s.y=e.y?Math.max(t[1]*l,e.y):void 0}if(s.x!==void 0&&s.y!==void 0){let e={id:b,type:`position`,position:{...s}};o.push(e)}if(e.width!==void 0&&e.height!==void 0){let t={id:b,type:`dimensions`,resizing:!0,setAttributes:f?f===`horizontal`?`width`:`height`:!0,dimensions:{width:e.width,height:e.height}};o.push(t)}for(let e of t){let t={...e,type:`position`};o.push(t)}n(o)},onEnd:({width:e,height:t})=>{let n={id:b,type:`dimensions`,resizing:!1,dimensions:{width:e,height:t}};x.getState().triggerNodeChanges([n])}}),E.current.update({controlPosition:D,boundaries:{minWidth:s,minHeight:c,maxWidth:l,maxHeight:u},keepAspectRatio:d,resizeDirection:f,onResizeStart:h,onResize:g,onResizeEnd:_,shouldResize:m}),()=>{E.current?.destroy()}},[D,s,c,l,u,d,h,g,_,m]),(0,T.jsx)(`div`,{className:Lv([`react-flow__resize-control`,`nodrag`,...D.split(`-`),n,r]),ref:S,style:{...i,scale:w,...o&&{[C?`backgroundColor`:`borderColor`]:o}},children:a})}(0,y.memo)(QA);var $A=e=>{switch(e){case`running`:return{borderColor:`border-[#643FB2] dark:border-[#8B5CF6]`,glow:`shadow-lg shadow-[#643FB2]/20`,badgeColor:`bg-[#643FB2] dark:bg-[#8B5CF6]`};case`completed`:return{borderColor:`border-green-500 dark:border-green-400`,glow:`shadow-lg shadow-green-500/20`,badgeColor:`bg-green-500 dark:bg-green-400`};case`failed`:return{borderColor:`border-red-500 dark:border-red-400`,glow:`shadow-lg shadow-red-500/20`,badgeColor:`bg-red-500 dark:bg-red-400`};case`cancelled`:return{borderColor:`border-orange-500 dark:border-orange-400`,glow:`shadow-lg shadow-orange-500/20`,badgeColor:`bg-orange-500 dark:bg-orange-400`};default:return{borderColor:`border-gray-300 dark:border-gray-600`,glow:``,badgeColor:`bg-gray-400 dark:bg-gray-500`}}},ej=(0,y.memo)(({data:e,selected:t})=>{let n=e,r=$A(n.state),[i,a]=(0,y.useState)(!1),o=n.outputData||n.error,s=n.state===`running`,c=s&&(n.isStreaming??!0),l=n.layoutDirection===`TB`,u=l?$.Top:$.Left,d=l?$.Bottom:$.Right;return(0,T.jsxs)(`div`,{className:at(`group relative w-64 bg-card dark:bg-card rounded border-2 transition-all duration-200`,r.borderColor,t?`ring-2 ring-blue-500 ring-offset-2`:``,s?r.glow:`shadow-sm`),children:[(0,T.jsx)(zO,{type:`target`,position:u,id:`target`,className:`!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0`,style:{backgroundColor:n.state===`running`?`#643FB2`:n.state===`completed`?`#10b981`:n.state===`failed`?`#ef4444`:n.state===`cancelled`?`#f97316`:`#4b5563`}}),(0,T.jsx)(zO,{type:`source`,position:d,id:`source`,className:`!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0`,style:{backgroundColor:n.state===`running`?`#643FB2`:n.state===`completed`?`#10b981`:n.state===`failed`?`#ef4444`:n.state===`cancelled`?`#f97316`:`#4b5563`}}),(0,T.jsxs)(`div`,{className:`p-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,T.jsx)(`div`,{className:`flex-shrink-0 relative`,children:(0,T.jsx)(`div`,{className:`w-10 h-10 rounded-lg bg-gray-900/90 dark:bg-gray-800/90 flex items-center justify-center`,children:n.isStartNode?(0,T.jsx)(Kl,{className:`w-5 h-5 text-[#643FB2] dark:text-[#8B5CF6]`}):(0,T.jsx)(Tu,{className:`w-5 h-5 text-gray-300 dark:text-gray-400`})})}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`h3`,{className:`font-medium text-sm text-gray-900 dark:text-gray-100 truncate`,children:n.name||n.executorId}),s&&(0,T.jsx)(Ql,{className:`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${c?`animate-spin`:``} flex-shrink-0`})]}),n.executorType&&(0,T.jsx)(`p`,{className:`text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5`,children:n.executorType})]})]}),o&&(0,T.jsxs)(`div`,{className:`mt-2 border-t border-border/50 pt-2`,children:[(0,T.jsxs)(`button`,{onClick:e=>{e.stopPropagation(),a(!i)},className:`flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-full`,children:[i?(0,T.jsx)(X,{className:`w-3 h-3`}):(0,T.jsx)(Z,{className:`w-3 h-3`}),(0,T.jsx)(`span`,{children:n.error?`Show error`:`Show output`})]}),i&&(0,T.jsx)(`div`,{className:`mt-2`,children:(()=>{if(n.error&&typeof n.error==`string`)return(0,T.jsx)(`div`,{className:`text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words max-h-32 overflow-auto`,children:Tv(n.error,200)});if(n.outputData)try{return(0,T.jsx)(`div`,{className:`text-xs text-gray-700 dark:text-gray-300 bg-muted/50 p-2 rounded border max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`whitespace-pre-wrap font-mono`,children:typeof n.outputData==`string`?n.outputData:JSON.stringify(n.outputData,null,2)})})}catch{return(0,T.jsx)(`div`,{className:`text-xs text-gray-600 dark:text-gray-400 bg-muted/50 p-2 rounded border`,children:`[Unable to display output]`})}return null})()})]}),s&&(0,T.jsx)(`div`,{className:`absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none`})]})]})});ej.displayName=`ExecutorNode`;var tj=(0,y.memo)(function({id:e,source:t,markerEnd:n,style:r}){let i=vA(t);if(!i)return null;let{width:a,height:o}=i.measured,{x:s,y:c}=i.internals.positionAbsolute;if(!a||!o)return null;let l=i.data?.layoutDirection===`TB`,u;if(l){let e=s+a/2,t=c+o,n=s+a/2,r=c,i=s+a+100;u=`M ${e} ${t} C ${e} ${t+40}, ${i} ${t+40}, ${i} ${c+o/2} C ${i} ${r-40}, ${n} ${r-40}, ${n} ${r}`}else{let e=s+a,t=c+o/2,n=s,r=c+o/2,i=c+o+100;u=`M ${e} ${t} C ${e+40} ${t}, ${e+40} ${i}, ${s+a/2} ${i} C ${n-40} ${i}, ${n-40} ${r}, ${n} ${r}`}return(0,T.jsx)(vk,{id:e,path:u,markerEnd:n,style:r})}),nj={executor:ej},rj={selfLoop:tj};function ij({workflowDump:e,onNodeSelect:t,viewOptions:n,onToggleViewOption:r,layoutDirection:i,onLayoutDirectionChange:a}){let{fitView:o,setViewport:s,setNodes:c}=gO();return(0,T.jsx)(`div`,{className:`absolute top-4 right-4 z-10`,children:(0,T.jsxs)(ku,{children:[(0,T.jsx)(Au,{asChild:!0,children:(0,T.jsxs)(K,{variant:`outline`,size:`sm`,className:`h-8 w-8 p-0 bg-white/90 backdrop-blur-sm border-gray-200 shadow-sm hover:bg-white dark:bg-gray-800/90 dark:border-gray-600 dark:hover:bg-gray-800`,children:[(0,T.jsx)(Bl,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`sr-only`,children:`View options`})]})}),(0,T.jsxs)(ju,{align:`end`,className:`w-56`,children:[(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>r?.(`showMinimap`),children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(eu,{className:`mr-2 h-4 w-4`}),`Show Minimap`]}),(0,T.jsx)(ip,{checked:n.showMinimap,onChange:()=>{}})]}),(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>r?.(`showGrid`),children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(Gl,{className:`mr-2 h-4 w-4`}),`Show Grid`]}),(0,T.jsx)(ip,{checked:n.showGrid,onChange:()=>{}})]}),(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>r?.(`animateRun`),children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(Ou,{className:`mr-2 h-4 w-4`}),`Animate Run`]}),(0,T.jsx)(ip,{checked:n.animateRun,onChange:()=>{}})]}),(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>r?.(`consolidateBidirectionalEdges`),children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(yl,{className:`mr-2 h-4 w-4`}),`Merge Bidirectional Edges`]}),(0,T.jsx)(ip,{checked:n.consolidateBidirectionalEdges,onChange:()=>{}})]}),(0,T.jsx)(Pu,{}),(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>{let n=i===`LR`?`TB`:`LR`;a?.(n),e&&c(Av(Ev(e,t,n),Dv(e),n))},children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(vl,{className:`mr-2 h-4 w-4`}),`Vertical Layout`]}),(0,T.jsx)(ip,{checked:i===`TB`,onChange:()=>{}})]}),(0,T.jsx)(Pu,{}),(0,T.jsxs)(Mu,{onClick:()=>{s({x:0,y:0,zoom:1})},children:[(0,T.jsx)(fu,{className:`mr-2 h-4 w-4`}),`Reset Zoom`]}),(0,T.jsxs)(Mu,{onClick:()=>{o({padding:.2})},children:[(0,T.jsx)(tu,{className:`mr-2 h-4 w-4`}),`Fit to Screen`]}),(0,T.jsxs)(Mu,{onClick:()=>{e&&c(Av(Ev(e,t,i),Dv(e),i))},children:[(0,T.jsx)(vu,{className:`mr-2 h-4 w-4`}),`Auto-arrange`]})]})]})})}function aj({nodes:e,nodeUpdates:t,isStreaming:n,animateRun:r}){let{fitView:i}=gO();return(0,y.useEffect)(()=>{if(r)if(n){let t=e.filter(e=>e.data.state===`running`);if(t.length>0){let e=t[0];i({nodes:[e],duration:800,padding:.3,minZoom:.8,maxZoom:1.5})}}else e.length>0&&i({duration:1e3,padding:.2})},[t,n,r,e]),null}var oj=(0,y.memo)(({timelineVisible:e})=>{let{fitView:t}=gO();return(0,y.useEffect)(()=>{let e=setTimeout(()=>{t({padding:.2,duration:300})},350);return()=>clearTimeout(e)},[e]),null}),sj=(0,y.memo)(function({workflowDump:e,events:t,isStreaming:n,onNodeSelect:r,className:i=``,viewOptions:a={showMinimap:!1,showGrid:!0,animateRun:!0,consolidateBidirectionalEdges:!0},onToggleViewOption:o,layoutDirection:s=`TB`,onLayoutDirectionChange:c,timelineVisible:l=!1}){let{initialNodes:u,initialEdges:d}=(0,y.useMemo)(()=>{if(!e)return{initialNodes:[],initialEdges:[]};let t=Ev(e,r,s),n=Dv(e),i=a.consolidateBidirectionalEdges?Fv(n):n;return{initialNodes:t.length>0?Av(t,i,s):t,initialEdges:i}},[e,r,s,a.consolidateBidirectionalEdges]),[f,p,m]=gA(u),[h,g,_]=_A(d),v=(0,y.useMemo)(()=>jv(t,e?.start_executor_id),[t,e?.start_executor_id]);(0,y.useMemo)(()=>{Object.keys(v).length>0?p(e=>Mv(e,v,n)):t.length===0&&p(e=>e.map(e=>({...e,data:{...e.data,state:`pending`,outputData:void 0,error:void 0,isStreaming:!1}})))},[v,p,t.length,n]),(0,y.useMemo)(()=>{t.length>0?g(e=>{let n=Pv(e,t);return a.consolidateBidirectionalEdges?Fv(n):n}):g(e=>{let t=e.map(e=>({...e,animated:!1,style:{stroke:`#6b7280`,strokeWidth:2}}));return a.consolidateBidirectionalEdges?Fv(t):t})},[t,g,a.consolidateBidirectionalEdges]),(0,y.useEffect)(()=>{u.length>0&&(p(u),g(d))},[e,a.consolidateBidirectionalEdges]);let b=(0,y.useCallback)((e,t)=>{e.stopPropagation(),r?.(t.data.executorId,t.data)},[r]);return e?u.length===0?(0,T.jsx)(`div`,{className:`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${i}`,children:(0,T.jsxs)(`div`,{className:`text-center text-gray-500 dark:text-gray-400`,children:[(0,T.jsx)(`div`,{className:`text-lg font-medium mb-2`,children:`No Executors Found`}),(0,T.jsx)(`div`,{className:`text-sm`,children:`Could not extract executors from workflow dump.`}),(0,T.jsxs)(`details`,{className:`mt-2 text-xs`,children:[(0,T.jsx)(`summary`,{className:`cursor-pointer`,children:`Debug Info`}),(0,T.jsx)(`pre`,{className:`mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-auto`,children:JSON.stringify(e,null,2)})]})]})}):(0,T.jsxs)(`div`,{className:`h-full w-full ${i}`,children:[(0,T.jsxs)(hA,{nodes:f,edges:h,onNodesChange:m,onEdgesChange:_,onNodeClick:b,nodeTypes:nj,edgeTypes:rj,fitView:!0,fitViewOptions:{padding:.2},minZoom:.1,maxZoom:1.5,defaultEdgeOptions:{type:`default`,animated:!1,style:{stroke:`#6b7280`,strokeWidth:2}},nodesDraggable:!n,nodesConnectable:!1,elementsSelectable:!0,proOptions:{hideAttribution:!0},children:[a.showGrid&&(0,T.jsx)(TA,{variant:xA.Dots,gap:20,size:1,color:`#e5e7eb`,className:`dark:opacity-30`}),(0,T.jsx)(PA,{position:`bottom-left`,showInteractive:!1,style:{backgroundColor:`rgba(255, 255, 255, 0.9)`,border:`1px solid #e5e7eb`,borderRadius:`3px`},className:`dark:!bg-gray-800/90 dark:!border-gray-600`}),a.showMinimap&&(0,T.jsx)(YA,{nodeColor:e=>{switch(e.data?.state){case`running`:return`#643FB2`;case`completed`:return`#10b981`;case`failed`:return`#ef4444`;case`cancelled`:return`#f97316`;default:return`#6b7280`}},maskColor:`rgba(0, 0, 0, 0.1)`,position:`bottom-right`,style:{backgroundColor:`rgba(255, 255, 255, 0.9)`,border:`1px solid #e5e7eb`,borderRadius:`8px`},className:`dark:!bg-gray-800/90 dark:!border-gray-600`}),(0,T.jsx)(aj,{nodes:f,nodeUpdates:v,isStreaming:n,animateRun:a.animateRun}),(0,T.jsx)(oj,{timelineVisible:l}),(0,T.jsx)(ij,{workflowDump:e,onNodeSelect:r,viewOptions:a,onToggleViewOption:o,layoutDirection:s,onLayoutDirectionChange:c})]}),(0,T.jsx)(`style`,{children:` +`));return[t,t.reduce((e,t)=>e.concat(...t),[])]}return[[],[]]},[e]);return(0,y.useEffect)(()=>{let n=t?.target??ZD,c=t?.actInsideInputWithModifier??!0;if(e!==null){let e=e=>{if(i.current=e.ctrlKey||e.metaKey||e.shiftKey||e.altKey,(!i.current||i.current&&!c)&&kT(e))return!1;let n=eO(e.code,s);if(a.current.add(e[n]),$D(o,a.current,!1)){let n=e.composedPath?.()?.[0]||e.target,a=n?.nodeName===`BUTTON`||n?.nodeName===`A`;t.preventDefault!==!1&&(i.current||!a)&&e.preventDefault(),r(!0)}},l=e=>{let t=eO(e.code,s);$D(o,a.current,!0)?(r(!1),a.current.clear()):a.current.delete(e[t]),e.key===`Meta`&&a.current.clear(),i.current=!1},u=()=>{a.current.clear(),r(!1)};return n?.addEventListener(`keydown`,e),n?.addEventListener(`keyup`,l),window.addEventListener(`blur`,u),window.addEventListener(`contextmenu`,u),()=>{n?.removeEventListener(`keydown`,e),n?.removeEventListener(`keyup`,l),window.removeEventListener(`blur`,u),window.removeEventListener(`contextmenu`,u)}}},[e,r]),n}function $D(e,t,n){return e.filter(e=>n||e.length===t.size).some(e=>e.every(e=>t.has(e)))}function eO(e,t){return t.includes(e)?`code`:`key`}var tO=()=>{let e=TD();return(0,y.useMemo)(()=>({zoomIn:t=>{let{panZoom:n}=e.getState();return n?n.scaleBy(1.2,{duration:t?.duration}):Promise.resolve(!1)},zoomOut:t=>{let{panZoom:n}=e.getState();return n?n.scaleBy(1/1.2,{duration:t?.duration}):Promise.resolve(!1)},zoomTo:(t,n)=>{let{panZoom:r}=e.getState();return r?r.scaleTo(t,{duration:n?.duration}):Promise.resolve(!1)},getZoom:()=>e.getState().transform[2],setViewport:async(t,n)=>{let{transform:[r,i,a],panZoom:o}=e.getState();return o?(await o.setViewport({x:t.x??r,y:t.y??i,zoom:t.zoom??a},n),Promise.resolve(!0)):Promise.resolve(!1)},getViewport:()=>{let[t,n,r]=e.getState().transform;return{x:t,y:n,zoom:r}},setCenter:async(t,n,r)=>e.getState().setCenter(t,n,r),fitBounds:async(t,n)=>{let{width:r,height:i,minZoom:a,maxZoom:o,panZoom:s}=e.getState(),c=gT(t,r,i,a,o,n?.padding??.1);return s?(await s.setViewport(c,{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),Promise.resolve(!0)):Promise.resolve(!1)},screenToFlowPosition:(t,n={})=>{let{transform:r,snapGrid:i,snapToGrid:a,domNode:o}=e.getState();if(!o)return t;let{x:s,y:c}=o.getBoundingClientRect(),l={x:t.x-s,y:t.y-c},u=n.snapGrid??i;return dT(l,r,n.snapToGrid??a,u)},flowToScreenPosition:t=>{let{transform:n,domNode:r}=e.getState();if(!r)return t;let{x:i,y:a}=r.getBoundingClientRect(),o=fT(t,n);return{x:o.x+i,y:o.y+a}}}),[])};function nO(e,t){let n=[],r=new Map,i=[];for(let t of e)if(t.type===`add`){i.push(t);continue}else if(t.type===`remove`||t.type===`replace`)r.set(t.id,[t]);else{let e=r.get(t.id);e?e.push(t):r.set(t.id,[t])}for(let e of t){let t=r.get(e.id);if(!t){n.push(e);continue}if(t[0].type===`remove`)continue;if(t[0].type===`replace`){n.push({...t[0].item});continue}let i={...e};for(let e of t)rO(e,i);n.push(i)}return i.length&&i.forEach(e=>{e.index===void 0?n.push({...e.item}):n.splice(e.index,0,{...e.item})}),n}function rO(e,t){switch(e.type){case`select`:t.selected=e.selected;break;case`position`:e.position!==void 0&&(t.position=e.position),e.dragging!==void 0&&(t.dragging=e.dragging);break;case`dimensions`:e.dimensions!==void 0&&(t.measured??={},t.measured.width=e.dimensions.width,t.measured.height=e.dimensions.height,e.setAttributes&&((e.setAttributes===!0||e.setAttributes===`width`)&&(t.width=e.dimensions.width),(e.setAttributes===!0||e.setAttributes===`height`)&&(t.height=e.dimensions.height))),typeof e.resizing==`boolean`&&(t.resizing=e.resizing);break}}function iO(e,t){return nO(e,t)}function aO(e,t){return nO(e,t)}function oO(e,t){return{id:e,type:`select`,selected:t}}function sO(e,t=new Set,n=!1){let r=[];for(let[i,a]of e){let e=t.has(i);!(a.selected===void 0&&!e)&&a.selected!==e&&(n&&(a.selected=e),r.push(oO(a.id,e)))}return r}function cO({items:e=[],lookup:t}){let n=[],r=new Map(e.map(e=>[e.id,e]));for(let[r,i]of e.entries()){let e=t.get(i.id),a=e?.internals?.userNode??e;a!==void 0&&a!==i&&n.push({id:i.id,item:i,type:`replace`}),a===void 0&&n.push({item:i,type:`add`,index:r})}for(let[e]of t)r.get(e)===void 0&&n.push({id:e,type:`remove`});return n}function lO(e){return{id:e.id,type:`remove`}}var uO=e=>Rw(e),dO=e=>Lw(e);function fO(e){return(0,y.forwardRef)(e)}var pO=typeof window<`u`?y.useLayoutEffect:y.useEffect;function mO(e){let[t,n]=(0,y.useState)(BigInt(0)),[r]=(0,y.useState)(()=>hO(()=>n(e=>e+BigInt(1))));return pO(()=>{let t=r.get();t.length&&(e(t),r.reset())},[t]),r}function hO(e){let t=[];return{get:()=>t,reset:()=>{t=[]},push:n=>{t.push(n),e()}}}var gO=(0,y.createContext)(null);function _O({children:e}){let t=TD(),n=mO((0,y.useCallback)(e=>{let{nodes:n=[],setNodes:r,hasDefaultNodes:i,onNodesChange:a,nodeLookup:o,fitViewQueued:s}=t.getState(),c=n;for(let t of e)c=typeof t==`function`?t(c):t;let l=cO({items:c,lookup:o});i&&r(c),l.length>0?a?.(l):s&&window.requestAnimationFrame(()=>{let{fitViewQueued:e,nodes:n,setNodes:r}=t.getState();e&&r(n)})},[])),r=mO((0,y.useCallback)(e=>{let{edges:n=[],setEdges:r,hasDefaultEdges:i,onEdgesChange:a,edgeLookup:o}=t.getState(),s=n;for(let t of e)s=typeof t==`function`?t(s):t;i?r(s):a&&a(cO({items:s,lookup:o}))},[])),i=(0,y.useMemo)(()=>({nodeQueue:n,edgeQueue:r}),[]);return(0,T.jsx)(gO.Provider,{value:i,children:e})}function vO(){let e=(0,y.useContext)(gO);if(!e)throw Error(`useBatchContext must be used within a BatchProvider`);return e}var yO=e=>!!e.panZoom;function bO(){let e=tO(),t=TD(),n=vO(),r=wD(yO),i=(0,y.useMemo)(()=>{let e=e=>t.getState().nodeLookup.get(e),r=e=>{n.nodeQueue.push(e)},i=e=>{n.edgeQueue.push(e)},a=e=>{let{nodeLookup:n,nodeOrigin:r}=t.getState(),i=uO(e)?e:n.get(e.id),a=i.parentId?xT(i.position,i.measured,i.parentId,n,r):i.position;return rT({...i,position:a,width:i.measured?.width??i.width,height:i.measured?.height??i.height})},o=(e,t,n={replace:!1})=>{r(r=>r.map(r=>{if(r.id===e){let e=typeof t==`function`?t(r):t;return n.replace&&uO(e)?e:{...r,...e}}return r}))},s=(e,t,n={replace:!1})=>{i(r=>r.map(r=>{if(r.id===e){let e=typeof t==`function`?t(r):t;return n.replace&&dO(e)?e:{...r,...e}}return r}))};return{getNodes:()=>t.getState().nodes.map(e=>({...e})),getNode:t=>e(t)?.internals.userNode,getInternalNode:e,getEdges:()=>{let{edges:e=[]}=t.getState();return e.map(e=>({...e}))},getEdge:e=>t.getState().edgeLookup.get(e),setNodes:r,setEdges:i,addNodes:e=>{let t=Array.isArray(e)?e:[e];n.nodeQueue.push(e=>[...e,...t])},addEdges:e=>{let t=Array.isArray(e)?e:[e];n.edgeQueue.push(e=>[...e,...t])},toObject:()=>{let{nodes:e=[],edges:n=[],transform:r}=t.getState(),[i,a,o]=r;return{nodes:e.map(e=>({...e})),edges:n.map(e=>({...e})),viewport:{x:i,y:a,zoom:o}}},deleteElements:async({nodes:e=[],edges:n=[]})=>{let{nodes:r,edges:i,onNodesDelete:a,onEdgesDelete:o,triggerNodeChanges:s,triggerEdgeChanges:c,onDelete:l,onBeforeDelete:u}=t.getState(),{nodes:d,edges:f}=await Jw({nodesToRemove:e,edgesToRemove:n,nodes:r,edges:i,onBeforeDelete:u}),p=f.length>0,m=d.length>0;if(p){let e=f.map(lO);o?.(f),c(e)}if(m){let e=d.map(lO);a?.(d),s(e)}return(m||p)&&l?.({nodes:d,edges:f}),{deletedNodes:d,deletedEdges:f}},getIntersectingNodes:(e,n=!0,r)=>{let i=sT(e),o=i?e:a(e),s=r!==void 0;return o?(r||t.getState().nodes).filter(r=>{let a=t.getState().nodeLookup.get(r.id);if(a&&!i&&(r.id===e.id||!a.internals.positionAbsolute))return!1;let c=rT(s?r:a),l=oT(c,o);return n&&l>0||l>=c.width*c.height||l>=o.width*o.height}):[]},isNodeIntersecting:(e,t,n=!0)=>{let r=sT(e)?e:a(e);if(!r)return!1;let i=oT(r,t);return n&&i>0||i>=r.width*r.height},updateNode:o,updateNodeData:(e,t,n={replace:!1})=>{o(e,e=>{let r=typeof t==`function`?t(e):t;return n.replace?{...e,data:r}:{...e,data:{...e.data,...r}}},n)},updateEdge:s,updateEdgeData:(e,t,n={replace:!1})=>{s(e,e=>{let r=typeof t==`function`?t(e):t;return n.replace?{...e,data:r}:{...e,data:{...e.data,...r}}},n)},getNodesBounds:e=>{let{nodeLookup:n,nodeOrigin:r}=t.getState();return Vw(e,{nodeLookup:n,nodeOrigin:r})},getHandleConnections:({type:e,id:n,nodeId:r})=>Array.from(t.getState().connectionLookup.get(`${r}-${e}${n?`-${n}`:``}`)?.values()??[]),getNodeConnections:({type:e,handleId:n,nodeId:r})=>Array.from(t.getState().connectionLookup.get(`${r}${e?n?`-${e}-${n}`:`-${e}`:``}`)?.values()??[]),fitView:async e=>{let r=t.getState().fitViewResolver??CT();return t.setState({fitViewQueued:!0,fitViewOptions:e,fitViewResolver:r}),n.nodeQueue.push(e=>[...e]),r.promise}}},[]);return(0,y.useMemo)(()=>({...i,...e,viewportInitialized:r}),[r])}var xO=e=>e.selected,SO=typeof window<`u`?window:void 0;function CO({deleteKeyCode:e,multiSelectionKeyCode:t}){let n=TD(),{deleteElements:r}=bO(),i=QD(e,{actInsideInputWithModifier:!1}),a=QD(t,{target:SO});(0,y.useEffect)(()=>{if(i){let{edges:e,nodes:t}=n.getState();r({nodes:t.filter(xO),edges:e.filter(xO)}),n.setState({nodesSelectionActive:!1})}},[i]),(0,y.useEffect)(()=>{n.setState({multiSelectionActive:a})},[a])}function wO(e){let t=TD();(0,y.useEffect)(()=>{let n=()=>{if(!e.current)return!1;let n=ET(e.current);(n.height===0||n.width===0)&&t.getState().onError?.(`004`,Tw.error004()),t.setState({width:n.width||500,height:n.height||500})};if(e.current){n(),window.addEventListener(`resize`,n);let t=new ResizeObserver(()=>n());return t.observe(e.current),()=>{window.removeEventListener(`resize`,n),t&&e.current&&t.unobserve(e.current)}}},[])}var TO={position:`absolute`,width:`100%`,height:`100%`,top:0,left:0},EO=e=>({userSelectionActive:e.userSelectionActive,lib:e.lib});function DO({onPaneContextMenu:e,zoomOnScroll:t=!0,zoomOnPinch:n=!0,panOnScroll:r=!1,panOnScrollSpeed:i=.5,panOnScrollMode:a=Aw.Free,zoomOnDoubleClick:o=!0,panOnDrag:s=!0,defaultViewport:c,translateExtent:l,minZoom:u,maxZoom:d,zoomActivationKeyCode:f,preventScrolling:p=!0,children:m,noWheelClassName:h,noPanClassName:g,onViewportChange:_,isControlledViewport:v,paneClickDistance:b}){let x=TD(),S=(0,y.useRef)(null),{userSelectionActive:C,lib:w}=wD(EO,bD),E=QD(f),D=(0,y.useRef)();wO(S);let O=(0,y.useCallback)(e=>{_?.({x:e[0],y:e[1],zoom:e[2]}),v||x.setState({transform:e})},[_,v]);return(0,y.useEffect)(()=>{if(S.current){D.current=JE({domNode:S.current,minZoom:u,maxZoom:d,translateExtent:l,viewport:c,paneClickDistance:b,onDraggingChange:e=>x.setState({paneDragging:e}),onPanZoomStart:(e,t)=>{let{onViewportChangeStart:n,onMoveStart:r}=x.getState();r?.(e,t),n?.(t)},onPanZoom:(e,t)=>{let{onViewportChange:n,onMove:r}=x.getState();r?.(e,t),n?.(t)},onPanZoomEnd:(e,t)=>{let{onViewportChangeEnd:n,onMoveEnd:r}=x.getState();r?.(e,t),n?.(t)}});let{x:e,y:t,zoom:n}=D.current.getViewport();return x.setState({panZoom:D.current,transform:[e,t,n],domNode:S.current.closest(`.react-flow`)}),()=>{D.current?.destroy()}}},[]),(0,y.useEffect)(()=>{D.current?.update({onPaneContextMenu:e,zoomOnScroll:t,zoomOnPinch:n,panOnScroll:r,panOnScrollSpeed:i,panOnScrollMode:a,zoomOnDoubleClick:o,panOnDrag:s,zoomActivationKeyPressed:E,preventScrolling:p,noPanClassName:g,userSelectionActive:C,noWheelClassName:h,lib:w,onTransformChange:O})},[e,t,n,r,i,a,o,s,E,p,g,C,h,w,O]),(0,T.jsx)(`div`,{className:`react-flow__renderer`,ref:S,style:TO,children:m})}var OO=e=>({userSelectionActive:e.userSelectionActive,userSelectionRect:e.userSelectionRect});function kO(){let{userSelectionActive:e,userSelectionRect:t}=wD(OO,bD);return e&&t?(0,T.jsx)(`div`,{className:`react-flow__selection react-flow__container`,style:{width:t.width,height:t.height,transform:`translate(${t.x}px, ${t.y}px)`}}):null}var AO=(e,t)=>n=>{n.target===t.current&&e?.(n)},jO=e=>({userSelectionActive:e.userSelectionActive,elementsSelectable:e.elementsSelectable,connectionInProgress:e.connection.inProgress,dragging:e.paneDragging});function MO({isSelecting:e,selectionKeyPressed:t,selectionMode:n=jw.Full,panOnDrag:r,selectionOnDrag:i,onSelectionStart:a,onSelectionEnd:o,onPaneClick:s,onPaneContextMenu:c,onPaneScroll:l,onPaneMouseEnter:u,onPaneMouseMove:d,onPaneMouseLeave:f,children:p}){let m=TD(),{userSelectionActive:h,elementsSelectable:g,dragging:_,connectionInProgress:v}=wD(jO,bD),b=g&&(e||h),x=(0,y.useRef)(null),S=(0,y.useRef)(),C=(0,y.useRef)(new Set),w=(0,y.useRef)(new Set),E=(0,y.useRef)(!1),D=(0,y.useRef)(!1),O=e=>{if(E.current||v){E.current=!1;return}s?.(e),m.getState().resetSelectedElements(),m.setState({nodesSelectionActive:!1})},k=e=>{if(Array.isArray(r)&&r?.includes(2)){e.preventDefault();return}c?.(e)},A=l?e=>l(e):void 0;return(0,T.jsxs)(`div`,{className:Vv([`react-flow__pane`,{draggable:r===!0||Array.isArray(r)&&r.includes(0),dragging:_,selection:e}]),onClick:b?void 0:AO(O,x),onContextMenu:AO(k,x),onWheel:AO(A,x),onPointerEnter:b?void 0:u,onPointerDown:b?t=>{let{resetSelectedElements:n,domNode:r}=m.getState();if(S.current=r?.getBoundingClientRect(),!g||!e||t.button!==0||t.target!==x.current||!S.current)return;t.target?.setPointerCapture?.(t.pointerId),D.current=!0,E.current=!1;let{x:i,y:o}=jT(t.nativeEvent,S.current);n(),m.setState({userSelectionRect:{width:0,height:0,startX:i,startY:o,x:i,y:o}}),a?.(t)}:d,onPointerMove:b?e=>{let{userSelectionRect:t,transform:r,nodeLookup:i,edgeLookup:a,connectionLookup:o,triggerNodeChanges:s,triggerEdgeChanges:c,defaultEdgeOptions:l}=m.getState();if(!S.current||!t)return;E.current=!0;let{x:u,y:d}=jT(e.nativeEvent,S.current),{startX:f,startY:p}=t,h={startX:f,startY:p,x:ue.id)),w.current=new Set;let v=l?.selectable??!0;for(let e of C.current){let t=o.get(e);if(t)for(let{edgeId:e}of t.values()){let t=a.get(e);t&&(t.selectable??v)&&w.current.add(e)}}ST(g,C.current)||s(sO(i,C.current,!0)),ST(_,w.current)||c(sO(a,w.current)),m.setState({userSelectionRect:h,userSelectionActive:!0,nodesSelectionActive:!1})}:d,onPointerUp:b?e=>{if(e.button!==0||!D.current)return;e.target?.releasePointerCapture?.(e.pointerId);let{userSelectionRect:n}=m.getState();!h&&n&&e.target===x.current&&O?.(e),m.setState({userSelectionActive:!1,userSelectionRect:null,nodesSelectionActive:C.current.size>0}),o?.(e),(t||i)&&(E.current=!1),D.current=!1}:void 0,onPointerLeave:f,ref:x,style:TO,children:[p,(0,T.jsx)(kO,{})]})}function NO({id:e,store:t,unselect:n=!1,nodeRef:r}){let{addSelectedNodes:i,unselectNodesAndEdges:a,multiSelectionActive:o,nodeLookup:s,onError:c}=t.getState(),l=s.get(e);if(!l){c?.(`012`,Tw.error012(e));return}t.setState({nodesSelectionActive:!1}),l.selected?(n||l.selected&&o)&&(a({nodes:[l],edges:[]}),requestAnimationFrame(()=>r?.current?.blur())):i([e])}function PO({nodeRef:e,disabled:t=!1,noDragClassName:n,handleSelector:r,nodeId:i,isSelectable:a,nodeClickDistance:o}){let s=TD(),[c,l]=(0,y.useState)(!1),u=(0,y.useRef)();return(0,y.useEffect)(()=>{u.current=SE({getStoreItems:()=>s.getState(),onNodeMouseDown:t=>{NO({id:t,store:s,nodeRef:e})},onDragStart:()=>{l(!0)},onDragStop:()=>{l(!1)}})},[]),(0,y.useEffect)(()=>{if(t)u.current?.destroy();else if(e.current)return u.current?.update({noDragClassName:n,handleSelector:r,domNode:e.current,isSelectable:a,nodeId:i,nodeClickDistance:o}),()=>{u.current?.destroy()}},[n,r,t,a,e,i]),c}var FO=e=>t=>t.selected&&(t.draggable||e&&t.draggable===void 0);function IO(){let e=TD();return(0,y.useCallback)(t=>{let{nodeExtent:n,snapToGrid:r,snapGrid:i,nodesDraggable:a,onError:o,updateNodePositions:s,nodeLookup:c,nodeOrigin:l}=e.getState(),u=new Map,d=FO(a),f=r?i[0]:5,p=r?i[1]:5,m=t.direction.x*f*t.factor,h=t.direction.y*p*t.factor;for(let[,e]of c){if(!d(e))continue;let t={x:e.internals.positionAbsolute.x+m,y:e.internals.positionAbsolute.y+h};r&&(t=uT(t,i));let{position:a,positionAbsolute:s}=qw({nodeId:e.id,nextPosition:t,nodeLookup:c,nodeExtent:n,nodeOrigin:l,onError:o});e.position=a,e.internals.positionAbsolute=s,u.set(e.id,e)}s(u)},[])}var LO=(0,y.createContext)(null),RO=LO.Provider;LO.Consumer;var zO=()=>(0,y.useContext)(LO),BO=e=>({connectOnClick:e.connectOnClick,noPanClassName:e.noPanClassName,rfId:e.rfId}),VO=(e,t,n)=>r=>{let{connectionClickStartHandle:i,connectionMode:a,connection:o}=r,{fromHandle:s,toHandle:c,isValid:l}=o,u=c?.nodeId===e&&c?.id===t&&c?.type===n;return{connectingFrom:s?.nodeId===e&&s?.id===t&&s?.type===n,connectingTo:u,clickConnecting:i?.nodeId===e&&i?.id===t&&i?.type===n,isPossibleEndHandle:a===kw.Strict?s?.type!==n:e!==s?.nodeId||t!==s?.id,connectionInProcess:!!s,clickConnectionInProcess:!!i,valid:u&&l}};function HO({type:e=`source`,position:t=$.Top,isValidConnection:n,isConnectable:r=!0,isConnectableStart:i=!0,isConnectableEnd:a=!0,id:o,onConnect:s,children:c,className:l,onMouseDown:u,onTouchStart:d,...f},p){let m=o||null,h=e===`target`,g=TD(),_=zO(),{connectOnClick:v,noPanClassName:y,rfId:b}=wD(BO,bD),{connectingFrom:x,connectingTo:S,clickConnecting:C,isPossibleEndHandle:w,connectionInProcess:E,clickConnectionInProcess:D,valid:O}=wD(VO(_,m,e),bD);_||g.getState().onError?.(`010`,Tw.error010());let k=e=>{let{defaultEdgeOptions:t,onConnect:n,hasDefaultEdges:r}=g.getState(),i={...t,...e};if(r){let{edges:e,setEdges:t}=g.getState();t(HT(i,e))}n?.(i),s?.(i)},A=e=>{if(!_)return;let t=AT(e.nativeEvent);if(i&&(t&&e.button===0||!t)){let t=g.getState();ME.onPointerDown(e.nativeEvent,{handleDomNode:e.currentTarget,autoPanOnConnect:t.autoPanOnConnect,connectionMode:t.connectionMode,connectionRadius:t.connectionRadius,domNode:t.domNode,nodeLookup:t.nodeLookup,lib:t.lib,isTarget:h,handleId:m,nodeId:_,flowId:t.rfId,panBy:t.panBy,cancelConnection:t.cancelConnection,onConnectStart:t.onConnectStart,onConnectEnd:t.onConnectEnd,updateConnection:t.updateConnection,onConnect:k,isValidConnection:n||t.isValidConnection,getTransform:()=>g.getState().transform,getFromHandle:()=>g.getState().connection.fromHandle,autoPanSpeed:t.autoPanSpeed,dragThreshold:t.connectionDragThreshold})}t?u?.(e):d?.(e)};return(0,T.jsx)(`div`,{"data-handleid":m,"data-nodeid":_,"data-handlepos":t,"data-id":`${b}-${_}-${m}-${e}`,className:Vv([`react-flow__handle`,`react-flow__handle-${t}`,`nodrag`,y,l,{source:!h,target:h,connectable:r,connectablestart:i,connectableend:a,clickconnecting:C,connectingfrom:x,connectingto:S,valid:O,connectionindicator:r&&(!E||w)&&(E||D?a:i)}]),onMouseDown:A,onTouchStart:A,onClick:v?t=>{let{onClickConnectStart:r,onClickConnectEnd:a,connectionClickStartHandle:o,connectionMode:s,isValidConnection:c,lib:l,rfId:u,nodeLookup:d,connection:f}=g.getState();if(!_||!o&&!i)return;if(!o){r?.(t.nativeEvent,{nodeId:_,handleId:m,handleType:e}),g.setState({connectionClickStartHandle:{nodeId:_,type:e,id:m}});return}let p=DT(t.target),h=n||c,{connection:v,isValid:y}=ME.isValid(t.nativeEvent,{handle:{nodeId:_,id:m,type:e},connectionMode:s,fromNodeId:o.nodeId,fromHandleId:o.id||null,fromType:o.type,isValidConnection:h,flowId:u,doc:p,lib:l,nodeLookup:d});y&&v&&k(v);let b=structuredClone(f);delete b.inProgress,b.toPosition=b.toHandle?b.toHandle.position:null,a?.(t,b),g.setState({connectionClickStartHandle:null})}:void 0,ref:p,...f,children:c})}var UO=(0,y.memo)(fO(HO));function WO({data:e,isConnectable:t,sourcePosition:n=$.Bottom}){return(0,T.jsxs)(T.Fragment,{children:[e?.label,(0,T.jsx)(UO,{type:`source`,position:n,isConnectable:t})]})}function GO({data:e,isConnectable:t,targetPosition:n=$.Top,sourcePosition:r=$.Bottom}){return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(UO,{type:`target`,position:n,isConnectable:t}),e?.label,(0,T.jsx)(UO,{type:`source`,position:r,isConnectable:t})]})}function KO(){return null}function qO({data:e,isConnectable:t,targetPosition:n=$.Top}){return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(UO,{type:`target`,position:n,isConnectable:t}),e?.label]})}var JO={ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0}},YO={input:WO,default:GO,output:qO,group:KO};function XO(e){return e.internals.handleBounds===void 0?{width:e.width??e.initialWidth??e.style?.width,height:e.height??e.initialHeight??e.style?.height}:{width:e.width??e.style?.width,height:e.height??e.style?.height}}var ZO=e=>{let{width:t,height:n,x:r,y:i}=Hw(e.nodeLookup,{filter:e=>!!e.selected});return{width:cT(t)?t:null,height:cT(n)?n:null,userSelectionActive:e.userSelectionActive,transformString:`translate(${e.transform[0]}px,${e.transform[1]}px) scale(${e.transform[2]}) translate(${r}px,${i}px)`}};function QO({onSelectionContextMenu:e,noPanClassName:t,disableKeyboardA11y:n}){let r=TD(),{width:i,height:a,transformString:o,userSelectionActive:s}=wD(ZO,bD),c=IO(),l=(0,y.useRef)(null);if((0,y.useEffect)(()=>{n||l.current?.focus({preventScroll:!0})},[n]),PO({nodeRef:l}),s||!i||!a)return null;let u=e?t=>{e(t,r.getState().nodes.filter(e=>e.selected))}:void 0;return(0,T.jsx)(`div`,{className:Vv([`react-flow__nodesselection`,`react-flow__container`,t]),style:{transform:o},children:(0,T.jsx)(`div`,{ref:l,className:`react-flow__nodesselection-rect`,onContextMenu:u,tabIndex:n?void 0:-1,onKeyDown:n?void 0:e=>{Object.prototype.hasOwnProperty.call(JO,e.key)&&(e.preventDefault(),c({direction:JO[e.key],factor:e.shiftKey?4:1}))},style:{width:i,height:a}})})}var $O=typeof window<`u`?window:void 0,ek=e=>({nodesSelectionActive:e.nodesSelectionActive,userSelectionActive:e.userSelectionActive});function tk({children:e,onPaneClick:t,onPaneMouseEnter:n,onPaneMouseMove:r,onPaneMouseLeave:i,onPaneContextMenu:a,onPaneScroll:o,paneClickDistance:s,deleteKeyCode:c,selectionKeyCode:l,selectionOnDrag:u,selectionMode:d,onSelectionStart:f,onSelectionEnd:p,multiSelectionKeyCode:m,panActivationKeyCode:h,zoomActivationKeyCode:g,elementsSelectable:_,zoomOnScroll:v,zoomOnPinch:y,panOnScroll:b,panOnScrollSpeed:x,panOnScrollMode:S,zoomOnDoubleClick:C,panOnDrag:w,defaultViewport:E,translateExtent:D,minZoom:O,maxZoom:k,preventScrolling:A,onSelectionContextMenu:j,noWheelClassName:M,noPanClassName:N,disableKeyboardA11y:P,onViewportChange:ee,isControlledViewport:F}){let{nodesSelectionActive:I,userSelectionActive:L}=wD(ek),R=QD(l,{target:$O}),z=QD(h,{target:$O}),B=z||w,V=z||b,H=u&&B!==!0,U=R||L||H;return CO({deleteKeyCode:c,multiSelectionKeyCode:m}),(0,T.jsx)(DO,{onPaneContextMenu:a,elementsSelectable:_,zoomOnScroll:v,zoomOnPinch:y,panOnScroll:V,panOnScrollSpeed:x,panOnScrollMode:S,zoomOnDoubleClick:C,panOnDrag:!R&&B,defaultViewport:E,translateExtent:D,minZoom:O,maxZoom:k,zoomActivationKeyCode:g,preventScrolling:A,noWheelClassName:M,noPanClassName:N,onViewportChange:ee,isControlledViewport:F,paneClickDistance:s,children:(0,T.jsxs)(MO,{onSelectionStart:f,onSelectionEnd:p,onPaneClick:t,onPaneMouseEnter:n,onPaneMouseMove:r,onPaneMouseLeave:i,onPaneContextMenu:a,onPaneScroll:o,panOnDrag:B,isSelecting:!!U,selectionMode:d,selectionKeyPressed:R,selectionOnDrag:H,children:[e,I&&(0,T.jsx)(QO,{onSelectionContextMenu:j,noPanClassName:N,disableKeyboardA11y:P})]})})}tk.displayName=`FlowRenderer`;var nk=(0,y.memo)(tk),rk=e=>t=>e?Uw(t.nodeLookup,{x:0,y:0,width:t.width,height:t.height},t.transform,!0).map(e=>e.id):Array.from(t.nodeLookup.keys());function ik(e){return wD((0,y.useCallback)(rk(e),[e]),bD)}var ak=e=>e.updateNodeInternals;function ok(){let e=wD(ak),[t]=(0,y.useState)(()=>typeof ResizeObserver>`u`?null:new ResizeObserver(t=>{let n=new Map;t.forEach(e=>{let t=e.target.getAttribute(`data-id`);n.set(t,{id:t,nodeElement:e.target,force:!0})}),e(n)}));return(0,y.useEffect)(()=>()=>{t?.disconnect()},[t]),t}function sk({node:e,nodeType:t,hasDimensions:n,resizeObserver:r}){let i=TD(),a=(0,y.useRef)(null),o=(0,y.useRef)(null),s=(0,y.useRef)(e.sourcePosition),c=(0,y.useRef)(e.targetPosition),l=(0,y.useRef)(t),u=n&&!!e.internals.handleBounds;return(0,y.useEffect)(()=>{a.current&&!e.hidden&&(!u||o.current!==a.current)&&(o.current&&r?.unobserve(o.current),r?.observe(a.current),o.current=a.current)},[u,e.hidden]),(0,y.useEffect)(()=>()=>{o.current&&=(r?.unobserve(o.current),null)},[]),(0,y.useEffect)(()=>{if(a.current){let n=l.current!==t,r=s.current!==e.sourcePosition,o=c.current!==e.targetPosition;(n||r||o)&&(l.current=t,s.current=e.sourcePosition,c.current=e.targetPosition,i.getState().updateNodeInternals(new Map([[e.id,{id:e.id,nodeElement:a.current,force:!0}]])))}},[e.id,t,e.sourcePosition,e.targetPosition]),a}function ck({id:e,onClick:t,onMouseEnter:n,onMouseMove:r,onMouseLeave:i,onContextMenu:a,onDoubleClick:o,nodesDraggable:s,elementsSelectable:c,nodesConnectable:l,nodesFocusable:u,resizeObserver:d,noDragClassName:f,noPanClassName:p,disableKeyboardA11y:m,rfId:h,nodeTypes:g,nodeClickDistance:_,onError:v}){let{node:y,internals:b,isParent:x}=wD(t=>{let n=t.nodeLookup.get(e),r=t.parentLookup.has(e);return{node:n,internals:n.internals,isParent:r}},bD),S=y.type||`default`,C=g?.[S]||YO[S];C===void 0&&(v?.(`003`,Tw.error003(S)),S=`default`,C=g?.default||YO.default);let w=!!(y.draggable||s&&y.draggable===void 0),E=!!(y.selectable||c&&y.selectable===void 0),D=!!(y.connectable||l&&y.connectable===void 0),O=!!(y.focusable||u&&y.focusable===void 0),k=TD(),A=bT(y),j=sk({node:y,nodeType:S,hasDimensions:A,resizeObserver:d}),M=PO({nodeRef:j,disabled:y.hidden||!w,noDragClassName:f,handleSelector:y.dragHandle,nodeId:e,isSelectable:E,nodeClickDistance:_}),N=IO();if(y.hidden)return null;let P=yT(y),ee=XO(y),F=E||w||t||n||r||i,I=n?e=>n(e,{...b.userNode}):void 0,L=r?e=>r(e,{...b.userNode}):void 0,R=i?e=>i(e,{...b.userNode}):void 0,z=a?e=>a(e,{...b.userNode}):void 0,B=o?e=>o(e,{...b.userNode}):void 0,V=n=>{let{selectNodesOnDrag:r,nodeDragThreshold:i}=k.getState();E&&(!r||!w||i>0)&&NO({id:e,store:k,nodeRef:j}),t&&t(n,{...b.userNode})},H=t=>{if(!(kT(t.nativeEvent)||m)){if(Dw.includes(t.key)&&E)NO({id:e,store:k,unselect:t.key===`Escape`,nodeRef:j});else if(w&&y.selected&&Object.prototype.hasOwnProperty.call(JO,t.key)){t.preventDefault();let{ariaLabelConfig:e}=k.getState();k.setState({ariaLiveMessage:e[`node.a11yDescription.ariaLiveMessage`]({direction:t.key.replace(`Arrow`,``).toLowerCase(),x:~~b.positionAbsolute.x,y:~~b.positionAbsolute.y})}),N({direction:JO[t.key],factor:t.shiftKey?4:1})}}},U=()=>{if(m||!j.current?.matches(`:focus-visible`))return;let{transform:t,width:n,height:r,autoPanOnNodeFocus:i,setCenter:a}=k.getState();i&&(Uw(new Map([[e,y]]),{x:0,y:0,width:n,height:r},t,!0).length>0||a(y.position.x+P.width/2,y.position.y+P.height/2,{zoom:t[2]}))};return(0,T.jsx)(`div`,{className:Vv([`react-flow__node`,`react-flow__node-${S}`,{[p]:w},y.className,{selected:y.selected,selectable:E,parent:x,draggable:w,dragging:M}]),ref:j,style:{zIndex:b.z,transform:`translate(${b.positionAbsolute.x}px,${b.positionAbsolute.y}px)`,pointerEvents:F?`all`:`none`,visibility:A?`visible`:`hidden`,...y.style,...ee},"data-id":e,"data-testid":`rf__node-${e}`,onMouseEnter:I,onMouseMove:L,onMouseLeave:R,onContextMenu:z,onClick:V,onDoubleClick:B,onKeyDown:O?H:void 0,tabIndex:O?0:void 0,onFocus:O?U:void 0,role:y.ariaRole??(O?`group`:void 0),"aria-roledescription":`node`,"aria-describedby":m?void 0:`${OD}-${h}`,"aria-label":y.ariaLabel,...y.domAttributes,children:(0,T.jsx)(RO,{value:e,children:(0,T.jsx)(C,{id:e,data:y.data,type:S,positionAbsoluteX:b.positionAbsolute.x,positionAbsoluteY:b.positionAbsolute.y,selected:y.selected??!1,selectable:E,draggable:w,deletable:y.deletable??!0,isConnectable:D,sourcePosition:y.sourcePosition,targetPosition:y.targetPosition,dragging:M,dragHandle:y.dragHandle,zIndex:b.z,parentId:y.parentId,...P})})})}var lk=e=>({nodesDraggable:e.nodesDraggable,nodesConnectable:e.nodesConnectable,nodesFocusable:e.nodesFocusable,elementsSelectable:e.elementsSelectable,onError:e.onError});function uk(e){let{nodesDraggable:t,nodesConnectable:n,nodesFocusable:r,elementsSelectable:i,onError:a}=wD(lk,bD),o=ik(e.onlyRenderVisibleElements),s=ok();return(0,T.jsx)(`div`,{className:`react-flow__nodes`,style:TO,children:o.map(o=>(0,T.jsx)(ck,{id:o,nodeTypes:e.nodeTypes,nodeExtent:e.nodeExtent,onClick:e.onNodeClick,onMouseEnter:e.onNodeMouseEnter,onMouseMove:e.onNodeMouseMove,onMouseLeave:e.onNodeMouseLeave,onContextMenu:e.onNodeContextMenu,onDoubleClick:e.onNodeDoubleClick,noDragClassName:e.noDragClassName,noPanClassName:e.noPanClassName,rfId:e.rfId,disableKeyboardA11y:e.disableKeyboardA11y,resizeObserver:s,nodesDraggable:t,nodesConnectable:n,nodesFocusable:r,elementsSelectable:i,nodeClickDistance:e.nodeClickDistance,onError:a},o))})}uk.displayName=`NodeRenderer`;var dk=(0,y.memo)(uk);function fk(e){return wD((0,y.useCallback)(t=>{if(!e)return t.edges.map(e=>e.id);let n=[];if(t.width&&t.height)for(let e of t.edges){let r=t.nodeLookup.get(e.source),i=t.nodeLookup.get(e.target);r&&i&&zT({sourceNode:r,targetNode:i,width:t.width,height:t.height,transform:t.transform})&&n.push(e.id)}return n},[e]),bD)}var pk=({color:e=`none`,strokeWidth:t=1})=>(0,T.jsx)(`polyline`,{className:`arrow`,style:{strokeWidth:t,...e&&{stroke:e}},strokeLinecap:`round`,fill:`none`,strokeLinejoin:`round`,points:`-5,-4 0,0 -5,4`}),mk=({color:e=`none`,strokeWidth:t=1})=>(0,T.jsx)(`polyline`,{className:`arrowclosed`,style:{strokeWidth:t,...e&&{stroke:e,fill:e}},strokeLinecap:`round`,strokeLinejoin:`round`,points:`-5,-4 0,0 -5,4 -5,-4`}),hk={[Pw.Arrow]:pk,[Pw.ArrowClosed]:mk};function gk(e){let t=TD();return(0,y.useMemo)(()=>Object.prototype.hasOwnProperty.call(hk,e)?hk[e]:(t.getState().onError?.(`009`,Tw.error009(e)),null),[e])}var _k=({id:e,type:t,color:n,width:r=12.5,height:i=12.5,markerUnits:a=`strokeWidth`,strokeWidth:o,orient:s=`auto-start-reverse`})=>{let c=gk(t);return c?(0,T.jsx)(`marker`,{className:`react-flow__arrowhead`,id:e,markerWidth:`${r}`,markerHeight:`${i}`,viewBox:`-10 -10 20 20`,markerUnits:a,orient:s,refX:`0`,refY:`0`,children:(0,T.jsx)(c,{color:n,strokeWidth:o})}):null},vk=({defaultColor:e,rfId:t})=>{let n=wD(e=>e.edges),r=wD(e=>e.defaultEdgeOptions),i=(0,y.useMemo)(()=>nE(n,{id:t,defaultColor:e,defaultMarkerStart:r?.markerStart,defaultMarkerEnd:r?.markerEnd}),[n,r,t,e]);return i.length?(0,T.jsx)(`svg`,{className:`react-flow__marker`,"aria-hidden":`true`,children:(0,T.jsx)(`defs`,{children:i.map(e=>(0,T.jsx)(_k,{id:e.id,type:e.type,color:e.color,width:e.width,height:e.height,markerUnits:e.markerUnits,strokeWidth:e.strokeWidth,orient:e.orient},e.id))})}):null};vk.displayName=`MarkerDefinitions`;var yk=(0,y.memo)(vk);function bk({x:e,y:t,label:n,labelStyle:r,labelShowBg:i=!0,labelBgStyle:a,labelBgPadding:o=[2,4],labelBgBorderRadius:s=2,children:c,className:l,...u}){let[d,f]=(0,y.useState)({x:1,y:0,width:0,height:0}),p=Vv([`react-flow__edge-textwrapper`,l]),m=(0,y.useRef)(null);return(0,y.useEffect)(()=>{if(m.current){let e=m.current.getBBox();f({x:e.x,y:e.y,width:e.width,height:e.height})}},[n]),n?(0,T.jsxs)(`g`,{transform:`translate(${e-d.width/2} ${t-d.height/2})`,className:p,visibility:d.width?`visible`:`hidden`,...u,children:[i&&(0,T.jsx)(`rect`,{width:d.width+2*o[0],x:-o[0],y:-o[1],height:d.height+2*o[1],className:`react-flow__edge-textbg`,style:a,rx:s,ry:s}),(0,T.jsx)(`text`,{className:`react-flow__edge-text`,y:d.height/2,dy:`0.3em`,ref:m,style:r,children:n}),c]}):null}bk.displayName=`EdgeText`;var xk=(0,y.memo)(bk);function Sk({path:e,labelX:t,labelY:n,label:r,labelStyle:i,labelShowBg:a,labelBgStyle:o,labelBgPadding:s,labelBgBorderRadius:c,interactionWidth:l=20,...u}){return(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`path`,{...u,d:e,fill:`none`,className:Vv([`react-flow__edge-path`,u.className])}),l?(0,T.jsx)(`path`,{d:e,fill:`none`,strokeOpacity:0,strokeWidth:l,className:`react-flow__edge-interaction`}):null,r&&cT(t)&&cT(n)?(0,T.jsx)(xk,{x:t,y:n,label:r,labelStyle:i,labelShowBg:a,labelBgStyle:o,labelBgPadding:s,labelBgBorderRadius:c}):null]})}function Ck({pos:e,x1:t,y1:n,x2:r,y2:i}){return e===$.Left||e===$.Right?[.5*(t+r),n]:[t,.5*(n+i)]}function wk({sourceX:e,sourceY:t,sourcePosition:n=$.Bottom,targetX:r,targetY:i,targetPosition:a=$.Top}){let[o,s]=Ck({pos:n,x1:e,y1:t,x2:r,y2:i}),[c,l]=Ck({pos:a,x1:r,y1:i,x2:e,y2:t}),[u,d,f,p]=NT({sourceX:e,sourceY:t,targetX:r,targetY:i,sourceControlX:o,sourceControlY:s,targetControlX:c,targetControlY:l});return[`M${e},${t} C${o},${s} ${c},${l} ${r},${i}`,u,d,f,p]}function Tk(e){return(0,y.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,sourcePosition:o,targetPosition:s,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:_})=>{let[v,y,b]=wk({sourceX:n,sourceY:r,sourcePosition:o,targetX:i,targetY:a,targetPosition:s});return(0,T.jsx)(Sk,{id:e.isInternal?void 0:t,path:v,labelX:y,labelY:b,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:_})})}var Ek=Tk({isInternal:!1}),Dk=Tk({isInternal:!0});Ek.displayName=`SimpleBezierEdge`,Dk.displayName=`SimpleBezierEdgeInternal`;function Ok(e){return(0,y.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,sourcePosition:p=$.Bottom,targetPosition:m=$.Top,markerEnd:h,markerStart:g,pathOptions:_,interactionWidth:v})=>{let[y,b,x]=YT({sourceX:n,sourceY:r,sourcePosition:p,targetX:i,targetY:a,targetPosition:m,borderRadius:_?.borderRadius,offset:_?.offset,stepPosition:_?.stepPosition});return(0,T.jsx)(Sk,{id:e.isInternal?void 0:t,path:y,labelX:b,labelY:x,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:h,markerStart:g,interactionWidth:v})})}var kk=Ok({isInternal:!1}),Ak=Ok({isInternal:!0});kk.displayName=`SmoothStepEdge`,Ak.displayName=`SmoothStepEdgeInternal`;function jk(e){return(0,y.memo)(({id:t,...n})=>{let r=e.isInternal?void 0:t;return(0,T.jsx)(kk,{...n,id:r,pathOptions:(0,y.useMemo)(()=>({borderRadius:0,offset:n.pathOptions?.offset}),[n.pathOptions?.offset])})})}var Mk=jk({isInternal:!1}),Nk=jk({isInternal:!0});Mk.displayName=`StepEdge`,Nk.displayName=`StepEdgeInternal`;function Pk(e){return(0,y.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:p,markerStart:m,interactionWidth:h})=>{let[g,_,v]=UT({sourceX:n,sourceY:r,targetX:i,targetY:a});return(0,T.jsx)(Sk,{id:e.isInternal?void 0:t,path:g,labelX:_,labelY:v,label:o,labelStyle:s,labelShowBg:c,labelBgStyle:l,labelBgPadding:u,labelBgBorderRadius:d,style:f,markerEnd:p,markerStart:m,interactionWidth:h})})}var Fk=Pk({isInternal:!1}),Ik=Pk({isInternal:!0});Fk.displayName=`StraightEdge`,Ik.displayName=`StraightEdgeInternal`;function Lk(e){return(0,y.memo)(({id:t,sourceX:n,sourceY:r,targetX:i,targetY:a,sourcePosition:o=$.Bottom,targetPosition:s=$.Top,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,pathOptions:_,interactionWidth:v})=>{let[y,b,x]=IT({sourceX:n,sourceY:r,sourcePosition:o,targetX:i,targetY:a,targetPosition:s,curvature:_?.curvature});return(0,T.jsx)(Sk,{id:e.isInternal?void 0:t,path:y,labelX:b,labelY:x,label:c,labelStyle:l,labelShowBg:u,labelBgStyle:d,labelBgPadding:f,labelBgBorderRadius:p,style:m,markerEnd:h,markerStart:g,interactionWidth:v})})}var Rk=Lk({isInternal:!1}),zk=Lk({isInternal:!0});Rk.displayName=`BezierEdge`,zk.displayName=`BezierEdgeInternal`;var Bk={default:zk,straight:Ik,step:Nk,smoothstep:Ak,simplebezier:Dk},Vk={sourceX:null,sourceY:null,targetX:null,targetY:null,sourcePosition:null,targetPosition:null},Hk=(e,t,n)=>n===$.Left?e-t:n===$.Right?e+t:e,Uk=(e,t,n)=>n===$.Top?e-t:n===$.Bottom?e+t:e,Wk=`react-flow__edgeupdater`;function Gk({position:e,centerX:t,centerY:n,radius:r=10,onMouseDown:i,onMouseEnter:a,onMouseOut:o,type:s}){return(0,T.jsx)(`circle`,{onMouseDown:i,onMouseEnter:a,onMouseOut:o,className:Vv([Wk,`${Wk}-${s}`]),cx:Hk(t,r,e),cy:Uk(n,r,e),r,stroke:`transparent`,fill:`transparent`})}function Kk({isReconnectable:e,reconnectRadius:t,edge:n,sourceX:r,sourceY:i,targetX:a,targetY:o,sourcePosition:s,targetPosition:c,onReconnect:l,onReconnectStart:u,onReconnectEnd:d,setReconnecting:f,setUpdateHover:p}){let m=TD(),h=(e,t)=>{if(e.button!==0)return;let{autoPanOnConnect:r,domNode:i,isValidConnection:a,connectionMode:o,connectionRadius:s,lib:c,onConnectStart:p,onConnectEnd:h,cancelConnection:g,nodeLookup:_,rfId:v,panBy:y,updateConnection:b}=m.getState(),x=t.type===`target`;ME.onPointerDown(e.nativeEvent,{autoPanOnConnect:r,connectionMode:o,connectionRadius:s,domNode:i,handleId:t.id,nodeId:t.nodeId,nodeLookup:_,isTarget:x,edgeUpdaterType:t.type,lib:c,flowId:v,cancelConnection:g,panBy:y,isValidConnection:a,onConnect:e=>l?.(n,e),onConnectStart:(r,i)=>{f(!0),u?.(e,n,t.type),p?.(r,i)},onConnectEnd:h,onReconnectEnd:(e,r)=>{f(!1),d?.(e,n,t.type,r)},updateConnection:b,getTransform:()=>m.getState().transform,getFromHandle:()=>m.getState().connection.fromHandle,dragThreshold:m.getState().connectionDragThreshold,handleDomNode:e.currentTarget})},g=e=>h(e,{nodeId:n.target,id:n.targetHandle??null,type:`target`}),_=e=>h(e,{nodeId:n.source,id:n.sourceHandle??null,type:`source`}),v=()=>p(!0),y=()=>p(!1);return(0,T.jsxs)(T.Fragment,{children:[(e===!0||e===`source`)&&(0,T.jsx)(Gk,{position:s,centerX:r,centerY:i,radius:t,onMouseDown:g,onMouseEnter:v,onMouseOut:y,type:`source`}),(e===!0||e===`target`)&&(0,T.jsx)(Gk,{position:c,centerX:a,centerY:o,radius:t,onMouseDown:_,onMouseEnter:v,onMouseOut:y,type:`target`})]})}function qk({id:e,edgesFocusable:t,edgesReconnectable:n,elementsSelectable:r,onClick:i,onDoubleClick:a,onContextMenu:o,onMouseEnter:s,onMouseMove:c,onMouseLeave:l,reconnectRadius:u,onReconnect:d,onReconnectStart:f,onReconnectEnd:p,rfId:m,edgeTypes:h,noPanClassName:g,onError:_,disableKeyboardA11y:v}){let b=wD(t=>t.edgeLookup.get(e)),x=wD(e=>e.defaultEdgeOptions);b=x?{...x,...b}:b;let S=b.type||`default`,C=h?.[S]||Bk[S];C===void 0&&(_?.(`011`,Tw.error011(S)),S=`default`,C=h?.default||Bk.default);let w=!!(b.focusable||t&&b.focusable===void 0),E=d!==void 0&&(b.reconnectable||n&&b.reconnectable===void 0),D=!!(b.selectable||r&&b.selectable===void 0),O=(0,y.useRef)(null),[k,A]=(0,y.useState)(!1),[j,M]=(0,y.useState)(!1),N=TD(),{zIndex:P,sourceX:ee,sourceY:F,targetX:I,targetY:L,sourcePosition:R,targetPosition:z}=wD((0,y.useCallback)(t=>{let n=t.nodeLookup.get(b.source),r=t.nodeLookup.get(b.target);if(!n||!r)return{zIndex:b.zIndex,...Vk};let i=ZT({id:e,sourceNode:n,targetNode:r,sourceHandle:b.sourceHandle||null,targetHandle:b.targetHandle||null,connectionMode:t.connectionMode,onError:_});return{zIndex:RT({selected:b.selected,zIndex:b.zIndex,sourceNode:n,targetNode:r,elevateOnSelect:t.elevateEdgesOnSelect}),...i||Vk}},[b.source,b.target,b.sourceHandle,b.targetHandle,b.selected,b.zIndex]),bD),B=(0,y.useMemo)(()=>b.markerStart?`url('#${tE(b.markerStart,m)}')`:void 0,[b.markerStart,m]),V=(0,y.useMemo)(()=>b.markerEnd?`url('#${tE(b.markerEnd,m)}')`:void 0,[b.markerEnd,m]);if(b.hidden||ee===null||F===null||I===null||L===null)return null;let H=t=>{let{addSelectedEdges:n,unselectNodesAndEdges:r,multiSelectionActive:a}=N.getState();D&&(N.setState({nodesSelectionActive:!1}),b.selected&&a?(r({nodes:[],edges:[b]}),O.current?.blur()):n([e])),i&&i(t,b)},U=a?e=>{a(e,{...b})}:void 0,te=o?e=>{o(e,{...b})}:void 0,ne=s?e=>{s(e,{...b})}:void 0,re=c?e=>{c(e,{...b})}:void 0,ie=l?e=>{l(e,{...b})}:void 0;return(0,T.jsx)(`svg`,{style:{zIndex:P},children:(0,T.jsxs)(`g`,{className:Vv([`react-flow__edge`,`react-flow__edge-${S}`,b.className,g,{selected:b.selected,animated:b.animated,inactive:!D&&!i,updating:k,selectable:D}]),onClick:H,onDoubleClick:U,onContextMenu:te,onMouseEnter:ne,onMouseMove:re,onMouseLeave:ie,onKeyDown:w?t=>{if(!v&&Dw.includes(t.key)&&D){let{unselectNodesAndEdges:n,addSelectedEdges:r}=N.getState();t.key===`Escape`?(O.current?.blur(),n({edges:[b]})):r([e])}}:void 0,tabIndex:w?0:void 0,role:b.ariaRole??(w?`group`:`img`),"aria-roledescription":`edge`,"data-id":e,"data-testid":`rf__edge-${e}`,"aria-label":b.ariaLabel===null?void 0:b.ariaLabel||`Edge from ${b.source} to ${b.target}`,"aria-describedby":w?`${kD}-${m}`:void 0,ref:O,...b.domAttributes,children:[!j&&(0,T.jsx)(C,{id:e,source:b.source,target:b.target,type:b.type,selected:b.selected,animated:b.animated,selectable:D,deletable:b.deletable??!0,label:b.label,labelStyle:b.labelStyle,labelShowBg:b.labelShowBg,labelBgStyle:b.labelBgStyle,labelBgPadding:b.labelBgPadding,labelBgBorderRadius:b.labelBgBorderRadius,sourceX:ee,sourceY:F,targetX:I,targetY:L,sourcePosition:R,targetPosition:z,data:b.data,style:b.style,sourceHandleId:b.sourceHandle,targetHandleId:b.targetHandle,markerStart:B,markerEnd:V,pathOptions:`pathOptions`in b?b.pathOptions:void 0,interactionWidth:b.interactionWidth}),E&&(0,T.jsx)(Kk,{edge:b,isReconnectable:E,reconnectRadius:u,onReconnect:d,onReconnectStart:f,onReconnectEnd:p,sourceX:ee,sourceY:F,targetX:I,targetY:L,sourcePosition:R,targetPosition:z,setUpdateHover:A,setReconnecting:M})]})})}var Jk=e=>({edgesFocusable:e.edgesFocusable,edgesReconnectable:e.edgesReconnectable,elementsSelectable:e.elementsSelectable,connectionMode:e.connectionMode,onError:e.onError});function Yk({defaultMarkerColor:e,onlyRenderVisibleElements:t,rfId:n,edgeTypes:r,noPanClassName:i,onReconnect:a,onEdgeContextMenu:o,onEdgeMouseEnter:s,onEdgeMouseMove:c,onEdgeMouseLeave:l,onEdgeClick:u,reconnectRadius:d,onEdgeDoubleClick:f,onReconnectStart:p,onReconnectEnd:m,disableKeyboardA11y:h}){let{edgesFocusable:g,edgesReconnectable:_,elementsSelectable:v,onError:y}=wD(Jk,bD),b=fk(t);return(0,T.jsxs)(`div`,{className:`react-flow__edges`,children:[(0,T.jsx)(yk,{defaultColor:e,rfId:n}),b.map(e=>(0,T.jsx)(qk,{id:e,edgesFocusable:g,edgesReconnectable:_,elementsSelectable:v,noPanClassName:i,onReconnect:a,onContextMenu:o,onMouseEnter:s,onMouseMove:c,onMouseLeave:l,onClick:u,reconnectRadius:d,onDoubleClick:f,onReconnectStart:p,onReconnectEnd:m,rfId:n,onError:y,edgeTypes:r,disableKeyboardA11y:h},e))]})}Yk.displayName=`EdgeRenderer`;var Xk=(0,y.memo)(Yk),Zk=e=>`translate(${e.transform[0]}px,${e.transform[1]}px) scale(${e.transform[2]})`;function Qk({children:e}){return(0,T.jsx)(`div`,{className:`react-flow__viewport xyflow__viewport react-flow__container`,style:{transform:wD(Zk)},children:e})}function $k(e){let t=bO(),n=(0,y.useRef)(!1);(0,y.useEffect)(()=>{!n.current&&t.viewportInitialized&&e&&(setTimeout(()=>e(t),1),n.current=!0)},[e,t.viewportInitialized])}var eA=e=>e.panZoom?.syncViewport;function tA(e){let t=wD(eA),n=TD();return(0,y.useEffect)(()=>{e&&(t?.(e),n.setState({transform:[e.x,e.y,e.zoom]}))},[e,t]),null}function nA(e){return e.connection.inProgress?{...e.connection,to:dT(e.connection.to,e.transform)}:{...e.connection}}function rA(e){return e?t=>e(nA(t)):nA}function iA(e){return wD(rA(e),bD)}var aA=e=>({nodesConnectable:e.nodesConnectable,isValid:e.connection.isValid,inProgress:e.connection.inProgress,width:e.width,height:e.height});function oA({containerStyle:e,style:t,type:n,component:r}){let{nodesConnectable:i,width:a,height:o,isValid:s,inProgress:c}=wD(aA,bD);return a&&i&&c?(0,T.jsx)(`svg`,{style:e,width:a,height:o,className:`react-flow__connectionline react-flow__container`,children:(0,T.jsx)(`g`,{className:Vv([`react-flow__connection`,Iw(s)]),children:(0,T.jsx)(sA,{style:t,type:n,CustomComponent:r,isValid:s})})}):null}var sA=({style:e,type:t=Nw.Bezier,CustomComponent:n,isValid:r})=>{let{inProgress:i,from:a,fromNode:o,fromHandle:s,fromPosition:c,to:l,toNode:u,toHandle:d,toPosition:f}=iA();if(!i)return;if(n)return(0,T.jsx)(n,{connectionLineType:t,connectionLineStyle:e,fromNode:o,fromHandle:s,fromX:a.x,fromY:a.y,toX:l.x,toY:l.y,fromPosition:c,toPosition:f,connectionStatus:Iw(r),toNode:u,toHandle:d});let p=``,m={sourceX:a.x,sourceY:a.y,sourcePosition:c,targetX:l.x,targetY:l.y,targetPosition:f};switch(t){case Nw.Bezier:[p]=IT(m);break;case Nw.SimpleBezier:[p]=wk(m);break;case Nw.Step:[p]=YT({...m,borderRadius:0});break;case Nw.SmoothStep:[p]=YT(m);break;default:[p]=UT(m)}return(0,T.jsx)(`path`,{d:p,fill:`none`,className:`react-flow__connection-path`,style:e})};sA.displayName=`ConnectionLine`;var cA={};function lA(e=cA){(0,y.useRef)(e),TD(),(0,y.useEffect)(()=>{},[e])}function uA(){TD(),(0,y.useRef)(!1),(0,y.useEffect)(()=>{},[])}function dA({nodeTypes:e,edgeTypes:t,onInit:n,onNodeClick:r,onEdgeClick:i,onNodeDoubleClick:a,onEdgeDoubleClick:o,onNodeMouseEnter:s,onNodeMouseMove:c,onNodeMouseLeave:l,onNodeContextMenu:u,onSelectionContextMenu:d,onSelectionStart:f,onSelectionEnd:p,connectionLineType:m,connectionLineStyle:h,connectionLineComponent:g,connectionLineContainerStyle:_,selectionKeyCode:v,selectionOnDrag:y,selectionMode:b,multiSelectionKeyCode:x,panActivationKeyCode:S,zoomActivationKeyCode:C,deleteKeyCode:w,onlyRenderVisibleElements:E,elementsSelectable:D,defaultViewport:O,translateExtent:k,minZoom:A,maxZoom:j,preventScrolling:M,defaultMarkerColor:N,zoomOnScroll:P,zoomOnPinch:ee,panOnScroll:F,panOnScrollSpeed:I,panOnScrollMode:L,zoomOnDoubleClick:R,panOnDrag:z,onPaneClick:B,onPaneMouseEnter:V,onPaneMouseMove:H,onPaneMouseLeave:U,onPaneScroll:te,onPaneContextMenu:ne,paneClickDistance:re,nodeClickDistance:ie,onEdgeContextMenu:ae,onEdgeMouseEnter:oe,onEdgeMouseMove:se,onEdgeMouseLeave:ce,reconnectRadius:le,onReconnect:ue,onReconnectStart:de,onReconnectEnd:fe,noDragClassName:pe,noWheelClassName:me,noPanClassName:he,disableKeyboardA11y:ge,nodeExtent:_e,rfId:ve,viewport:ye,onViewportChange:be}){return lA(e),lA(t),uA(),$k(n),tA(ye),(0,T.jsx)(nk,{onPaneClick:B,onPaneMouseEnter:V,onPaneMouseMove:H,onPaneMouseLeave:U,onPaneContextMenu:ne,onPaneScroll:te,paneClickDistance:re,deleteKeyCode:w,selectionKeyCode:v,selectionOnDrag:y,selectionMode:b,onSelectionStart:f,onSelectionEnd:p,multiSelectionKeyCode:x,panActivationKeyCode:S,zoomActivationKeyCode:C,elementsSelectable:D,zoomOnScroll:P,zoomOnPinch:ee,zoomOnDoubleClick:R,panOnScroll:F,panOnScrollSpeed:I,panOnScrollMode:L,panOnDrag:z,defaultViewport:O,translateExtent:k,minZoom:A,maxZoom:j,onSelectionContextMenu:d,preventScrolling:M,noDragClassName:pe,noWheelClassName:me,noPanClassName:he,disableKeyboardA11y:ge,onViewportChange:be,isControlledViewport:!!ye,children:(0,T.jsxs)(Qk,{children:[(0,T.jsx)(Xk,{edgeTypes:t,onEdgeClick:i,onEdgeDoubleClick:o,onReconnect:ue,onReconnectStart:de,onReconnectEnd:fe,onlyRenderVisibleElements:E,onEdgeContextMenu:ae,onEdgeMouseEnter:oe,onEdgeMouseMove:se,onEdgeMouseLeave:ce,reconnectRadius:le,defaultMarkerColor:N,noPanClassName:he,disableKeyboardA11y:ge,rfId:ve}),(0,T.jsx)(oA,{style:h,type:m,component:g,containerStyle:_}),(0,T.jsx)(`div`,{className:`react-flow__edgelabel-renderer`}),(0,T.jsx)(dk,{nodeTypes:e,onNodeClick:r,onNodeDoubleClick:a,onNodeMouseEnter:s,onNodeMouseMove:c,onNodeMouseLeave:l,onNodeContextMenu:u,nodeClickDistance:ie,onlyRenderVisibleElements:E,noPanClassName:he,noDragClassName:pe,disableKeyboardA11y:ge,nodeExtent:_e,rfId:ve}),(0,T.jsx)(`div`,{className:`react-flow__viewport-portal`})]})})}dA.displayName=`GraphView`;var fA=(0,y.memo)(dA),pA=({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c=.5,maxZoom:l=2,nodeOrigin:u,nodeExtent:d}={})=>{let f=new Map,p=new Map,m=new Map,h=new Map,g=r??t??[],_=n??e??[],v=u??[0,0],y=d??Ew;gE(m,h,g);let b=sE(_,f,p,{nodeOrigin:v,nodeExtent:y,elevateNodesOnSelect:!1}),x=[0,0,1];if(o&&i&&a){let{x:e,y:t,zoom:n}=gT(Hw(f,{filter:e=>!!((e.width||e.initialWidth)&&(e.height||e.initialHeight))}),i,a,c,l,s?.padding??.1);x=[e,t,n]}return{rfId:`1`,width:0,height:0,transform:x,nodes:_,nodesInitialized:b,nodeLookup:f,parentLookup:p,edges:g,edgeLookup:h,connectionLookup:m,onNodesChange:null,onEdgesChange:null,hasDefaultNodes:n!==void 0,hasDefaultEdges:r!==void 0,panZoom:null,minZoom:c,maxZoom:l,translateExtent:Ew,nodeExtent:y,nodesSelectionActive:!1,userSelectionActive:!1,userSelectionRect:null,connectionMode:kw.Strict,domNode:null,paneDragging:!1,noPanClassName:`nopan`,nodeOrigin:v,nodeDragThreshold:1,connectionDragThreshold:1,snapGrid:[15,15],snapToGrid:!1,nodesDraggable:!0,nodesConnectable:!0,nodesFocusable:!0,edgesFocusable:!0,edgesReconnectable:!0,elementsSelectable:!0,elevateNodesOnSelect:!0,elevateEdgesOnSelect:!1,selectNodesOnDrag:!0,multiSelectionActive:!1,fitViewQueued:o??!1,fitViewOptions:s,fitViewResolver:null,connection:{...Mw},connectionClickStartHandle:null,connectOnClick:!0,ariaLiveMessage:``,autoPanOnConnect:!0,autoPanOnNodeDrag:!0,autoPanOnNodeFocus:!0,autoPanSpeed:15,connectionRadius:20,onError:lT,isValidConnection:void 0,onSelectionChangeHandlers:[],lib:`react`,debug:!1,ariaLabelConfig:Ow}},mA=({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c,maxZoom:l,nodeOrigin:u,nodeExtent:d})=>yD((f,p)=>{async function m(){let{nodeLookup:e,panZoom:t,fitViewOptions:n,fitViewResolver:r,width:i,height:a,minZoom:o,maxZoom:s}=p();t&&(await Kw({nodes:e,width:i,height:a,panZoom:t,minZoom:o,maxZoom:s},n),r?.resolve(!0),f({fitViewResolver:null}))}return{...pA({nodes:e,edges:t,width:i,height:a,fitView:o,fitViewOptions:s,minZoom:c,maxZoom:l,nodeOrigin:u,nodeExtent:d,defaultNodes:n,defaultEdges:r}),setNodes:e=>{let{nodeLookup:t,parentLookup:n,nodeOrigin:r,elevateNodesOnSelect:i,fitViewQueued:a}=p(),o=sE(e,t,n,{nodeOrigin:r,nodeExtent:d,elevateNodesOnSelect:i,checkEquality:!0});a&&o?(m(),f({nodes:e,nodesInitialized:o,fitViewQueued:!1,fitViewOptions:void 0})):f({nodes:e,nodesInitialized:o})},setEdges:e=>{let{connectionLookup:t,edgeLookup:n}=p();gE(t,n,e),f({edges:e})},setDefaultNodesAndEdges:(e,t)=>{if(e){let{setNodes:t}=p();t(e),f({hasDefaultNodes:!0})}if(t){let{setEdges:e}=p();e(t),f({hasDefaultEdges:!0})}},updateNodeInternals:e=>{let{triggerNodeChanges:t,nodeLookup:n,parentLookup:r,domNode:i,nodeOrigin:a,nodeExtent:o,debug:s,fitViewQueued:c}=p(),{changes:l,updatedInternals:u}=pE(e,n,r,i,a,o);u&&(oE(n,r,{nodeOrigin:a,nodeExtent:o}),c?(m(),f({fitViewQueued:!1,fitViewOptions:void 0})):f({}),l?.length>0&&(s&&console.log(`React Flow: trigger node changes`,l),t?.(l)))},updateNodePositions:(e,t=!1)=>{let n=[],r=[],{nodeLookup:i,triggerNodeChanges:a}=p();for(let[a,o]of e){let e=i.get(a),s=!!(e?.expandParent&&e?.parentId&&o?.position),c={id:a,type:`position`,position:s?{x:Math.max(0,o.position.x),y:Math.max(0,o.position.y)}:o.position,dragging:t};s&&e.parentId&&n.push({id:a,parentId:e.parentId,rect:{...o.internals.positionAbsolute,width:o.measured.width??0,height:o.measured.height??0}}),r.push(c)}if(n.length>0){let{parentLookup:e,nodeOrigin:t}=p(),a=fE(n,i,e,t);r.push(...a)}a(r)},triggerNodeChanges:e=>{let{onNodesChange:t,setNodes:n,nodes:r,hasDefaultNodes:i,debug:a}=p();e?.length&&(i&&n(iO(e,r)),a&&console.log(`React Flow: trigger node changes`,e),t?.(e))},triggerEdgeChanges:e=>{let{onEdgesChange:t,setEdges:n,edges:r,hasDefaultEdges:i,debug:a}=p();e?.length&&(i&&n(aO(e,r)),a&&console.log(`React Flow: trigger edge changes`,e),t?.(e))},addSelectedNodes:e=>{let{multiSelectionActive:t,edgeLookup:n,nodeLookup:r,triggerNodeChanges:i,triggerEdgeChanges:a}=p();if(t){i(e.map(e=>oO(e,!0)));return}i(sO(r,new Set([...e]),!0)),a(sO(n))},addSelectedEdges:e=>{let{multiSelectionActive:t,edgeLookup:n,nodeLookup:r,triggerNodeChanges:i,triggerEdgeChanges:a}=p();if(t){a(e.map(e=>oO(e,!0)));return}a(sO(n,new Set([...e]))),i(sO(r,new Set,!0))},unselectNodesAndEdges:({nodes:e,edges:t}={})=>{let{edges:n,nodes:r,nodeLookup:i,triggerNodeChanges:a,triggerEdgeChanges:o}=p(),s=e||r,c=t||n,l=s.map(e=>{let t=i.get(e.id);return t&&(t.selected=!1),oO(e.id,!1)}),u=c.map(e=>oO(e.id,!1));a(l),o(u)},setMinZoom:e=>{let{panZoom:t,maxZoom:n}=p();t?.setScaleExtent([e,n]),f({minZoom:e})},setMaxZoom:e=>{let{panZoom:t,minZoom:n}=p();t?.setScaleExtent([n,e]),f({maxZoom:e})},setTranslateExtent:e=>{p().panZoom?.setTranslateExtent(e),f({translateExtent:e})},setPaneClickDistance:e=>{p().panZoom?.setClickDistance(e)},resetSelectedElements:()=>{let{edges:e,nodes:t,triggerNodeChanges:n,triggerEdgeChanges:r,elementsSelectable:i}=p();if(!i)return;let a=t.reduce((e,t)=>t.selected?[...e,oO(t.id,!1)]:e,[]),o=e.reduce((e,t)=>t.selected?[...e,oO(t.id,!1)]:e,[]);n(a),r(o)},setNodeExtent:e=>{let{nodes:t,nodeLookup:n,parentLookup:r,nodeOrigin:i,elevateNodesOnSelect:a,nodeExtent:o}=p();e[0][0]===o[0][0]&&e[0][1]===o[0][1]&&e[1][0]===o[1][0]&&e[1][1]===o[1][1]||(sE(t,n,r,{nodeOrigin:i,nodeExtent:e,elevateNodesOnSelect:a,checkEquality:!1}),f({nodeExtent:e}))},panBy:e=>{let{transform:t,width:n,height:r,panZoom:i,translateExtent:a}=p();return mE({delta:e,panZoom:i,transform:t,translateExtent:a,width:n,height:r})},setCenter:async(e,t,n)=>{let{width:r,height:i,maxZoom:a,panZoom:o}=p();if(!o)return Promise.resolve(!1);let s=n?.zoom===void 0?a:n.zoom;return await o.setViewport({x:r/2-e*s,y:i/2-t*s,zoom:s},{duration:n?.duration,ease:n?.ease,interpolate:n?.interpolate}),Promise.resolve(!0)},cancelConnection:()=>{f({connection:{...Mw}})},updateConnection:e=>{f({connection:e})},reset:()=>f({...pA()})}},Object.is);function hA({initialNodes:e,initialEdges:t,defaultNodes:n,defaultEdges:r,initialWidth:i,initialHeight:a,initialMinZoom:o,initialMaxZoom:s,initialFitViewOptions:c,fitView:l,nodeOrigin:u,nodeExtent:d,children:f}){let[p]=(0,y.useState)(()=>mA({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,width:i,height:a,fitView:l,minZoom:o,maxZoom:s,fitViewOptions:c,nodeOrigin:u,nodeExtent:d}));return(0,T.jsx)(SD,{value:p,children:(0,T.jsx)(_O,{children:f})})}function gA({children:e,nodes:t,edges:n,defaultNodes:r,defaultEdges:i,width:a,height:o,fitView:s,fitViewOptions:c,minZoom:l,maxZoom:u,nodeOrigin:d,nodeExtent:f}){return(0,y.useContext)(xD)?(0,T.jsx)(T.Fragment,{children:e}):(0,T.jsx)(hA,{initialNodes:t,initialEdges:n,defaultNodes:r,defaultEdges:i,initialWidth:a,initialHeight:o,fitView:s,initialFitViewOptions:c,initialMinZoom:l,initialMaxZoom:u,nodeOrigin:d,nodeExtent:f,children:e})}var _A={width:`100%`,height:`100%`,overflow:`hidden`,position:`relative`,zIndex:0};function vA({nodes:e,edges:t,defaultNodes:n,defaultEdges:r,className:i,nodeTypes:a,edgeTypes:o,onNodeClick:s,onEdgeClick:c,onInit:l,onMove:u,onMoveStart:d,onMoveEnd:f,onConnect:p,onConnectStart:m,onConnectEnd:h,onClickConnectStart:g,onClickConnectEnd:_,onNodeMouseEnter:v,onNodeMouseMove:b,onNodeMouseLeave:x,onNodeContextMenu:S,onNodeDoubleClick:C,onNodeDragStart:w,onNodeDrag:E,onNodeDragStop:D,onNodesDelete:O,onEdgesDelete:k,onDelete:A,onSelectionChange:j,onSelectionDragStart:M,onSelectionDrag:N,onSelectionDragStop:P,onSelectionContextMenu:ee,onSelectionStart:F,onSelectionEnd:I,onBeforeDelete:L,connectionMode:R,connectionLineType:z=Nw.Bezier,connectionLineStyle:B,connectionLineComponent:V,connectionLineContainerStyle:H,deleteKeyCode:U=`Backspace`,selectionKeyCode:te=`Shift`,selectionOnDrag:ne=!1,selectionMode:re=jw.Full,panActivationKeyCode:ie=`Space`,multiSelectionKeyCode:ae=_T()?`Meta`:`Control`,zoomActivationKeyCode:oe=_T()?`Meta`:`Control`,snapToGrid:se,snapGrid:ce,onlyRenderVisibleElements:le=!1,selectNodesOnDrag:ue,nodesDraggable:de,autoPanOnNodeFocus:fe,nodesConnectable:pe,nodesFocusable:me,nodeOrigin:he=UD,edgesFocusable:ge,edgesReconnectable:_e,elementsSelectable:ve=!0,defaultViewport:ye=WD,minZoom:be=.5,maxZoom:xe=2,translateExtent:Se=Ew,preventScrolling:Ce=!0,nodeExtent:we,defaultMarkerColor:Te=`#b1b1b7`,zoomOnScroll:Ee=!0,zoomOnPinch:De=!0,panOnScroll:Oe=!1,panOnScrollSpeed:ke=.5,panOnScrollMode:Ae=Aw.Free,zoomOnDoubleClick:je=!0,panOnDrag:Me=!0,onPaneClick:Ne,onPaneMouseEnter:Pe,onPaneMouseMove:Fe,onPaneMouseLeave:Ie,onPaneScroll:Le,onPaneContextMenu:W,paneClickDistance:Re=0,nodeClickDistance:ze=0,children:Be,onReconnect:Ve,onReconnectStart:He,onReconnectEnd:G,onEdgeContextMenu:Ue,onEdgeDoubleClick:We,onEdgeMouseEnter:Ge,onEdgeMouseMove:Ke,onEdgeMouseLeave:qe,reconnectRadius:Je=10,onNodesChange:Ye,onEdgesChange:Xe,noDragClassName:Ze=`nodrag`,noWheelClassName:Qe=`nowheel`,noPanClassName:$e=`nopan`,fitView:et,fitViewOptions:tt,connectOnClick:nt,attributionPosition:rt,proOptions:it,defaultEdgeOptions:at,elevateNodesOnSelect:ot,elevateEdgesOnSelect:K,disableKeyboardA11y:st=!1,autoPanOnConnect:ct,autoPanOnNodeDrag:q,autoPanSpeed:lt,connectionRadius:ut,isValidConnection:dt,onError:ft,style:pt,id:mt,nodeDragThreshold:ht,connectionDragThreshold:gt,viewport:J,onViewportChange:_t,width:vt,height:yt,colorMode:bt=`light`,debug:xt,onScroll:St,ariaLabelConfig:Ct,...wt},Tt){let Et=mt||`1`,Dt=XD(bt),Ot=(0,y.useCallback)(e=>{e.currentTarget.scrollTo({top:0,left:0,behavior:`instant`}),St?.(e)},[St]);return(0,T.jsx)(`div`,{"data-testid":`rf__wrapper`,...wt,onScroll:Ot,style:{...pt,..._A},ref:Tt,className:Vv([`react-flow`,i,Dt]),id:mt,role:`application`,children:(0,T.jsxs)(gA,{nodes:e,edges:t,width:vt,height:yt,fitView:et,fitViewOptions:tt,minZoom:be,maxZoom:xe,nodeOrigin:he,nodeExtent:we,children:[(0,T.jsx)(fA,{onInit:l,onNodeClick:s,onEdgeClick:c,onNodeMouseEnter:v,onNodeMouseMove:b,onNodeMouseLeave:x,onNodeContextMenu:S,onNodeDoubleClick:C,nodeTypes:a,edgeTypes:o,connectionLineType:z,connectionLineStyle:B,connectionLineComponent:V,connectionLineContainerStyle:H,selectionKeyCode:te,selectionOnDrag:ne,selectionMode:re,deleteKeyCode:U,multiSelectionKeyCode:ae,panActivationKeyCode:ie,zoomActivationKeyCode:oe,onlyRenderVisibleElements:le,defaultViewport:ye,translateExtent:Se,minZoom:be,maxZoom:xe,preventScrolling:Ce,zoomOnScroll:Ee,zoomOnPinch:De,zoomOnDoubleClick:je,panOnScroll:Oe,panOnScrollSpeed:ke,panOnScrollMode:Ae,panOnDrag:Me,onPaneClick:Ne,onPaneMouseEnter:Pe,onPaneMouseMove:Fe,onPaneMouseLeave:Ie,onPaneScroll:Le,onPaneContextMenu:W,paneClickDistance:Re,nodeClickDistance:ze,onSelectionContextMenu:ee,onSelectionStart:F,onSelectionEnd:I,onReconnect:Ve,onReconnectStart:He,onReconnectEnd:G,onEdgeContextMenu:Ue,onEdgeDoubleClick:We,onEdgeMouseEnter:Ge,onEdgeMouseMove:Ke,onEdgeMouseLeave:qe,reconnectRadius:Je,defaultMarkerColor:Te,noDragClassName:Ze,noWheelClassName:Qe,noPanClassName:$e,rfId:Et,disableKeyboardA11y:st,nodeExtent:we,viewport:J,onViewportChange:_t}),(0,T.jsx)(JD,{nodes:e,edges:t,defaultNodes:n,defaultEdges:r,onConnect:p,onConnectStart:m,onConnectEnd:h,onClickConnectStart:g,onClickConnectEnd:_,nodesDraggable:de,autoPanOnNodeFocus:fe,nodesConnectable:pe,nodesFocusable:me,edgesFocusable:ge,edgesReconnectable:_e,elementsSelectable:ve,elevateNodesOnSelect:ot,elevateEdgesOnSelect:K,minZoom:be,maxZoom:xe,nodeExtent:we,onNodesChange:Ye,onEdgesChange:Xe,snapToGrid:se,snapGrid:ce,connectionMode:R,translateExtent:Se,connectOnClick:nt,defaultEdgeOptions:at,fitView:et,fitViewOptions:tt,onNodesDelete:O,onEdgesDelete:k,onDelete:A,onNodeDragStart:w,onNodeDrag:E,onNodeDragStop:D,onSelectionDrag:N,onSelectionDragStart:M,onSelectionDragStop:P,onMove:u,onMoveStart:d,onMoveEnd:f,noPanClassName:$e,nodeOrigin:he,rfId:Et,autoPanOnConnect:ct,autoPanOnNodeDrag:q,autoPanSpeed:lt,onError:ft,connectionRadius:ut,isValidConnection:dt,selectNodesOnDrag:ue,nodeDragThreshold:ht,connectionDragThreshold:gt,onBeforeDelete:L,paneClickDistance:Re,debug:xt,ariaLabelConfig:Ct}),(0,T.jsx)(HD,{onSelectionChange:j}),Be,(0,T.jsx)(ID,{proOptions:it,position:rt}),(0,T.jsx)(PD,{rfId:Et,disableKeyboardA11y:st})]})})}var yA=fO(vA);function bA(e){let[t,n]=(0,y.useState)(e);return[t,n,(0,y.useCallback)(e=>n(t=>iO(e,t)),[])]}function xA(e){let[t,n]=(0,y.useState)(e);return[t,n,(0,y.useCallback)(e=>n(t=>aO(e,t)),[])]}Tw.error014();function SA(e){return wD((0,y.useCallback)(t=>t.nodeLookup.get(e),[e]),bD)}function CA({dimensions:e,lineWidth:t,variant:n,className:r}){return(0,T.jsx)(`path`,{strokeWidth:t,d:`M${e[0]/2} 0 V${e[1]} M0 ${e[1]/2} H${e[0]}`,className:Vv([`react-flow__background-pattern`,n,r])})}function wA({radius:e,className:t}){return(0,T.jsx)(`circle`,{cx:e,cy:e,r:e,className:Vv([`react-flow__background-pattern`,`dots`,t])})}var TA;(function(e){e.Lines=`lines`,e.Dots=`dots`,e.Cross=`cross`})(TA||={});var EA={[TA.Dots]:1,[TA.Lines]:1,[TA.Cross]:6},DA=e=>({transform:e.transform,patternId:`pattern-${e.rfId}`});function OA({id:e,variant:t=TA.Dots,gap:n=20,size:r,lineWidth:i=1,offset:a=0,color:o,bgColor:s,style:c,className:l,patternClassName:u}){let d=(0,y.useRef)(null),{transform:f,patternId:p}=wD(DA,bD),m=r||EA[t],h=t===TA.Dots,g=t===TA.Cross,_=Array.isArray(n)?n:[n,n],v=[_[0]*f[2]||1,_[1]*f[2]||1],b=m*f[2],x=Array.isArray(a)?a:[a,a],S=g?[b,b]:v,C=[x[0]*f[2]||1+S[0]/2,x[1]*f[2]||1+S[1]/2],w=`${p}${e||``}`;return(0,T.jsxs)(`svg`,{className:Vv([`react-flow__background`,l]),style:{...c,...TO,"--xy-background-color-props":s,"--xy-background-pattern-color-props":o},ref:d,"data-testid":`rf__background`,children:[(0,T.jsx)(`pattern`,{id:w,x:f[0]%v[0],y:f[1]%v[1],width:v[0],height:v[1],patternUnits:`userSpaceOnUse`,patternTransform:`translate(-${C[0]},-${C[1]})`,children:h?(0,T.jsx)(wA,{radius:b/2,className:u}):(0,T.jsx)(CA,{dimensions:S,lineWidth:i,variant:t,className:u})}),(0,T.jsx)(`rect`,{x:`0`,y:`0`,width:`100%`,height:`100%`,fill:`url(#${w})`})]})}OA.displayName=`Background`;var kA=(0,y.memo)(OA);function AA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 32`,children:(0,T.jsx)(`path`,{d:`M32 18.133H18.133V32h-4.266V18.133H0v-4.266h13.867V0h4.266v13.867H32z`})})}function jA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 5`,children:(0,T.jsx)(`path`,{d:`M0 0h32v4.2H0z`})})}function MA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 32 30`,children:(0,T.jsx)(`path`,{d:`M3.692 4.63c0-.53.4-.938.939-.938h5.215V0H4.708C2.13 0 0 2.054 0 4.63v5.216h3.692V4.631zM27.354 0h-5.2v3.692h5.17c.53 0 .984.4.984.939v5.215H32V4.631A4.624 4.624 0 0027.354 0zm.954 24.83c0 .532-.4.94-.939.94h-5.215v3.768h5.215c2.577 0 4.631-2.13 4.631-4.707v-5.139h-3.692v5.139zm-23.677.94c-.531 0-.939-.4-.939-.94v-5.138H0v5.139c0 2.577 2.13 4.707 4.708 4.707h5.138V25.77H4.631z`})})}function NA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 25 32`,children:(0,T.jsx)(`path`,{d:`M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0 8 0 4.571 3.429 4.571 7.619v3.048H3.048A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047zm4.724-13.866H7.467V7.619c0-2.59 2.133-4.724 4.723-4.724 2.591 0 4.724 2.133 4.724 4.724v3.048z`})})}function PA(){return(0,T.jsx)(`svg`,{xmlns:`http://www.w3.org/2000/svg`,viewBox:`0 0 25 32`,children:(0,T.jsx)(`path`,{d:`M21.333 10.667H19.81V7.619C19.81 3.429 16.38 0 12.19 0c-4.114 1.828-1.37 2.133.305 2.438 1.676.305 4.42 2.59 4.42 5.181v3.048H3.047A3.056 3.056 0 000 13.714v15.238A3.056 3.056 0 003.048 32h18.285a3.056 3.056 0 003.048-3.048V13.714a3.056 3.056 0 00-3.048-3.047zM12.19 24.533a3.056 3.056 0 01-3.047-3.047 3.056 3.056 0 013.047-3.048 3.056 3.056 0 013.048 3.048 3.056 3.056 0 01-3.048 3.047z`})})}function FA({children:e,className:t,...n}){return(0,T.jsx)(`button`,{type:`button`,className:Vv([`react-flow__controls-button`,t]),...n,children:e})}var IA=e=>({isInteractive:e.nodesDraggable||e.nodesConnectable||e.elementsSelectable,minZoomReached:e.transform[2]<=e.minZoom,maxZoomReached:e.transform[2]>=e.maxZoom,ariaLabelConfig:e.ariaLabelConfig});function LA({style:e,showZoom:t=!0,showFitView:n=!0,showInteractive:r=!0,fitViewOptions:i,onZoomIn:a,onZoomOut:o,onFitView:s,onInteractiveChange:c,className:l,children:u,position:d=`bottom-left`,orientation:f=`vertical`,"aria-label":p}){let m=TD(),{isInteractive:h,minZoomReached:g,maxZoomReached:_,ariaLabelConfig:v}=wD(IA,bD),{zoomIn:y,zoomOut:b,fitView:x}=bO();return(0,T.jsxs)(FD,{className:Vv([`react-flow__controls`,f===`horizontal`?`horizontal`:`vertical`,l]),position:d,style:e,"data-testid":`rf__controls`,"aria-label":p??v[`controls.ariaLabel`],children:[t&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(FA,{onClick:()=>{y(),a?.()},className:`react-flow__controls-zoomin`,title:v[`controls.zoomIn.ariaLabel`],"aria-label":v[`controls.zoomIn.ariaLabel`],disabled:_,children:(0,T.jsx)(AA,{})}),(0,T.jsx)(FA,{onClick:()=>{b(),o?.()},className:`react-flow__controls-zoomout`,title:v[`controls.zoomOut.ariaLabel`],"aria-label":v[`controls.zoomOut.ariaLabel`],disabled:g,children:(0,T.jsx)(jA,{})})]}),n&&(0,T.jsx)(FA,{className:`react-flow__controls-fitview`,onClick:()=>{x(i),s?.()},title:v[`controls.fitView.ariaLabel`],"aria-label":v[`controls.fitView.ariaLabel`],children:(0,T.jsx)(MA,{})}),r&&(0,T.jsx)(FA,{className:`react-flow__controls-interactive`,onClick:()=>{m.setState({nodesDraggable:!h,nodesConnectable:!h,elementsSelectable:!h}),c?.(!h)},title:v[`controls.interactive.ariaLabel`],"aria-label":v[`controls.interactive.ariaLabel`],children:h?(0,T.jsx)(PA,{}):(0,T.jsx)(NA,{})}),u]})}LA.displayName=`Controls`;var RA=(0,y.memo)(LA);function zA({id:e,x:t,y:n,width:r,height:i,style:a,color:o,strokeColor:s,strokeWidth:c,className:l,borderRadius:u,shapeRendering:d,selected:f,onClick:p}){let{background:m,backgroundColor:h}=a||{},g=o||m||h;return(0,T.jsx)(`rect`,{className:Vv([`react-flow__minimap-node`,{selected:f},l]),x:t,y:n,rx:u,ry:u,width:r,height:i,style:{fill:g,stroke:s,strokeWidth:c},shapeRendering:d,onClick:p?t=>p(t,e):void 0})}var BA=(0,y.memo)(zA),VA=e=>e.nodes.map(e=>e.id),HA=e=>e instanceof Function?e:()=>e;function UA({nodeStrokeColor:e,nodeColor:t,nodeClassName:n=``,nodeBorderRadius:r=5,nodeStrokeWidth:i,nodeComponent:a=BA,onClick:o}){let s=wD(VA,bD),c=HA(t),l=HA(e),u=HA(n),d=typeof window>`u`||window.chrome?`crispEdges`:`geometricPrecision`;return(0,T.jsx)(T.Fragment,{children:s.map(e=>(0,T.jsx)(GA,{id:e,nodeColorFunc:c,nodeStrokeColorFunc:l,nodeClassNameFunc:u,nodeBorderRadius:r,nodeStrokeWidth:i,NodeComponent:a,onClick:o,shapeRendering:d},e))})}function WA({id:e,nodeColorFunc:t,nodeStrokeColorFunc:n,nodeClassNameFunc:r,nodeBorderRadius:i,nodeStrokeWidth:a,shapeRendering:o,NodeComponent:s,onClick:c}){let{node:l,x:u,y:d,width:f,height:p}=wD(t=>{let{internals:n}=t.nodeLookup.get(e),r=n.userNode,{x:i,y:a}=n.positionAbsolute,{width:o,height:s}=yT(r);return{node:r,x:i,y:a,width:o,height:s}},bD);return!l||l.hidden||!bT(l)?null:(0,T.jsx)(s,{x:u,y:d,width:f,height:p,style:l.style,selected:!!l.selected,className:r(l),color:t(l),borderRadius:i,strokeColor:n(l),strokeWidth:a,shapeRendering:o,onClick:c,id:l.id})}var GA=(0,y.memo)(WA),KA=(0,y.memo)(UA),qA=200,JA=150,YA=e=>!e.hidden,XA=e=>{let t={x:-e.transform[0]/e.transform[2],y:-e.transform[1]/e.transform[2],width:e.width/e.transform[2],height:e.height/e.transform[2]};return{viewBB:t,boundingRect:e.nodeLookup.size>0?aT(Hw(e.nodeLookup,{filter:YA}),t):t,rfId:e.rfId,panZoom:e.panZoom,translateExtent:e.translateExtent,flowWidth:e.width,flowHeight:e.height,ariaLabelConfig:e.ariaLabelConfig}},ZA=`react-flow__minimap-desc`;function QA({style:e,className:t,nodeStrokeColor:n,nodeColor:r,nodeClassName:i=``,nodeBorderRadius:a=5,nodeStrokeWidth:o,nodeComponent:s,bgColor:c,maskColor:l,maskStrokeColor:u,maskStrokeWidth:d,position:f=`bottom-right`,onClick:p,onNodeClick:m,pannable:h=!1,zoomable:g=!1,ariaLabel:_,inversePan:v,zoomStep:b=1,offsetScale:x=5}){let S=TD(),C=(0,y.useRef)(null),{boundingRect:w,viewBB:E,rfId:D,panZoom:O,translateExtent:k,flowWidth:A,flowHeight:j,ariaLabelConfig:M}=wD(XA,bD),N=e?.width??qA,P=e?.height??JA,ee=w.width/N,F=w.height/P,I=Math.max(ee,F),L=I*N,R=I*P,z=x*I,B=w.x-(L-w.width)/2-z,V=w.y-(R-w.height)/2-z,H=L+z*2,U=R+z*2,te=`${ZA}-${D}`,ne=(0,y.useRef)(0),re=(0,y.useRef)();ne.current=I,(0,y.useEffect)(()=>{if(C.current&&O)return re.current=NE({domNode:C.current,panZoom:O,getTransform:()=>S.getState().transform,getViewScale:()=>ne.current}),()=>{re.current?.destroy()}},[O]),(0,y.useEffect)(()=>{re.current?.update({translateExtent:k,width:A,height:j,inversePan:v,pannable:h,zoomStep:b,zoomable:g})},[h,g,v,b,k,A,j]);let ie=p?e=>{let[t,n]=re.current?.pointer(e)||[0,0];p(e,{x:t,y:n})}:void 0,ae=m?(0,y.useCallback)((e,t)=>{let n=S.getState().nodeLookup.get(t).internals.userNode;m(e,n)},[]):void 0,oe=_??M[`minimap.ariaLabel`];return(0,T.jsx)(FD,{position:f,style:{...e,"--xy-minimap-background-color-props":typeof c==`string`?c:void 0,"--xy-minimap-mask-background-color-props":typeof l==`string`?l:void 0,"--xy-minimap-mask-stroke-color-props":typeof u==`string`?u:void 0,"--xy-minimap-mask-stroke-width-props":typeof d==`number`?d*I:void 0,"--xy-minimap-node-background-color-props":typeof r==`string`?r:void 0,"--xy-minimap-node-stroke-color-props":typeof n==`string`?n:void 0,"--xy-minimap-node-stroke-width-props":typeof o==`number`?o:void 0},className:Vv([`react-flow__minimap`,t]),"data-testid":`rf__minimap`,children:(0,T.jsxs)(`svg`,{width:N,height:P,viewBox:`${B} ${V} ${H} ${U}`,className:`react-flow__minimap-svg`,role:`img`,"aria-labelledby":te,ref:C,onClick:ie,children:[oe&&(0,T.jsx)(`title`,{id:te,children:oe}),(0,T.jsx)(KA,{onClick:ae,nodeColor:r,nodeStrokeColor:n,nodeBorderRadius:a,nodeClassName:i,nodeStrokeWidth:o,nodeComponent:s}),(0,T.jsx)(`path`,{className:`react-flow__minimap-mask`,d:`M${B-z},${V-z}h${H+z*2}v${U+z*2}h${-H-z*2}z + M${E.x},${E.y}h${E.width}v${E.height}h${-E.width}z`,fillRule:`evenodd`,pointerEvents:`none`})]})})}QA.displayName=`MiniMap`;var $A=(0,y.memo)(QA),ej=e=>t=>e?`${Math.max(1/t.transform[2],1)}`:void 0,tj={[YE.Line]:`right`,[YE.Handle]:`bottom-right`};function nj({nodeId:e,position:t,variant:n=YE.Handle,className:r,style:i=void 0,children:a,color:o,minWidth:s=10,minHeight:c=10,maxWidth:l=Number.MAX_VALUE,maxHeight:u=Number.MAX_VALUE,keepAspectRatio:d=!1,resizeDirection:f,autoScale:p=!0,shouldResize:m,onResizeStart:h,onResize:g,onResizeEnd:_}){let v=zO(),b=typeof e==`string`?e:v,x=TD(),S=(0,y.useRef)(null),C=n===YE.Handle,w=wD((0,y.useCallback)(ej(C&&p),[C,p]),bD),E=(0,y.useRef)(null),D=t??tj[n];return(0,y.useEffect)(()=>{if(!(!S.current||!b))return E.current||=sD({domNode:S.current,nodeId:b,getStoreItems:()=>{let{nodeLookup:e,transform:t,snapGrid:n,snapToGrid:r,nodeOrigin:i,domNode:a}=x.getState();return{nodeLookup:e,transform:t,snapGrid:n,snapToGrid:r,nodeOrigin:i,paneDomNode:a}},onChange:(e,t)=>{let{triggerNodeChanges:n,nodeLookup:r,parentLookup:i,nodeOrigin:a}=x.getState(),o=[],s={x:e.x,y:e.y},c=r.get(b);if(c&&c.expandParent&&c.parentId){let t=c.origin??a,n=e.width??c.measured.width??0,l=e.height??c.measured.height??0,u=fE([{id:c.id,parentId:c.parentId,rect:{width:n,height:l,...xT({x:e.x??c.position.x,y:e.y??c.position.y},{width:n,height:l},c.parentId,r,t)}}],r,i,a);o.push(...u),s.x=e.x?Math.max(t[0]*n,e.x):void 0,s.y=e.y?Math.max(t[1]*l,e.y):void 0}if(s.x!==void 0&&s.y!==void 0){let e={id:b,type:`position`,position:{...s}};o.push(e)}if(e.width!==void 0&&e.height!==void 0){let t={id:b,type:`dimensions`,resizing:!0,setAttributes:f?f===`horizontal`?`width`:`height`:!0,dimensions:{width:e.width,height:e.height}};o.push(t)}for(let e of t){let t={...e,type:`position`};o.push(t)}n(o)},onEnd:({width:e,height:t})=>{let n={id:b,type:`dimensions`,resizing:!1,dimensions:{width:e,height:t}};x.getState().triggerNodeChanges([n])}}),E.current.update({controlPosition:D,boundaries:{minWidth:s,minHeight:c,maxWidth:l,maxHeight:u},keepAspectRatio:d,resizeDirection:f,onResizeStart:h,onResize:g,onResizeEnd:_,shouldResize:m}),()=>{E.current?.destroy()}},[D,s,c,l,u,d,h,g,_,m]),(0,T.jsx)(`div`,{className:Vv([`react-flow__resize-control`,`nodrag`,...D.split(`-`),n,r]),ref:S,style:{...i,scale:w,...o&&{[C?`backgroundColor`:`borderColor`]:o}},children:a})}(0,y.memo)(nj);var rj=e=>{switch(e){case`running`:return{borderColor:`border-[#643FB2] dark:border-[#8B5CF6]`,glow:`shadow-lg shadow-[#643FB2]/20`,badgeColor:`bg-[#643FB2] dark:bg-[#8B5CF6]`};case`completed`:return{borderColor:`border-green-500 dark:border-green-400`,glow:`shadow-lg shadow-green-500/20`,badgeColor:`bg-green-500 dark:bg-green-400`};case`failed`:return{borderColor:`border-red-500 dark:border-red-400`,glow:`shadow-lg shadow-red-500/20`,badgeColor:`bg-red-500 dark:bg-red-400`};case`cancelled`:return{borderColor:`border-orange-500 dark:border-orange-400`,glow:`shadow-lg shadow-orange-500/20`,badgeColor:`bg-orange-500 dark:bg-orange-400`};default:return{borderColor:`border-gray-300 dark:border-gray-600`,glow:``,badgeColor:`bg-gray-400 dark:bg-gray-500`}}},ij=(0,y.memo)(({data:e,selected:t})=>{let n=e,r=rj(n.state),[i,a]=(0,y.useState)(!1),o=n.outputData||n.error,s=n.state===`running`,c=s&&(n.isStreaming??!0),l=n.layoutDirection===`TB`,u=l?$.Top:$.Left,d=l?$.Bottom:$.Right;return(0,T.jsxs)(`div`,{className:at(`group relative w-64 bg-card dark:bg-card rounded border-2 transition-all duration-200`,r.borderColor,t?`ring-2 ring-blue-500 ring-offset-2`:``,s?r.glow:`shadow-sm`),children:[(0,T.jsx)(UO,{type:`target`,position:u,id:`target`,className:`!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0`,style:{backgroundColor:n.state===`running`?`#643FB2`:n.state===`completed`?`#10b981`:n.state===`failed`?`#ef4444`:n.state===`cancelled`?`#f97316`:`#4b5563`}}),(0,T.jsx)(UO,{type:`source`,position:d,id:`source`,className:`!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0`,style:{backgroundColor:n.state===`running`?`#643FB2`:n.state===`completed`?`#10b981`:n.state===`failed`?`#ef4444`:n.state===`cancelled`?`#f97316`:`#4b5563`}}),(0,T.jsxs)(`div`,{className:`p-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,T.jsx)(`div`,{className:`flex-shrink-0 relative`,children:(0,T.jsx)(`div`,{className:`w-10 h-10 rounded-lg bg-gray-900/90 dark:bg-gray-800/90 flex items-center justify-center`,children:n.isStartNode?(0,T.jsx)(Kl,{className:`w-5 h-5 text-[#643FB2] dark:text-[#8B5CF6]`}):(0,T.jsx)(Tu,{className:`w-5 h-5 text-gray-300 dark:text-gray-400`})})}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,T.jsx)(`h3`,{className:`font-medium text-sm text-gray-900 dark:text-gray-100 truncate`,children:n.name||n.executorId}),s&&(0,T.jsx)(Ql,{className:`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${c?`animate-spin`:``} flex-shrink-0`})]}),n.executorType&&(0,T.jsx)(`p`,{className:`text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5`,children:n.executorType})]})]}),o&&(0,T.jsxs)(`div`,{className:`mt-2 border-t border-border/50 pt-2`,children:[(0,T.jsxs)(`button`,{onClick:e=>{e.stopPropagation(),a(!i)},className:`flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-full`,children:[i?(0,T.jsx)(X,{className:`w-3 h-3`}):(0,T.jsx)(Z,{className:`w-3 h-3`}),(0,T.jsx)(`span`,{children:n.error?`Show error`:`Show output`})]}),i&&(0,T.jsx)(`div`,{className:`mt-2`,children:(()=>{if(n.error&&typeof n.error==`string`)return(0,T.jsx)(`div`,{className:`text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/20 p-2 rounded border border-red-200 dark:border-red-800 break-words max-h-32 overflow-auto`,children:kv(n.error,200)});if(n.outputData)try{return(0,T.jsx)(`div`,{className:`text-xs text-gray-700 dark:text-gray-300 bg-muted/50 p-2 rounded border max-h-32 overflow-auto`,children:(0,T.jsx)(`pre`,{className:`whitespace-pre-wrap font-mono`,children:typeof n.outputData==`string`?n.outputData:JSON.stringify(n.outputData,null,2)})})}catch{return(0,T.jsx)(`div`,{className:`text-xs text-gray-600 dark:text-gray-400 bg-muted/50 p-2 rounded border`,children:`[Unable to display output]`})}return null})()})]}),s&&(0,T.jsx)(`div`,{className:`absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none`})]})]})});ij.displayName=`ExecutorNode`;var aj=(0,y.memo)(function({id:e,source:t,markerEnd:n,style:r}){let i=SA(t);if(!i)return null;let{width:a,height:o}=i.measured,{x:s,y:c}=i.internals.positionAbsolute;if(!a||!o)return null;let l=i.data?.layoutDirection===`TB`,u;if(l){let e=s+a/2,t=c+o,n=s+a/2,r=c,i=s+a+100;u=`M ${e} ${t} C ${e} ${t+40}, ${i} ${t+40}, ${i} ${c+o/2} C ${i} ${r-40}, ${n} ${r-40}, ${n} ${r}`}else{let e=s+a,t=c+o/2,n=s,r=c+o/2,i=c+o+100;u=`M ${e} ${t} C ${e+40} ${t}, ${e+40} ${i}, ${s+a/2} ${i} C ${n-40} ${i}, ${n-40} ${r}, ${n} ${r}`}return(0,T.jsx)(Sk,{id:e,path:u,markerEnd:n,style:r})}),oj={executor:ij},sj={selfLoop:aj};function cj({workflowDump:e,onNodeSelect:t,viewOptions:n,onToggleViewOption:r,layoutDirection:i,onLayoutDirectionChange:a}){let{fitView:o,setViewport:s,setNodes:c}=bO();return(0,T.jsx)(`div`,{className:`absolute top-4 right-4 z-10`,children:(0,T.jsxs)(ku,{children:[(0,T.jsx)(Au,{asChild:!0,children:(0,T.jsxs)(K,{variant:`outline`,size:`sm`,className:`h-8 w-8 p-0 bg-white/90 backdrop-blur-sm border-gray-200 shadow-sm hover:bg-white dark:bg-gray-800/90 dark:border-gray-600 dark:hover:bg-gray-800`,children:[(0,T.jsx)(Bl,{className:`h-4 w-4`}),(0,T.jsx)(`span`,{className:`sr-only`,children:`View options`})]})}),(0,T.jsxs)(ju,{align:`end`,className:`w-56`,children:[(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>r?.(`showMinimap`),children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(eu,{className:`mr-2 h-4 w-4`}),`Show Minimap`]}),(0,T.jsx)(ip,{checked:n.showMinimap,onChange:()=>{}})]}),(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>r?.(`showGrid`),children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(Gl,{className:`mr-2 h-4 w-4`}),`Show Grid`]}),(0,T.jsx)(ip,{checked:n.showGrid,onChange:()=>{}})]}),(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>r?.(`animateRun`),children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(Ou,{className:`mr-2 h-4 w-4`}),`Animate Run`]}),(0,T.jsx)(ip,{checked:n.animateRun,onChange:()=>{}})]}),(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>r?.(`consolidateBidirectionalEdges`),children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(yl,{className:`mr-2 h-4 w-4`}),`Merge Bidirectional Edges`]}),(0,T.jsx)(ip,{checked:n.consolidateBidirectionalEdges,onChange:()=>{}})]}),(0,T.jsx)(Pu,{}),(0,T.jsxs)(Mu,{className:`flex items-center justify-between`,onClick:()=>{let n=i===`LR`?`TB`:`LR`;a?.(n),e&&c(Pv(Av(e,t,n),jv(e),n))},children:[(0,T.jsxs)(`div`,{className:`flex items-center`,children:[(0,T.jsx)(vl,{className:`mr-2 h-4 w-4`}),`Vertical Layout`]}),(0,T.jsx)(ip,{checked:i===`TB`,onChange:()=>{}})]}),(0,T.jsx)(Pu,{}),(0,T.jsxs)(Mu,{onClick:()=>{s({x:0,y:0,zoom:1})},children:[(0,T.jsx)(fu,{className:`mr-2 h-4 w-4`}),`Reset Zoom`]}),(0,T.jsxs)(Mu,{onClick:()=>{o({padding:.2})},children:[(0,T.jsx)(tu,{className:`mr-2 h-4 w-4`}),`Fit to Screen`]}),(0,T.jsxs)(Mu,{onClick:()=>{e&&c(Pv(Av(e,t,i),jv(e),i))},children:[(0,T.jsx)(vu,{className:`mr-2 h-4 w-4`}),`Auto-arrange`]})]})]})})}function lj({nodes:e,nodeUpdates:t,isStreaming:n,animateRun:r}){let{fitView:i}=bO();return(0,y.useEffect)(()=>{if(r)if(n){let t=e.filter(e=>e.data.state===`running`);if(t.length>0){let e=t[0];i({nodes:[e],duration:800,padding:.3,minZoom:.8,maxZoom:1.5})}}else e.length>0&&i({duration:1e3,padding:.2})},[t,n,r,e]),null}var uj=(0,y.memo)(({timelineVisible:e})=>{let{fitView:t}=bO();return(0,y.useEffect)(()=>{let e=setTimeout(()=>{t({padding:.2,duration:300})},350);return()=>clearTimeout(e)},[e]),null}),dj=(0,y.memo)(function({workflowDump:e,events:t,isStreaming:n,onNodeSelect:r,className:i=``,viewOptions:a={showMinimap:!1,showGrid:!0,animateRun:!0,consolidateBidirectionalEdges:!0},onToggleViewOption:o,layoutDirection:s=`TB`,onLayoutDirectionChange:c,timelineVisible:l=!1}){let{initialNodes:u,initialEdges:d}=(0,y.useMemo)(()=>{if(!e)return{initialNodes:[],initialEdges:[]};let t=Av(e,r,s),n=jv(e),i=a.consolidateBidirectionalEdges?zv(n):n;return{initialNodes:t.length>0?Pv(t,i,s):t,initialEdges:i}},[e,r,s,a.consolidateBidirectionalEdges]),[f,p,m]=bA(u),[h,g,_]=xA(d),v=(0,y.useMemo)(()=>Fv(t,e?.start_executor_id),[t,e?.start_executor_id]);(0,y.useMemo)(()=>{Object.keys(v).length>0?p(e=>Iv(e,v,n)):t.length===0&&p(e=>e.map(e=>({...e,data:{...e.data,state:`pending`,outputData:void 0,error:void 0,isStreaming:!1}})))},[v,p,t.length,n]),(0,y.useMemo)(()=>{t.length>0?g(e=>{let n=Rv(e,t);return a.consolidateBidirectionalEdges?zv(n):n}):g(e=>{let t=e.map(e=>({...e,animated:!1,style:{stroke:`#6b7280`,strokeWidth:2}}));return a.consolidateBidirectionalEdges?zv(t):t})},[t,g,a.consolidateBidirectionalEdges]),(0,y.useEffect)(()=>{u.length>0&&(p(u),g(d))},[e,a.consolidateBidirectionalEdges]);let b=(0,y.useCallback)((e,t)=>{e.stopPropagation(),r?.(t.data.executorId,t.data)},[r]);return e?u.length===0?(0,T.jsx)(`div`,{className:`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${i}`,children:(0,T.jsxs)(`div`,{className:`text-center text-gray-500 dark:text-gray-400`,children:[(0,T.jsx)(`div`,{className:`text-lg font-medium mb-2`,children:`No Executors Found`}),(0,T.jsx)(`div`,{className:`text-sm`,children:`Could not extract executors from workflow dump.`}),(0,T.jsxs)(`details`,{className:`mt-2 text-xs`,children:[(0,T.jsx)(`summary`,{className:`cursor-pointer`,children:`Debug Info`}),(0,T.jsx)(`pre`,{className:`mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left overflow-auto`,children:JSON.stringify(e,null,2)})]})]})}):(0,T.jsxs)(`div`,{className:`h-full w-full ${i}`,children:[(0,T.jsxs)(yA,{nodes:f,edges:h,onNodesChange:m,onEdgesChange:_,onNodeClick:b,nodeTypes:oj,edgeTypes:sj,fitView:!0,fitViewOptions:{padding:.2},minZoom:.1,maxZoom:1.5,defaultEdgeOptions:{type:`default`,animated:!1,style:{stroke:`#6b7280`,strokeWidth:2}},nodesDraggable:!n,nodesConnectable:!1,elementsSelectable:!0,proOptions:{hideAttribution:!0},children:[a.showGrid&&(0,T.jsx)(kA,{variant:TA.Dots,gap:20,size:1,color:`#e5e7eb`,className:`dark:opacity-30`}),(0,T.jsx)(RA,{position:`bottom-left`,showInteractive:!1,style:{backgroundColor:`rgba(255, 255, 255, 0.9)`,border:`1px solid #e5e7eb`,borderRadius:`3px`},className:`dark:!bg-gray-800/90 dark:!border-gray-600`}),a.showMinimap&&(0,T.jsx)($A,{nodeColor:e=>{switch(e.data?.state){case`running`:return`#643FB2`;case`completed`:return`#10b981`;case`failed`:return`#ef4444`;case`cancelled`:return`#f97316`;default:return`#6b7280`}},maskColor:`rgba(0, 0, 0, 0.1)`,position:`bottom-right`,style:{backgroundColor:`rgba(255, 255, 255, 0.9)`,border:`1px solid #e5e7eb`,borderRadius:`8px`},className:`dark:!bg-gray-800/90 dark:!border-gray-600`}),(0,T.jsx)(lj,{nodes:f,nodeUpdates:v,isStreaming:n,animateRun:a.animateRun}),(0,T.jsx)(uj,{timelineVisible:l}),(0,T.jsx)(cj,{workflowDump:e,onNodeSelect:r,viewOptions:a,onToggleViewOption:o,layoutDirection:s,onLayoutDirectionChange:c})]}),(0,T.jsx)(`style`,{children:` .react-flow__edge-path { transition: stroke 0.3s ease, stroke-width 0.3s ease; } @@ -182,12 +182,12 @@ asyncio.run(main())`})]})]}),(0,T.jsxs)(`div`,{className:`flex gap-2 pt-4 border .dark .react-flow__controls-button:hover svg { fill: rgb(255, 255, 255) !important; } - `})]}):(0,T.jsx)(`div`,{className:`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${i}`,children:(0,T.jsxs)(`div`,{className:`text-center text-gray-500 dark:text-gray-400`,children:[(0,T.jsx)(`div`,{className:`text-lg font-medium mb-2`,children:`No Workflow Data`}),(0,T.jsx)(`div`,{className:`text-sm`,children:`Workflow dump is not available.`})]})})});function cj({title:e,icon:t,children:n,className:r=``}){return(0,T.jsxs)(`div`,{className:`border rounded-lg p-4 bg-card ${r}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-3`,children:[t,(0,T.jsx)(`h3`,{className:`text-sm font-semibold text-foreground`,children:e})]}),(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:n})]})}function lj({workflow:e,open:t,onOpenChange:n}){let r=e.source===`directory`?(0,T.jsx)(Ul,{className:`h-4 w-4 text-muted-foreground`}):e.source===`in_memory`?(0,T.jsx)(Rl,{className:`h-4 w-4 text-muted-foreground`}):(0,T.jsx)(Wl,{className:`h-4 w-4 text-muted-foreground`}),i=e.source===`directory`?`Local`:e.source===`in_memory`?`In-Memory`:`Gallery`;return(0,T.jsx)(Im,{open:t,onOpenChange:n,children:(0,T.jsxs)(Lm,{className:`max-w-4xl max-h-[90vh] flex flex-col`,children:[(0,T.jsxs)(Rm,{className:`px-6 pt-6 flex-shrink-0`,children:[(0,T.jsx)(zm,{children:`Workflow Details`}),(0,T.jsx)(Vm,{onClose:()=>n(!1)})]}),(0,T.jsxs)(`div`,{className:`px-6 pb-6 overflow-y-auto flex-1`,children:[(0,T.jsxs)(`div`,{className:`mb-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-3 mb-2`,children:[(0,T.jsx)(Tu,{className:`h-6 w-6 text-primary`}),(0,T.jsx)(`h2`,{className:`text-xl font-semibold text-foreground`,children:e.name||e.id})]}),e.description&&(0,T.jsx)(`p`,{className:`text-muted-foreground`,children:e.description})]}),(0,T.jsx)(`div`,{className:`h-px bg-border mb-6`}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-4 mb-4`,children:[(0,T.jsx)(cj,{title:`Start Executor`,icon:(0,T.jsx)(jl,{className:`h-4 w-4 text-muted-foreground`}),children:(0,T.jsx)(`div`,{className:`font-mono text-foreground`,children:e.start_executor_id})}),(0,T.jsx)(cj,{title:`Source`,icon:r,children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-foreground`,children:i}),e.module_path&&(0,T.jsx)(`div`,{className:`font-mono text-xs break-all`,children:e.module_path})]})}),(0,T.jsx)(cj,{title:`Environment`,icon:e.has_env?(0,T.jsx)(Ml,{className:`h-4 w-4 text-orange-500`}):(0,T.jsx)(kl,{className:`h-4 w-4 text-green-500`}),className:`md:col-span-2`,children:(0,T.jsx)(`div`,{className:e.has_env?`text-orange-600 dark:text-orange-400`:`text-green-600 dark:text-green-400`,children:e.has_env?`Requires environment variables`:`No environment variables required`})})]}),(0,T.jsx)(cj,{title:`Executors (${e.executors.length})`,icon:(0,T.jsx)(au,{className:`h-4 w-4 text-muted-foreground`}),children:e.executors.length>0?(0,T.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2`,children:e.executors.map((e,t)=>(0,T.jsx)(`div`,{className:`font-mono text-xs text-foreground bg-muted px-2 py-1 rounded truncate`,title:e,children:e},t))}):(0,T.jsx)(`div`,{className:`text-muted-foreground`,children:`No executors configured`})})]})]})})}function uj({session:e,checkpoints:t,open:n,onOpenChange:r}){let[i,a]=(0,y.useState)(null),[o,s]=(0,y.useState)(null),[c,l]=(0,y.useState)(!1),[u,d]=(0,y.useState)(!0);if((0,y.useEffect)(()=>{n&&t.length>0&&(t.some(e=>e.checkpoint_id===i)||a(t[0].checkpoint_id))},[n,t]),(0,y.useEffect)(()=>{!i||!e||(async()=>{l(!0);try{s((await Lh.getConversationItem(e.conversation_id,`checkpoint_${i}`)).metadata?.full_checkpoint??null)}catch(e){console.error(`Failed to load checkpoint:`,e),s(null)}finally{l(!1)}})()},[i,e]),!e)return null;let f=t.find(e=>e.checkpoint_id===i),p=o?.state?._executor_state?Object.keys(o.state._executor_state):[],m=o?.messages?Object.keys(o.messages):[],h=e=>{if(!e)return``;let t=e/1024;return t<1?`${e} B`:t<1024?`${t.toFixed(1)} KB`:`${(t/1024).toFixed(1)} MB`};return(0,T.jsx)(Im,{open:n,onOpenChange:r,children:(0,T.jsxs)(Lm,{className:`w-[90vw] max-w-6xl min-w-[800px] h-[85vh] flex flex-col p-0`,children:[(0,T.jsx)(Rm,{className:`px-6 pt-6 pb-4 border-b flex-shrink-0`,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`flex-1`,children:[(0,T.jsx)(zm,{children:e.metadata.name}),(0,T.jsxs)(`div`,{className:`text-sm text-muted-foreground mt-1`,children:[t.length,` checkpoint`,t.length===1?``:`s`]}),(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground mt-2 max-w-2xl`,children:`This is a read only view of the current checkpoint ids in the checkpoint storage for this workflow run.`})]}),(0,T.jsx)(Vm,{onClose:()=>r(!1)})]})}),(0,T.jsxs)(`div`,{className:`flex-1 flex overflow-hidden min-h-0`,children:[(0,T.jsx)(`div`,{className:`w-80 border-r flex flex-col`,children:(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsx)(`div`,{className:`p-4 space-y-2`,children:t.length===0?(0,T.jsx)(`div`,{className:`text-center text-sm text-muted-foreground py-8`,children:`No checkpoints yet`}):t.map((e,n)=>{let r=e.checkpoint_id===i,o=e.metadata.has_pending_hil;return(0,T.jsxs)(`div`,{className:`relative`,children:[(0,T.jsx)(`button`,{onClick:()=>a(e.checkpoint_id),className:at(`relative w-full text-left p-3 rounded-lg border transition-colors`,r?`bg-primary/10 border-primary`:`hover:bg-muted/50 border-transparent`),children:(0,T.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,T.jsx)(`div`,{className:`flex flex-col items-center pt-1`,children:(0,T.jsx)(`div`,{className:at(`w-2 h-2 rounded-full z-10`,o?`bg-blue-500 ring-2 ring-blue-500/20`:`bg-muted-foreground/30`)})}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`span`,{className:`text-sm font-medium`,children:e.metadata.iteration_count===0?`Initial State`:`Step ${e.metadata.iteration_count}`}),(0,T.jsx)(`span`,{className:`text-[10px] font-mono text-muted-foreground/70`,title:e.checkpoint_id,children:e.checkpoint_id.slice(0,8)}),n===0&&(0,T.jsx)(ct,{variant:`secondary`,className:`text-[10px] h-4 px-1`,children:`Latest`}),o&&(0,T.jsxs)(ct,{variant:`secondary`,className:`text-[10px] h-4 px-1.5`,children:[e.metadata.pending_hil_count,` HIL`]})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-3 text-xs text-muted-foreground mt-1`,children:[(0,T.jsx)(`span`,{children:new Date(e.timestamp).toLocaleTimeString()}),e.metadata.size_bytes&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{children:`•`}),(0,T.jsx)(`span`,{children:h(e.metadata.size_bytes)})]})]})]})]})}),n0&&(0,T.jsxs)(`div`,{children:[(0,T.jsxs)(`div`,{className:`text-sm font-medium mb-3 flex items-center gap-2`,children:[(0,T.jsx)(au,{className:`h-4 w-4`}),`Active Executors (`,p.length,`)`]}),(0,T.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:p.map(e=>(0,T.jsx)(ct,{variant:`outline`,className:`font-mono text-xs`,children:e},e))})]}),m.length>0&&o&&(0,T.jsxs)(`div`,{children:[(0,T.jsxs)(`div`,{className:`text-sm font-medium mb-3 flex items-center gap-2`,children:[(0,T.jsx)(nu,{className:`h-4 w-4`}),`Messages`]}),(0,T.jsx)(`div`,{className:`grid grid-cols-2 gap-3`,children:m.map(e=>{let t=o.messages[e]?.length;return(0,T.jsxs)(`div`,{className:`bg-muted/50 p-3 rounded-lg`,children:[(0,T.jsx)(`div`,{className:`text-xs font-mono text-muted-foreground mb-1`,children:e}),(0,T.jsxs)(`div`,{className:`font-medium`,children:[t,` message`,t===1?``:`s`]})]},e)})})]}),o?.pending_request_info_events&&Object.keys(o.pending_request_info_events).length>0&&(0,T.jsxs)(`div`,{children:[(0,T.jsxs)(`div`,{className:`text-sm font-medium mb-3 flex items-center gap-2`,children:[(0,T.jsx)(Ol,{className:`h-4 w-4`}),`Pending HIL Requests (`,Object.keys(o.pending_request_info_events).length,`)`]}),(0,T.jsx)(`div`,{className:`space-y-2`,children:Object.entries(o.pending_request_info_events).map(([e,t])=>(0,T.jsxs)(`div`,{className:`bg-muted/50 border border-border p-3 rounded-lg`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between mb-2`,children:[(0,T.jsxs)(`code`,{className:`text-xs bg-background px-2 py-1 rounded`,children:[e.slice(0,24),`...`]}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs`,children:t.source_executor_id})]}),(0,T.jsxs)(`div`,{className:`text-xs space-y-1`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Request:`}),` `,(0,T.jsx)(`code`,{className:`bg-background px-1 py-0.5 rounded`,children:t.request_type?.split(`.`).pop()||t.request_type})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Response:`}),` `,(0,T.jsx)(`code`,{className:`bg-background px-1 py-0.5 rounded`,children:t.response_type?.split(`.`).pop()||t.response_type})]})]})]},e))})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`div`,{className:`text-sm font-medium mb-3`,children:`Workflow State`}),o?.state&&Object.keys(o.state).filter(e=>e!==`_executor_state`).length>0?(0,T.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:Object.keys(o.state).filter(e=>e!==`_executor_state`).map(e=>(0,T.jsx)(ct,{variant:`secondary`,className:`font-mono text-xs`,children:e},e))}):(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No custom state`})]}),(0,T.jsxs)(`div`,{className:`border-t pt-6`,children:[(0,T.jsxs)(`button`,{onClick:()=>d(!u),className:`flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full`,children:[u?(0,T.jsx)(X,{className:`h-4 w-4`}):(0,T.jsx)(Z,{className:`h-4 w-4`}),`Raw JSON`]}),u&&(0,T.jsx)(`pre`,{className:`mt-3 text-[10px] font-mono bg-muted p-4 rounded overflow-x-auto`,children:JSON.stringify(o,null,2)})]})]})})})]})]})})}function dj({request:e,response:t,onResponseChange:n,onSubmit:r,isSubmitting:i}){let[a,o]=(0,y.useState)(!0),s=e=>{n(e)},c=vv(e.request_schema,t);return(0,T.jsx)(`div`,{className:`relative group`,children:(0,T.jsx)(`div`,{children:(0,T.jsx)(`div`,{className:`flex-1`,children:(0,T.jsxs)(`div`,{className:`border border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden rounded-lg`,children:[(0,T.jsxs)(`div`,{className:`px-4 py-3 bg-orange-100/50 dark:bg-orange-950/30 border-b border-orange-200 dark:border-orange-800 flex items-center justify-between cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-950/40 transition-colors`,onClick:()=>o(!a),children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-sm text-orange-900 dark:text-orange-100`,children:`Workflow needs your input`}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs font-mono border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300`,children:e.request_id.slice(0,8)}),!a&&(0,T.jsx)(`span`,{className:`text-xs text-orange-600 dark:text-orange-400 animate-pulse`,children:`Click to respond`})]}),i&&(0,T.jsx)(ct,{variant:`secondary`,className:`animate-pulse`,children:`Submitting...`})]}),a&&(0,T.jsxs)(`div`,{className:`p-4 space-y-4`,children:[Object.keys(e.request_data).length>0&&(0,T.jsxs)(`div`,{className:`bg-white/60 dark:bg-gray-900/30 rounded-md p-3 space-y-2`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wider`,children:`Context`}),(0,T.jsx)(`div`,{className:`max-h-48 overflow-y-auto space-y-1 pr-2`,children:Object.entries(e.request_data).filter(([e])=>![`request_id`,`source_executor_id`].includes(e)).map(([e,t])=>(0,T.jsxs)(`div`,{className:`text-sm`,children:[(0,T.jsxs)(`span`,{className:`font-medium text-muted-foreground`,children:[e,`:`]}),` `,(0,T.jsx)(`span`,{className:`text-foreground break-all`,children:typeof t==`object`?JSON.stringify(t,null,2):String(t)})]},e))})]}),e.request_schema?.description&&(0,T.jsxs)(`div`,{className:`text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800`,children:[(0,T.jsx)(`p`,{className:`font-medium text-blue-900 dark:text-blue-100 mb-1`,children:`What's needed:`}),(0,T.jsx)(`p`,{className:`text-blue-800 dark:text-blue-200`,children:e.request_schema.description})]}),(0,T.jsx)(`div`,{className:`space-y-3`,children:(0,T.jsx)(_v,{schema:e.request_schema,values:t,onChange:s})}),(0,T.jsxs)(`div`,{className:`space-y-2 pt-2`,children:[(0,T.jsxs)(K,{size:`default`,onClick:r,disabled:!c||i,className:`w-full gap-2`,children:[(0,T.jsx)(hu,{className:`w-4 h-4`}),`Submit Response`]}),!c&&(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground text-center`,children:`Please fill in all required fields`})]})]})]})})})})}function fj(e,t=!0){switch(e){case`running`:return(0,T.jsx)(Ql,{className:`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${t?`animate-spin`:``}`});case`completed`:return(0,T.jsx)(kl,{className:`w-4 h-4 text-green-500 dark:text-green-400`});case`failed`:return(0,T.jsx)(Ml,{className:`w-4 h-4 text-red-500 dark:text-red-400`});case`cancelled`:return(0,T.jsx)(Ol,{className:`w-4 h-4 text-orange-500 dark:text-orange-400`});default:return(0,T.jsx)(`div`,{className:`w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500`})}}function pj(e){switch(e){case`running`:return`bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20`;case`completed`:return`bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20`;case`failed`:return`bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20`;case`cancelled`:return`bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20`;default:return`bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20`}}function mj(e){return e.content?.map(e=>e.type===`output_text`?e.text:e.type===`refusal`?e.refusal:void 0).filter(e=>!!e).join(` -`)??``}function hj({run:e,isExpanded:t,onToggle:n,onClick:r,isSelected:i,isStreaming:a}){let o=new Date(e.timestamp).toLocaleTimeString(),s=e.output.trim().length>0||e.error,c=(0,y.useRef)(null);return(0,y.useEffect)(()=>{t&&e.state===`running`&&c.current&&(c.current.scrollTop=c.current.scrollHeight)},[e.output,t,e.state]),(0,T.jsxs)(`div`,{className:`border rounded-lg transition-all ${i?`border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10`:`border-border hover:border-muted-foreground/30`}`,children:[(0,T.jsxs)(`div`,{className:`p-3 cursor-pointer`,onClick:()=>{r(),s&&n()},children:[(0,T.jsxs)(`div`,{className:`grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1`,children:[(0,T.jsx)(`div`,{className:`w-3 text-muted-foreground`,children:s&&(0,T.jsx)(T.Fragment,{children:t?(0,T.jsx)(X,{className:`w-3 h-3`}):(0,T.jsx)(Z,{className:`w-3 h-3`})})}),(0,T.jsx)(`div`,{children:fj(e.state,a)}),(0,T.jsx)(`span`,{className:`font-medium text-sm truncate overflow-hidden`,children:e.executorName}),e.runNumber>1?(0,T.jsxs)(ct,{variant:`outline`,className:`text-xs whitespace-nowrap`,children:[`Run #`,e.runNumber]}):(0,T.jsx)(`div`,{})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground ml-5`,children:[(0,T.jsx)(`span`,{className:`font-mono`,children:o}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs border ${pj(e.state)}`,children:e.state})]})]}),t&&s&&(0,T.jsx)(`div`,{className:`border-t px-3 py-2 bg-muted/30`,children:e.error?(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-xs font-medium text-red-600 dark:text-red-400`,children:`Error:`}),(0,T.jsx)(`pre`,{className:`text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all`,children:e.error})]}):(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Output:`}),(0,T.jsx)(`pre`,{ref:c,className:`text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all`,children:e.output})]})})]})}function gj({events:e,itemOutputs:t,currentExecutorId:n,isStreaming:r,onExecutorClick:i,selectedExecutorId:a,workflowResult:o,pendingHilRequests:s=[],hilResponses:c={},onHilResponseChange:l,onHilSubmit:u,isSubmittingHil:d=!1,inputSchema:f,onRun:p,onCancel:m,isCancelling:h=!1,workflowState:g=`ready`,wasCancelled:_=!1,checkpoints:v=[]}){let[b,x]=(0,y.useState)(new Set),[S,C]=(0,y.useState)(0),[w,E]=(0,y.useState)(!1),D=(0,y.useRef)(null),O=(0,y.useRef)(null),k=(0,y.useRef)(null);(0,y.useEffect)(()=>{if(r){let e=setInterval(()=>{C(e=>e+1)},100);return()=>clearInterval(e)}},[r]);let{executorRuns:A,executorRunCount:j}=(0,y.useMemo)(()=>{let n=[],r=new Map;return e.forEach(e=>{let i=`_uiTimestamp`in e&&typeof e._uiTimestamp==`number`?e._uiTimestamp*1e3:Date.now();if(e.type===`response.output_item.added`){let a=e.item;if(a&&a.type===`executor_action`&&`executor_id`in a&&a.id){let e=String(a.executor_id),o=a.id,s=(r.get(e)||0)+1;r.set(e,s),n.push({executorId:e,executorName:Tv(e,35),itemId:o,state:`running`,output:t[o]||``,timestamp:i,runNumber:s})}else if(a&&a.type===`message`&&`metadata`in a&&a.id){let e=a.metadata;if(e?.agent_id&&e?.source===`magentic`){let o=e.agent_id,s=a.id,c=(r.get(o)||0)+1;r.set(o,c),n.push({executorId:o,executorName:Tv(o,35),itemId:s,state:`running`,output:t[s]||``,timestamp:i,runNumber:c})}else if(e?.executor_id&&e.workflow_output_kind===`intermediate`){let o=e.executor_id,s=a.id,c=(r.get(o)||0)+1;r.set(o,c),n.push({executorId:o,executorName:Tv(o,35),itemId:s,state:a.status===`completed`?`completed`:`running`,output:t[s]||mj(a),timestamp:i,runNumber:c})}}}if(e.type===`response.output_item.done`){let r=e.item;if(r&&r.type===`executor_action`&&`executor_id`in r&&r.id){let e=r.id,i=n.find(t=>t.itemId===e);i&&(i.state=r.status===`completed`?`completed`:r.status===`failed`?`failed`:`completed`,i.output=t[e]||``,r.status===`failed`&&`error`in r&&r.error&&(i.error=String(r.error)))}else if(r&&r.type===`message`&&`metadata`in r&&r.id){let e=r.metadata;if(e?.agent_id&&e?.source===`magentic`){let e=r.id,i=n.find(t=>t.itemId===e);i&&(i.state=r.status===`completed`?`completed`:`failed`,i.output=t[e]||``)}else if(e?.executor_id&&e.workflow_output_kind===`intermediate`){let e=r.id,i=n.find(t=>t.itemId===e);i&&(i.state=r.status===`completed`?`completed`:`failed`,i.output=t[e]||mj(r))}}}if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let a=e.data,o=a.executor_id;if(!o)return;let s=a.event_type;if(s===`ExecutorInvokedEvent`){let e=(r.get(o)||0)+1;r.set(o,e);let a=`fallback_${o}_run${e}`;n.push({executorId:o,executorName:Tv(o,35),itemId:a,state:`running`,output:t[a]||``,timestamp:i,runNumber:e})}else if(s===`ExecutorCompletedEvent`){let e;for(let t=n.length-1;t>=0;t--)if(n[t].executorId===o&&n[t].state===`running`){e=n[t];break}e&&(e.state=`completed`,e.output=t[e.itemId]||``)}else if(s?.includes(`Error`)||s?.includes(`Failed`)){let e;for(let t=n.length-1;t>=0;t--)if(n[t].executorId===o&&n[t].state===`running`){e=n[t];break}e&&(e.state=`failed`,e.error=typeof a.data==`string`?a.data:`Execution failed`)}}}),n.forEach(e=>{e.state===`running`&&t[e.itemId]&&(e.output=t[e.itemId])}),{executorRuns:n,executorRunCount:r}},[e,t,S]);return(0,y.useEffect)(()=>{n&&x(e=>{let t=new Set(e);return t.add(`${n}-${j.get(n)||1}`),t})},[n,j]),(0,y.useEffect)(()=>{if(A.length>0&&r){let e=A[A.length-1],t=`${e.executorId}-${e.runNumber}`;t!==D.current&&(D.current=t,O.current&&O.current.scrollIntoView({behavior:`smooth`,block:`end`}))}},[A,r]),(0,y.useEffect)(()=>{o&&!r&&O.current&&setTimeout(()=>{O.current?.scrollIntoView({behavior:`smooth`,block:`end`})},100)},[o,r]),(0,y.useEffect)(()=>{!r&&A.length>0&&s.length===0&&setTimeout(()=>{O.current?.scrollIntoView({behavior:`smooth`,block:`end`})},100)},[r,s.length,A.length]),(0,y.useEffect)(()=>{s.length>0&&k.current&&setTimeout(()=>{k.current?.scrollIntoView({behavior:`smooth`,block:`end`}),k.current?.classList.add(`highlight-attention`),setTimeout(()=>{k.current?.classList.remove(`highlight-attention`)},1e3)},100)},[s.length]),(0,T.jsxs)(`div`,{className:`h-full flex flex-col border-l bg-muted/30`,children:[(0,T.jsxs)(`div`,{className:`p-3 border-b bg-background flex items-center justify-between flex-shrink-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`Execution Timeline`}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs`,children:A.length}),r&&(0,T.jsxs)(`div`,{className:`flex items-center gap-1 text-xs text-muted-foreground`,children:[(0,T.jsx)(`div`,{className:`h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]`}),(0,T.jsx)(`span`,{children:`Running`})]})]}),A.length>0&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>{let e=A.map(e=>`${`[${new Date(e.timestamp).toLocaleTimeString()}] ${e.executorName} (${e.state})`}\n${e.error||e.output||`(no output)`}\n`).join(` -`);navigator.clipboard.writeText(e),E(!0),setTimeout(()=>E(!1),2e3)},className:`h-7 px-2 text-xs ${w?`text-green-600 dark:text-green-400`:``}`,children:w?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Tl,{className:`w-3 h-3 mr-1`}),`Copied!`]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Ll,{className:`w-3 h-3 mr-1`}),`Copy All`]})})]}),(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsxs)(`div`,{className:`p-3 space-y-2`,children:[A.length===0?(0,T.jsx)(`div`,{className:`text-center text-muted-foreground text-sm py-8`,children:r?`Workflow is running...`:`Ready to run workflow`}):(0,T.jsxs)(T.Fragment,{children:[A.map((e,t)=>{let n=`${e.executorId}-${e.runNumber}`;return(0,T.jsx)(hj,{run:e,isExpanded:b.has(n),onToggle:()=>{x(e=>{let t=new Set(e);return t.has(n)?t.delete(n):t.add(n),t})},onClick:()=>i?.(e.executorId),isSelected:a===e.executorId,isStreaming:r},`${n}-${t}`)}),s.length>0&&(0,T.jsx)(`div`,{ref:k,"data-hil-form":!0,className:`transition-all duration-300`,children:s.map(e=>(0,T.jsx)(dj,{request:e,response:c[e.request_id]||{},onResponseChange:t=>l?.(e.request_id,t),onSubmit:()=>u?.(),isSubmitting:d},e.request_id))})]}),o&&o.trim().length>0&&!r&&!_&&(0,T.jsxs)(`div`,{className:`border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10`,children:[(0,T.jsx)(`div`,{className:`p-3 bg-green-500/10 border-b border-green-500/20`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-1`,children:[(0,T.jsx)(kl,{className:`w-4 h-4 text-green-500 dark:text-green-400`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`Workflow Complete`})]})}),(0,T.jsx)(`div`,{className:`border-t px-3 py-2 bg-muted/30`,children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Final Output:`}),(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all`,children:o})]})})]}),_&&!r&&(0,T.jsx)(`div`,{className:`border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10`,children:(0,T.jsxs)(`div`,{className:`px-4 py-3 flex items-center gap-2`,children:[(0,T.jsx)(yu,{className:`w-4 h-4 text-orange-500 dark:text-orange-400 fill-current`}),(0,T.jsx)(`span`,{className:`font-medium text-sm text-orange-700 dark:text-orange-300`,children:`Execution stopped by user`})]})}),(0,T.jsx)(`div`,{ref:O})]})}),(p||m)&&s.length===0&&(0,T.jsx)(`div`,{className:`border-t p-3 bg-background flex-shrink-0`,children:f&&wv(f)?(0,T.jsx)(cg,{onSubmit:async e=>{p?.([{type:`message`,role:`user`,content:e}])},isSubmitting:g===`running`,isStreaming:g===`running`,onCancel:m,isCancelling:h,placeholder:`Message workflow...`,showFileUpload:!0,entityName:`workflow`}):(0,T.jsx)(Iv,{inputSchema:f,onRun:p||(()=>{}),onCancel:m,isSubmitting:g===`running`,isCancelling:h,workflowState:g,checkpoints:v,showCheckpoints:!1})})]})}Td();function _j(e){if(`created_at`in e&&typeof e.created_at==`number`&&e.created_at)return e.created_at;let t=`response`in e?e.response:void 0;if(t&&typeof t==`object`&&`created_at`in t){let e=t.created_at;if(typeof e==`number`)return e}let n=`data`in e?e.data:void 0;if(n&&typeof n==`object`&&`timestamp`in n&&typeof n.timestamp==`string`){let e=new Date(n.timestamp).getTime();return Number.isFinite(e)?e/1e3:void 0}}function vj({selectedWorkflow:e,onDebugEvent:t}){let[n,r]=(0,y.useState)(null),[i,a]=(0,y.useState)(!1),[o,s]=(0,y.useState)(null),[c,l]=(0,y.useState)([]),[u,d]=(0,y.useState)(!1),[f,p]=(0,y.useState)(!1),[m,h]=(0,y.useState)(null),[g,_]=(0,y.useState)(!1),[v,b]=(0,y.useState)(!1),[x,S]=(0,y.useState)(!1),[C,w]=(0,y.useState)(!1),[E,D]=(0,y.useState)(``),[O,k]=(0,y.useState)([]),{isCancelling:A,createAbortSignal:j,handleCancel:M,resetCancelling:N}=wh(),[P,ee]=(0,y.useState)([]),[F,I]=(0,y.useState)({}),L=(0,y.useRef)({}),R=(0,y.useRef)(null),z=(0,y.useRef)(null),B=Q(e=>e.currentSession),V=Q(e=>e.availableSessions),H=Q(e=>e.loadingSessions),U=Q(e=>e.setCurrentSession),te=Q(e=>e.setAvailableSessions),ne=Q(e=>e.setLoadingSessions),re=Q(e=>e.addSession),ie=Q(e=>e.removeSession),ae=Q(e=>e.addToast),oe=Q(e=>e.runtime),se=Q(e=>e.streamingEnabled),[ce,le]=(0,y.useState)(()=>{let e=localStorage.getItem(`workflowViewOptions`),t={showMinimap:!1,showGrid:!0,animateRun:!1,consolidateBidirectionalEdges:!0};if(e){let n=JSON.parse(e);return{...t,...n}}return t}),[ue,de]=(0,y.useState)(()=>localStorage.getItem(`workflowLayoutDirection`)||`TB`);(0,y.useEffect)(()=>{localStorage.setItem(`workflowViewOptions`,JSON.stringify(ce))},[ce]),(0,y.useEffect)(()=>{localStorage.setItem(`workflowLayoutDirection`,ue)},[ue]);let fe=e=>{le(t=>({...t,[e]:!t[e]}))},pe=async()=>{if(x||!e)return;S(!0);let{addToast:t,updateWorkflow:n}=await Ah(()=>Promise.resolve().then(()=>(Dd(),Ed)).then(e=>({addToast:e.useDevUIStore.getState().addToast,updateWorkflow:e.useDevUIStore.getState().updateWorkflow})),void 0,import.meta.url);try{await Lh.reloadEntity(e.id);let i=await Lh.getWorkflowInfo(e.id);n(i),r(i),t({message:`${e.name} has been reloaded successfully`,type:`success`})}catch(e){t({message:`Failed to reload: ${e instanceof Error?e.message:`Failed to reload entity`}`,type:`error`,duration:6e3})}finally{S(!1)}};(0,y.useEffect)(()=>{l([]),d(!1),h(null),D(``),s(null),L.current={},R.current=null,z.current=null,(async()=>{if(e.type===`workflow`){a(!0),s(null);try{r(await Lh.getWorkflowInfo(e.id)),s(null)}catch(e){r(null),s(e instanceof Error?e.message:String(e)),console.error(`Error loading workflow info:`,e)}finally{a(!1)}}})()},[e.id,e.type]);let me=(0,y.useCallback)(async()=>{if(n){ne(!0);try{let e=await Lh.listWorkflowSessions(n.id);if(e.data.length===0){let e=await Lh.createWorkflowSession(n.id,{name:`Checkpoint Storage ${new Date().toLocaleString()}`});te([e]),U(e)}else{let t=[...e.data].sort((e,t)=>t.created_at-e.created_at);if(te(t),!B){let e=t[0];U(e),await ge(e)}}}catch(e){console.error(`Failed to load sessions:`,e),oe!==`dotnet`&&ae({message:`Failed to load sessions`,type:`error`})}finally{ne(!1)}}},[n,B,oe,ae,te,U]);(0,y.useEffect)(()=>{me()},[n?.id,oe]);let he=(0,y.useCallback)(async()=>{if(!B){k([]);return}try{k((await Lh.listConversationItems(B.conversation_id,{limit:100})).data.filter(e=>typeof e==`object`&&!!e&&`type`in e&&e.type===`checkpoint`))}catch(e){console.error(`Failed to load checkpoints for session ${B.conversation_id}:`,e),k([])}},[B]);(0,y.useEffect)(()=>{v&&B&&he()},[v,B,he]);let ge=(0,y.useCallback)(async e=>{!e||!n||(l([]),d(!1),p(!1),h(null),w(!1),D(``),ee([]),I({}),L.current={},R.current=null,z.current=null)},[n]),_e=(0,y.useCallback)(async e=>{let t=V.find(t=>t.conversation_id===e);t&&(U(t),await ge(t))},[V,U,ge]),ve=(0,y.useCallback)(async()=>{if(n)try{let e=await Lh.createWorkflowSession(n.id,{name:`Checkpoint Storage ${new Date().toLocaleString()}`});console.log(`[WorkflowView] Created new session:`,e.conversation_id),console.log(`[WorkflowView] Previous session:`,B?.conversation_id),re(e),U(e),await ge(e),await new Promise(e=>setTimeout(e,100)),ae({message:`New checkpoint storage created`,type:`success`})}catch(e){console.error(`Failed to create checkpoint storage:`,e),ae({message:`Failed to create checkpoint storage`,type:`error`})}},[n,B,re,U,ge,ae]),ye=(0,y.useCallback)(async()=>{if(!(!B||!n)&&confirm(`Delete this session? All checkpoints will be lost.`))try{await Lh.deleteWorkflowSession(n.id,B.conversation_id),ie(B.conversation_id),ae({message:`Session deleted`,type:`success`})}catch(e){console.error(`Failed to delete session:`,e),ae({message:`Failed to delete session`,type:`error`})}},[B,n,ie,ae]),be=e=>{h(e)},xe=(0,y.useMemo)(()=>c.filter(e=>e.type===`response.output_item.added`||e.type===`response.output_item.done`||e.type===`response.created`||e.type===`response.in_progress`||e.type===`response.completed`||e.type===`response.failed`||e.type===`response.workflow_event.completed`||e.type===`response.workflow_event.complete`),[c]),Se=(0,y.useMemo)(()=>{let e=[];return xe.forEach(t=>{if(t.type===`response.output_item.added`||t.type===`response.output_item.done`){let n=t.item;n&&n.type===`executor_action`&&`executor_id`in n&&n.executor_id&&e.push({executorId:String(n.executor_id),message:t.type===`response.output_item.added`?`Executor started`:n.status===`completed`?`Executor completed`:n.status===`failed`?`Executor failed`:`Executor processing`,timestamp:new Date().toISOString(),status:n.status===`completed`?`completed`:n.status===`failed`?`error`:`running`})}else if(t.type===`response.workflow_event.complete`&&`data`in t&&t.data&&typeof t.data==`object`){let n=t.data;n.executor_id!=null&&e.push({executorId:String(n.executor_id),message:String(n.event_type||`Processing`),timestamp:String(n.timestamp||new Date().toISOString()),status:String(n.event_type||``).includes(`Completed`)?`completed`:String(n.event_type||``).includes(`Error`)?`error`:`running`})}}),e},[xe]),Ce=(0,y.useMemo)(()=>u?Se.filter(e=>e.status===`running`).slice(-2).map(e=>e.executorId):[],[Se,u]),we=(0,y.useCallback)(async(n,r)=>{if(!e||e.type!==`workflow`)return;d(!0),p(!1),l([]),D(``),L.current={},R.current=null,z.current=null,ee([]),I({}),t(`clear`);let i=j();try{console.log(`[WorkflowView] Running workflow with:`),console.log(` - Current session ID:`,B?.conversation_id),console.log(` - Input data:`,n);let a={input_data:n,conversation_id:B?.conversation_id||void 0,checkpoint_id:r};B?.conversation_id?Lh.clearStreamingState(B.conversation_id):Lh.clearStreamingState(e.id);let o=Lh.streamWorkflowExecutionOpenAI(e.id,a,i);for await(let e of o){if((e.type===`response.output_item.added`||e.type===`response.output_item.done`||e.type===`response.created`||e.type===`response.in_progress`||e.type===`response.completed`||e.type===`response.failed`||e.type===`response.workflow_event.completed`||e.type===`response.workflow_event.complete`)&&l(t=>{let n=_j(e),r=Math.floor(Date.now()/1e3),i=t.length>0&&t[t.length-1]._uiTimestamp||0,a=n===void 0?Math.max(r,i+1):Math.max(n,i);return[...t,{...e,_uiTimestamp:a}]}),t(e),e.type===`response.output_item.added`){let t=e.item;if(t&&t.type===`executor_action`&&t.executor_id&&t.id&&(R.current=t.id,L.current[t.id]||(L.current[t.id]=``)),t&&t.type===`message`&&`metadata`in t&&t.metadata?.source===`magentic`&&t.id&&(R.current=t.id,L.current[t.id]||(L.current[t.id]=``)),t&&t.type===`message`&&(!(`metadata`in t)||!t.metadata?.source)&&t.metadata?.workflow_output_kind!==`intermediate`&&`content`in t&&Array.isArray(t.content))for(let e of t.content){let t=e.type===`output_text`?e.text:e.type===`refusal`?e.refusal:void 0;if(t){D(e=>e&&e.length>0?e+` + `})]}):(0,T.jsx)(`div`,{className:`flex items-center justify-center h-full bg-gray-50 dark:bg-gray-900 rounded border border-gray-200 dark:border-gray-700 ${i}`,children:(0,T.jsxs)(`div`,{className:`text-center text-gray-500 dark:text-gray-400`,children:[(0,T.jsx)(`div`,{className:`text-lg font-medium mb-2`,children:`No Workflow Data`}),(0,T.jsx)(`div`,{className:`text-sm`,children:`Workflow dump is not available.`})]})})});function fj({title:e,icon:t,children:n,className:r=``}){return(0,T.jsxs)(`div`,{className:`border rounded-lg p-4 bg-card ${r}`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-3`,children:[t,(0,T.jsx)(`h3`,{className:`text-sm font-semibold text-foreground`,children:e})]}),(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:n})]})}function pj({workflow:e,open:t,onOpenChange:n}){let r=e.source===`directory`?(0,T.jsx)(Ul,{className:`h-4 w-4 text-muted-foreground`}):e.source===`in_memory`?(0,T.jsx)(Rl,{className:`h-4 w-4 text-muted-foreground`}):(0,T.jsx)(Wl,{className:`h-4 w-4 text-muted-foreground`}),i=e.source===`directory`?`Local`:e.source===`in_memory`?`In-Memory`:`Gallery`;return(0,T.jsx)(Im,{open:t,onOpenChange:n,children:(0,T.jsxs)(Lm,{className:`max-w-4xl max-h-[90vh] flex flex-col`,children:[(0,T.jsxs)(Rm,{className:`px-6 pt-6 flex-shrink-0`,children:[(0,T.jsx)(zm,{children:`Workflow Details`}),(0,T.jsx)(Vm,{onClose:()=>n(!1)})]}),(0,T.jsxs)(`div`,{className:`px-6 pb-6 overflow-y-auto flex-1`,children:[(0,T.jsxs)(`div`,{className:`mb-6`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-3 mb-2`,children:[(0,T.jsx)(Tu,{className:`h-6 w-6 text-primary`}),(0,T.jsx)(`h2`,{className:`text-xl font-semibold text-foreground`,children:e.name||e.id})]}),e.description&&(0,T.jsx)(`p`,{className:`text-muted-foreground`,children:e.description})]}),(0,T.jsx)(`div`,{className:`h-px bg-border mb-6`}),(0,T.jsxs)(`div`,{className:`grid grid-cols-1 md:grid-cols-2 gap-4 mb-4`,children:[(0,T.jsx)(fj,{title:`Start Executor`,icon:(0,T.jsx)(jl,{className:`h-4 w-4 text-muted-foreground`}),children:(0,T.jsx)(`div`,{className:`font-mono text-foreground`,children:e.start_executor_id})}),(0,T.jsx)(fj,{title:`Source`,icon:r,children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-foreground`,children:i}),e.module_path&&(0,T.jsx)(`div`,{className:`font-mono text-xs break-all`,children:e.module_path})]})}),(0,T.jsx)(fj,{title:`Environment`,icon:e.has_env?(0,T.jsx)(Ml,{className:`h-4 w-4 text-orange-500`}):(0,T.jsx)(kl,{className:`h-4 w-4 text-green-500`}),className:`md:col-span-2`,children:(0,T.jsx)(`div`,{className:e.has_env?`text-orange-600 dark:text-orange-400`:`text-green-600 dark:text-green-400`,children:e.has_env?`Requires environment variables`:`No environment variables required`})})]}),(0,T.jsx)(fj,{title:`Executors (${e.executors.length})`,icon:(0,T.jsx)(au,{className:`h-4 w-4 text-muted-foreground`}),children:e.executors.length>0?(0,T.jsx)(`div`,{className:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2`,children:e.executors.map((e,t)=>(0,T.jsx)(`div`,{className:`font-mono text-xs text-foreground bg-muted px-2 py-1 rounded truncate`,title:e,children:e},t))}):(0,T.jsx)(`div`,{className:`text-muted-foreground`,children:`No executors configured`})})]})]})})}function mj({session:e,checkpoints:t,open:n,onOpenChange:r}){let[i,a]=(0,y.useState)(null),[o,s]=(0,y.useState)(null),[c,l]=(0,y.useState)(!1),[u,d]=(0,y.useState)(!0);if((0,y.useEffect)(()=>{n&&t.length>0&&(t.some(e=>e.checkpoint_id===i)||a(t[0].checkpoint_id))},[n,t]),(0,y.useEffect)(()=>{!i||!e||(async()=>{l(!0);try{s((await Vh.getConversationItem(e.conversation_id,`checkpoint_${i}`)).metadata?.full_checkpoint??null)}catch(e){console.error(`Failed to load checkpoint:`,e),s(null)}finally{l(!1)}})()},[i,e]),!e)return null;let f=t.find(e=>e.checkpoint_id===i),p=o?.state?._executor_state?Object.keys(o.state._executor_state):[],m=o?.messages?Object.keys(o.messages):[],h=e=>{if(!e)return``;let t=e/1024;return t<1?`${e} B`:t<1024?`${t.toFixed(1)} KB`:`${(t/1024).toFixed(1)} MB`};return(0,T.jsx)(Im,{open:n,onOpenChange:r,children:(0,T.jsxs)(Lm,{className:`w-[90vw] max-w-6xl min-w-[800px] h-[85vh] flex flex-col p-0`,children:[(0,T.jsx)(Rm,{className:`px-6 pt-6 pb-4 border-b flex-shrink-0`,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`flex-1`,children:[(0,T.jsx)(zm,{children:e.metadata.name}),(0,T.jsxs)(`div`,{className:`text-sm text-muted-foreground mt-1`,children:[t.length,` checkpoint`,t.length===1?``:`s`]}),(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground mt-2 max-w-2xl`,children:`This is a read only view of the current checkpoint ids in the checkpoint storage for this workflow run.`})]}),(0,T.jsx)(Vm,{onClose:()=>r(!1)})]})}),(0,T.jsxs)(`div`,{className:`flex-1 flex overflow-hidden min-h-0`,children:[(0,T.jsx)(`div`,{className:`w-80 border-r flex flex-col`,children:(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsx)(`div`,{className:`p-4 space-y-2`,children:t.length===0?(0,T.jsx)(`div`,{className:`text-center text-sm text-muted-foreground py-8`,children:`No checkpoints yet`}):t.map((e,n)=>{let r=e.checkpoint_id===i,o=e.metadata.has_pending_hil;return(0,T.jsxs)(`div`,{className:`relative`,children:[(0,T.jsx)(`button`,{onClick:()=>a(e.checkpoint_id),className:at(`relative w-full text-left p-3 rounded-lg border transition-colors`,r?`bg-primary/10 border-primary`:`hover:bg-muted/50 border-transparent`),children:(0,T.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,T.jsx)(`div`,{className:`flex flex-col items-center pt-1`,children:(0,T.jsx)(`div`,{className:at(`w-2 h-2 rounded-full z-10`,o?`bg-blue-500 ring-2 ring-blue-500/20`:`bg-muted-foreground/30`)})}),(0,T.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`span`,{className:`text-sm font-medium`,children:e.metadata.iteration_count===0?`Initial State`:`Step ${e.metadata.iteration_count}`}),(0,T.jsx)(`span`,{className:`text-[10px] font-mono text-muted-foreground/70`,title:e.checkpoint_id,children:e.checkpoint_id.slice(0,8)}),n===0&&(0,T.jsx)(ct,{variant:`secondary`,className:`text-[10px] h-4 px-1`,children:`Latest`}),o&&(0,T.jsxs)(ct,{variant:`secondary`,className:`text-[10px] h-4 px-1.5`,children:[e.metadata.pending_hil_count,` HIL`]})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-3 text-xs text-muted-foreground mt-1`,children:[(0,T.jsx)(`span`,{children:new Date(e.timestamp).toLocaleTimeString()}),e.metadata.size_bytes&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`span`,{children:`•`}),(0,T.jsx)(`span`,{children:h(e.metadata.size_bytes)})]})]})]})]})}),n0&&(0,T.jsxs)(`div`,{children:[(0,T.jsxs)(`div`,{className:`text-sm font-medium mb-3 flex items-center gap-2`,children:[(0,T.jsx)(au,{className:`h-4 w-4`}),`Active Executors (`,p.length,`)`]}),(0,T.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:p.map(e=>(0,T.jsx)(ct,{variant:`outline`,className:`font-mono text-xs`,children:e},e))})]}),m.length>0&&o&&(0,T.jsxs)(`div`,{children:[(0,T.jsxs)(`div`,{className:`text-sm font-medium mb-3 flex items-center gap-2`,children:[(0,T.jsx)(nu,{className:`h-4 w-4`}),`Messages`]}),(0,T.jsx)(`div`,{className:`grid grid-cols-2 gap-3`,children:m.map(e=>{let t=o.messages[e]?.length;return(0,T.jsxs)(`div`,{className:`bg-muted/50 p-3 rounded-lg`,children:[(0,T.jsx)(`div`,{className:`text-xs font-mono text-muted-foreground mb-1`,children:e}),(0,T.jsxs)(`div`,{className:`font-medium`,children:[t,` message`,t===1?``:`s`]})]},e)})})]}),o?.pending_request_info_events&&Object.keys(o.pending_request_info_events).length>0&&(0,T.jsxs)(`div`,{children:[(0,T.jsxs)(`div`,{className:`text-sm font-medium mb-3 flex items-center gap-2`,children:[(0,T.jsx)(Ol,{className:`h-4 w-4`}),`Pending HIL Requests (`,Object.keys(o.pending_request_info_events).length,`)`]}),(0,T.jsx)(`div`,{className:`space-y-2`,children:Object.entries(o.pending_request_info_events).map(([e,t])=>(0,T.jsxs)(`div`,{className:`bg-muted/50 border border-border p-3 rounded-lg`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between mb-2`,children:[(0,T.jsxs)(`code`,{className:`text-xs bg-background px-2 py-1 rounded`,children:[e.slice(0,24),`...`]}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs`,children:t.source_executor_id})]}),(0,T.jsxs)(`div`,{className:`text-xs space-y-1`,children:[(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Request:`}),` `,(0,T.jsx)(`code`,{className:`bg-background px-1 py-0.5 rounded`,children:t.request_type?.split(`.`).pop()||t.request_type})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`span`,{className:`text-muted-foreground`,children:`Response:`}),` `,(0,T.jsx)(`code`,{className:`bg-background px-1 py-0.5 rounded`,children:t.response_type?.split(`.`).pop()||t.response_type})]})]})]},e))})]}),(0,T.jsxs)(`div`,{children:[(0,T.jsx)(`div`,{className:`text-sm font-medium mb-3`,children:`Workflow State`}),o?.state&&Object.keys(o.state).filter(e=>e!==`_executor_state`).length>0?(0,T.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:Object.keys(o.state).filter(e=>e!==`_executor_state`).map(e=>(0,T.jsx)(ct,{variant:`secondary`,className:`font-mono text-xs`,children:e},e))}):(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground`,children:`No custom state`})]}),(0,T.jsxs)(`div`,{className:`border-t pt-6`,children:[(0,T.jsxs)(`button`,{onClick:()=>d(!u),className:`flex items-center gap-2 text-sm font-medium hover:text-primary transition-colors w-full`,children:[u?(0,T.jsx)(X,{className:`h-4 w-4`}):(0,T.jsx)(Z,{className:`h-4 w-4`}),`Raw JSON`]}),u&&(0,T.jsx)(`pre`,{className:`mt-3 text-[10px] font-mono bg-muted p-4 rounded overflow-x-auto`,children:JSON.stringify(o,null,2)})]})]})})})]})]})})}function hj({request:e,response:t,onResponseChange:n,onSubmit:r,isSubmitting:i}){let[a,o]=(0,y.useState)(!0),s=e=>{n(e)},c=Sv(e.request_schema,t);return(0,T.jsx)(`div`,{className:`relative group`,children:(0,T.jsx)(`div`,{children:(0,T.jsx)(`div`,{className:`flex-1`,children:(0,T.jsxs)(`div`,{className:`border border-orange-200 dark:border-orange-800 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden rounded-lg`,children:[(0,T.jsxs)(`div`,{className:`px-4 py-3 bg-orange-100/50 dark:bg-orange-950/30 border-b border-orange-200 dark:border-orange-800 flex items-center justify-between cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-950/40 transition-colors`,onClick:()=>o(!a),children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-sm text-orange-900 dark:text-orange-100`,children:`Workflow needs your input`}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs font-mono border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300`,children:e.request_id.slice(0,8)}),!a&&(0,T.jsx)(`span`,{className:`text-xs text-orange-600 dark:text-orange-400 animate-pulse`,children:`Click to respond`})]}),i&&(0,T.jsx)(ct,{variant:`secondary`,className:`animate-pulse`,children:`Submitting...`})]}),a&&(0,T.jsxs)(`div`,{className:`p-4 space-y-4`,children:[Object.keys(e.request_data).length>0&&(0,T.jsxs)(`div`,{className:`bg-white/60 dark:bg-gray-900/30 rounded-md p-3 space-y-2`,children:[(0,T.jsx)(`p`,{className:`text-xs font-medium text-muted-foreground uppercase tracking-wider`,children:`Context`}),(0,T.jsx)(`div`,{className:`max-h-48 overflow-y-auto space-y-1 pr-2`,children:Object.entries(e.request_data).filter(([e])=>![`request_id`,`source_executor_id`].includes(e)).map(([e,t])=>(0,T.jsxs)(`div`,{className:`text-sm`,children:[(0,T.jsxs)(`span`,{className:`font-medium text-muted-foreground`,children:[e,`:`]}),` `,(0,T.jsx)(`span`,{className:`text-foreground break-all`,children:typeof t==`object`?JSON.stringify(t,null,2):String(t)})]},e))})]}),e.request_schema?.description&&(0,T.jsxs)(`div`,{className:`text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 p-3 rounded-md border border-blue-200 dark:border-blue-800`,children:[(0,T.jsx)(`p`,{className:`font-medium text-blue-900 dark:text-blue-100 mb-1`,children:`What's needed:`}),(0,T.jsx)(`p`,{className:`text-blue-800 dark:text-blue-200`,children:e.request_schema.description})]}),(0,T.jsx)(`div`,{className:`space-y-3`,children:(0,T.jsx)(xv,{schema:e.request_schema,values:t,onChange:s})}),(0,T.jsxs)(`div`,{className:`space-y-2 pt-2`,children:[(0,T.jsxs)(K,{size:`default`,onClick:r,disabled:!c||i,className:`w-full gap-2`,children:[(0,T.jsx)(hu,{className:`w-4 h-4`}),`Submit Response`]}),!c&&(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground text-center`,children:`Please fill in all required fields`})]})]})]})})})})}function gj(e,t=!0){switch(e){case`running`:return(0,T.jsx)(Ql,{className:`w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] ${t?`animate-spin`:``}`});case`completed`:return(0,T.jsx)(kl,{className:`w-4 h-4 text-green-500 dark:text-green-400`});case`failed`:return(0,T.jsx)(Ml,{className:`w-4 h-4 text-red-500 dark:text-red-400`});case`cancelled`:return(0,T.jsx)(Ol,{className:`w-4 h-4 text-orange-500 dark:text-orange-400`});default:return(0,T.jsx)(`div`,{className:`w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500`})}}function _j(e){switch(e){case`running`:return`bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20`;case`completed`:return`bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20`;case`failed`:return`bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20`;case`cancelled`:return`bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20`;default:return`bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20`}}function vj(e){return e.content?.map(e=>e.type===`output_text`?e.text:e.type===`refusal`?e.refusal:void 0).filter(e=>!!e).join(` +`)??``}function yj({run:e,isExpanded:t,onToggle:n,onClick:r,isSelected:i,isStreaming:a}){let o=new Date(e.timestamp).toLocaleTimeString(),s=e.output.trim().length>0||e.error,c=(0,y.useRef)(null);return(0,y.useEffect)(()=>{t&&e.state===`running`&&c.current&&(c.current.scrollTop=c.current.scrollHeight)},[e.output,t,e.state]),(0,T.jsxs)(`div`,{className:`border rounded-lg transition-all ${i?`border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10`:`border-border hover:border-muted-foreground/30`}`,children:[(0,T.jsxs)(`div`,{className:`p-3 cursor-pointer`,onClick:()=>{r(),s&&n()},children:[(0,T.jsxs)(`div`,{className:`grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1`,children:[(0,T.jsx)(`div`,{className:`w-3 text-muted-foreground`,children:s&&(0,T.jsx)(T.Fragment,{children:t?(0,T.jsx)(X,{className:`w-3 h-3`}):(0,T.jsx)(Z,{className:`w-3 h-3`})})}),(0,T.jsx)(`div`,{children:gj(e.state,a)}),(0,T.jsx)(`span`,{className:`font-medium text-sm truncate overflow-hidden`,children:e.executorName}),e.runNumber>1?(0,T.jsxs)(ct,{variant:`outline`,className:`text-xs whitespace-nowrap`,children:[`Run #`,e.runNumber]}):(0,T.jsx)(`div`,{})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs text-muted-foreground ml-5`,children:[(0,T.jsx)(`span`,{className:`font-mono`,children:o}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs border ${_j(e.state)}`,children:e.state})]})]}),t&&s&&(0,T.jsx)(`div`,{className:`border-t px-3 py-2 bg-muted/30`,children:e.error?(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-xs font-medium text-red-600 dark:text-red-400`,children:`Error:`}),(0,T.jsx)(`pre`,{className:`text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all`,children:e.error})]}):(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Output:`}),(0,T.jsx)(`pre`,{ref:c,className:`text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all`,children:e.output})]})})]})}function bj({events:e,itemOutputs:t,currentExecutorId:n,isStreaming:r,onExecutorClick:i,selectedExecutorId:a,workflowResult:o,pendingHilRequests:s=[],hilResponses:c={},onHilResponseChange:l,onHilSubmit:u,isSubmittingHil:d=!1,inputSchema:f,onRun:p,onCancel:m,isCancelling:h=!1,workflowState:g=`ready`,wasCancelled:_=!1,checkpoints:v=[]}){let[b,x]=(0,y.useState)(new Set),[S,C]=(0,y.useState)(0),[w,E]=(0,y.useState)(!1),D=(0,y.useRef)(null),O=(0,y.useRef)(null),k=(0,y.useRef)(null);(0,y.useEffect)(()=>{if(r){let e=setInterval(()=>{C(e=>e+1)},100);return()=>clearInterval(e)}},[r]);let{executorRuns:A,executorRunCount:j}=(0,y.useMemo)(()=>{let n=[],r=new Map;return e.forEach(e=>{let i=`_uiTimestamp`in e&&typeof e._uiTimestamp==`number`?e._uiTimestamp*1e3:Date.now();if(e.type===`response.output_item.added`){let a=e.item;if(a&&a.type===`executor_action`&&`executor_id`in a&&a.id){let e=String(a.executor_id),o=a.id,s=(r.get(e)||0)+1;r.set(e,s),n.push({executorId:e,executorName:kv(e,35),itemId:o,state:`running`,output:t[o]||``,timestamp:i,runNumber:s})}else if(a&&a.type===`message`&&`metadata`in a&&a.id){let e=a.metadata;if(e?.agent_id&&e?.source===`magentic`){let o=e.agent_id,s=a.id,c=(r.get(o)||0)+1;r.set(o,c),n.push({executorId:o,executorName:kv(o,35),itemId:s,state:`running`,output:t[s]||``,timestamp:i,runNumber:c})}else if(e?.executor_id&&e.workflow_output_kind===`intermediate`){let o=e.executor_id,s=a.id,c=(r.get(o)||0)+1;r.set(o,c),n.push({executorId:o,executorName:kv(o,35),itemId:s,state:a.status===`completed`?`completed`:`running`,output:t[s]||vj(a),timestamp:i,runNumber:c})}}}if(e.type===`response.output_item.done`){let r=e.item;if(r&&r.type===`executor_action`&&`executor_id`in r&&r.id){let e=r.id,i=n.find(t=>t.itemId===e);i&&(i.state=r.status===`completed`?`completed`:r.status===`failed`?`failed`:`completed`,i.output=t[e]||``,r.status===`failed`&&`error`in r&&r.error&&(i.error=String(r.error)))}else if(r&&r.type===`message`&&`metadata`in r&&r.id){let e=r.metadata;if(e?.agent_id&&e?.source===`magentic`){let e=r.id,i=n.find(t=>t.itemId===e);i&&(i.state=r.status===`completed`?`completed`:`failed`,i.output=t[e]||``)}else if(e?.executor_id&&e.workflow_output_kind===`intermediate`){let e=r.id,i=n.find(t=>t.itemId===e);i&&(i.state=r.status===`completed`?`completed`:`failed`,i.output=t[e]||vj(r))}}}if(e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let a=e.data,o=a.executor_id;if(!o)return;let s=a.event_type;if(s===`ExecutorInvokedEvent`){let e=(r.get(o)||0)+1;r.set(o,e);let a=`fallback_${o}_run${e}`;n.push({executorId:o,executorName:kv(o,35),itemId:a,state:`running`,output:t[a]||``,timestamp:i,runNumber:e})}else if(s===`ExecutorCompletedEvent`){let e;for(let t=n.length-1;t>=0;t--)if(n[t].executorId===o&&n[t].state===`running`){e=n[t];break}e&&(e.state=`completed`,e.output=t[e.itemId]||``)}else if(s?.includes(`Error`)||s?.includes(`Failed`)){let e;for(let t=n.length-1;t>=0;t--)if(n[t].executorId===o&&n[t].state===`running`){e=n[t];break}e&&(e.state=`failed`,e.error=typeof a.data==`string`?a.data:`Execution failed`)}}}),n.forEach(e=>{e.state===`running`&&t[e.itemId]&&(e.output=t[e.itemId])}),{executorRuns:n,executorRunCount:r}},[e,t,S]);return(0,y.useEffect)(()=>{n&&x(e=>{let t=new Set(e);return t.add(`${n}-${j.get(n)||1}`),t})},[n,j]),(0,y.useEffect)(()=>{if(A.length>0&&r){let e=A[A.length-1],t=`${e.executorId}-${e.runNumber}`;t!==D.current&&(D.current=t,O.current&&O.current.scrollIntoView({behavior:`smooth`,block:`end`}))}},[A,r]),(0,y.useEffect)(()=>{o&&!r&&O.current&&setTimeout(()=>{O.current?.scrollIntoView({behavior:`smooth`,block:`end`})},100)},[o,r]),(0,y.useEffect)(()=>{!r&&A.length>0&&s.length===0&&setTimeout(()=>{O.current?.scrollIntoView({behavior:`smooth`,block:`end`})},100)},[r,s.length,A.length]),(0,y.useEffect)(()=>{s.length>0&&k.current&&setTimeout(()=>{k.current?.scrollIntoView({behavior:`smooth`,block:`end`}),k.current?.classList.add(`highlight-attention`),setTimeout(()=>{k.current?.classList.remove(`highlight-attention`)},1e3)},100)},[s.length]),(0,T.jsxs)(`div`,{className:`h-full flex flex-col border-l bg-muted/30`,children:[(0,T.jsxs)(`div`,{className:`p-3 border-b bg-background flex items-center justify-between flex-shrink-0`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`Execution Timeline`}),(0,T.jsx)(ct,{variant:`outline`,className:`text-xs`,children:A.length}),r&&(0,T.jsxs)(`div`,{className:`flex items-center gap-1 text-xs text-muted-foreground`,children:[(0,T.jsx)(`div`,{className:`h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]`}),(0,T.jsx)(`span`,{children:`Running`})]})]}),A.length>0&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>{let e=A.map(e=>`${`[${new Date(e.timestamp).toLocaleTimeString()}] ${e.executorName} (${e.state})`}\n${e.error||e.output||`(no output)`}\n`).join(` +`);navigator.clipboard.writeText(e),E(!0),setTimeout(()=>E(!1),2e3)},className:`h-7 px-2 text-xs ${w?`text-green-600 dark:text-green-400`:``}`,children:w?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Tl,{className:`w-3 h-3 mr-1`}),`Copied!`]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Ll,{className:`w-3 h-3 mr-1`}),`Copy All`]})})]}),(0,T.jsx)(gf,{className:`flex-1`,children:(0,T.jsxs)(`div`,{className:`p-3 space-y-2`,children:[A.length===0?(0,T.jsx)(`div`,{className:`text-center text-muted-foreground text-sm py-8`,children:r?`Workflow is running...`:`Ready to run workflow`}):(0,T.jsxs)(T.Fragment,{children:[A.map((e,t)=>{let n=`${e.executorId}-${e.runNumber}`;return(0,T.jsx)(yj,{run:e,isExpanded:b.has(n),onToggle:()=>{x(e=>{let t=new Set(e);return t.has(n)?t.delete(n):t.add(n),t})},onClick:()=>i?.(e.executorId),isSelected:a===e.executorId,isStreaming:r},`${n}-${t}`)}),s.length>0&&(0,T.jsx)(`div`,{ref:k,"data-hil-form":!0,className:`transition-all duration-300`,children:s.map(e=>(0,T.jsx)(hj,{request:e,response:c[e.request_id]||{},onResponseChange:t=>l?.(e.request_id,t),onSubmit:()=>u?.(),isSubmitting:d},e.request_id))})]}),o&&o.trim().length>0&&!r&&!_&&(0,T.jsxs)(`div`,{className:`border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10`,children:[(0,T.jsx)(`div`,{className:`p-3 bg-green-500/10 border-b border-green-500/20`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2 mb-1`,children:[(0,T.jsx)(kl,{className:`w-4 h-4 text-green-500 dark:text-green-400`}),(0,T.jsx)(`span`,{className:`font-medium text-sm`,children:`Workflow Complete`})]})}),(0,T.jsx)(`div`,{className:`border-t px-3 py-2 bg-muted/30`,children:(0,T.jsxs)(`div`,{className:`space-y-1`,children:[(0,T.jsx)(`div`,{className:`text-xs font-medium text-muted-foreground`,children:`Final Output:`}),(0,T.jsx)(`pre`,{className:`text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all`,children:o})]})})]}),_&&!r&&(0,T.jsx)(`div`,{className:`border rounded-lg border-orange-500/40 bg-orange-500/5 dark:bg-orange-500/10`,children:(0,T.jsxs)(`div`,{className:`px-4 py-3 flex items-center gap-2`,children:[(0,T.jsx)(yu,{className:`w-4 h-4 text-orange-500 dark:text-orange-400 fill-current`}),(0,T.jsx)(`span`,{className:`font-medium text-sm text-orange-700 dark:text-orange-300`,children:`Execution stopped by user`})]})}),(0,T.jsx)(`div`,{ref:O})]})}),(p||m)&&s.length===0&&(0,T.jsx)(`div`,{className:`border-t p-3 bg-background flex-shrink-0`,children:f&&Ov(f)?(0,T.jsx)(fg,{onSubmit:async e=>{p?.([{type:`message`,role:`user`,content:e}])},isSubmitting:g===`running`,isStreaming:g===`running`,onCancel:m,isCancelling:h,placeholder:`Message workflow...`,showFileUpload:!0,entityName:`workflow`}):(0,T.jsx)(Bv,{inputSchema:f,onRun:p||(()=>{}),onCancel:m,isSubmitting:g===`running`,isCancelling:h,workflowState:g,checkpoints:v,showCheckpoints:!1})})]})}Td();function xj(e){if(`created_at`in e&&typeof e.created_at==`number`&&e.created_at)return e.created_at;let t=`response`in e?e.response:void 0;if(t&&typeof t==`object`&&`created_at`in t){let e=t.created_at;if(typeof e==`number`)return e}let n=`data`in e?e.data:void 0;if(n&&typeof n==`object`&&`timestamp`in n&&typeof n.timestamp==`string`){let e=new Date(n.timestamp).getTime();return Number.isFinite(e)?e/1e3:void 0}}function Sj({selectedWorkflow:e,onDebugEvent:t}){let[n,r]=(0,y.useState)(null),[i,a]=(0,y.useState)(!1),[o,s]=(0,y.useState)(null),[c,l]=(0,y.useState)([]),[u,d]=(0,y.useState)(!1),[f,p]=(0,y.useState)(!1),[m,h]=(0,y.useState)(null),[g,_]=(0,y.useState)(!1),[v,b]=(0,y.useState)(!1),[x,S]=(0,y.useState)(!1),[C,w]=(0,y.useState)(!1),[E,D]=(0,y.useState)(``),[O,k]=(0,y.useState)([]),{isCancelling:A,createAbortSignal:j,handleCancel:M,resetCancelling:N}=wh(),[P,ee]=(0,y.useState)([]),[F,I]=(0,y.useState)({}),L=(0,y.useRef)({}),R=(0,y.useRef)(null),z=(0,y.useRef)(null),B=Q(e=>e.currentSession),V=Q(e=>e.availableSessions),H=Q(e=>e.loadingSessions),U=Q(e=>e.setCurrentSession),te=Q(e=>e.setAvailableSessions),ne=Q(e=>e.setLoadingSessions),re=Q(e=>e.addSession),ie=Q(e=>e.removeSession),ae=Q(e=>e.addToast),oe=Q(e=>e.runtime),se=Q(e=>e.streamingEnabled),[ce,le]=(0,y.useState)(()=>{let e=localStorage.getItem(`workflowViewOptions`),t={showMinimap:!1,showGrid:!0,animateRun:!1,consolidateBidirectionalEdges:!0};if(e){let n=JSON.parse(e);return{...t,...n}}return t}),[ue,de]=(0,y.useState)(()=>localStorage.getItem(`workflowLayoutDirection`)||`TB`);(0,y.useEffect)(()=>{localStorage.setItem(`workflowViewOptions`,JSON.stringify(ce))},[ce]),(0,y.useEffect)(()=>{localStorage.setItem(`workflowLayoutDirection`,ue)},[ue]);let fe=e=>{le(t=>({...t,[e]:!t[e]}))},pe=async()=>{if(x||!e)return;S(!0);let{addToast:t,updateWorkflow:n}=await Ah(()=>Promise.resolve().then(()=>(Dd(),Ed)).then(e=>({addToast:e.useDevUIStore.getState().addToast,updateWorkflow:e.useDevUIStore.getState().updateWorkflow})),void 0,import.meta.url);try{await Vh.reloadEntity(e.id);let i=await Vh.getWorkflowInfo(e.id);n(i),r(i),t({message:`${e.name} has been reloaded successfully`,type:`success`})}catch(e){t({message:`Failed to reload: ${e instanceof Error?e.message:`Failed to reload entity`}`,type:`error`,duration:6e3})}finally{S(!1)}};(0,y.useEffect)(()=>{l([]),d(!1),h(null),D(``),s(null),L.current={},R.current=null,z.current=null,(async()=>{if(e.type===`workflow`){a(!0),s(null);try{r(await Vh.getWorkflowInfo(e.id)),s(null)}catch(e){r(null),s(e instanceof Error?e.message:String(e)),console.error(`Error loading workflow info:`,e)}finally{a(!1)}}})()},[e.id,e.type]);let me=(0,y.useCallback)(async()=>{if(n){ne(!0);try{let e=await Vh.listWorkflowSessions(n.id);if(e.data.length===0){let e=await Vh.createWorkflowSession(n.id,{name:`Checkpoint Storage ${new Date().toLocaleString()}`});te([e]),U(e)}else{let t=[...e.data].sort((e,t)=>t.created_at-e.created_at);if(te(t),!B){let e=t[0];U(e),await ge(e)}}}catch(e){console.error(`Failed to load sessions:`,e),oe!==`dotnet`&&ae({message:`Failed to load sessions`,type:`error`})}finally{ne(!1)}}},[n,B,oe,ae,te,U]);(0,y.useEffect)(()=>{me()},[n?.id,oe]);let he=(0,y.useCallback)(async()=>{if(!B){k([]);return}try{k((await Vh.listConversationItems(B.conversation_id,{limit:100})).data.filter(e=>typeof e==`object`&&!!e&&`type`in e&&e.type===`checkpoint`))}catch(e){console.error(`Failed to load checkpoints for session ${B.conversation_id}:`,e),k([])}},[B]);(0,y.useEffect)(()=>{v&&B&&he()},[v,B,he]);let ge=(0,y.useCallback)(async e=>{!e||!n||(l([]),d(!1),p(!1),h(null),w(!1),D(``),ee([]),I({}),L.current={},R.current=null,z.current=null)},[n]),_e=(0,y.useCallback)(async e=>{let t=V.find(t=>t.conversation_id===e);t&&(U(t),await ge(t))},[V,U,ge]),ve=(0,y.useCallback)(async()=>{if(n)try{let e=await Vh.createWorkflowSession(n.id,{name:`Checkpoint Storage ${new Date().toLocaleString()}`});console.log(`[WorkflowView] Created new session:`,e.conversation_id),console.log(`[WorkflowView] Previous session:`,B?.conversation_id),re(e),U(e),await ge(e),await new Promise(e=>setTimeout(e,100)),ae({message:`New checkpoint storage created`,type:`success`})}catch(e){console.error(`Failed to create checkpoint storage:`,e),ae({message:`Failed to create checkpoint storage`,type:`error`})}},[n,B,re,U,ge,ae]),ye=(0,y.useCallback)(async()=>{if(!(!B||!n)&&confirm(`Delete this session? All checkpoints will be lost.`))try{await Vh.deleteWorkflowSession(n.id,B.conversation_id),ie(B.conversation_id),ae({message:`Session deleted`,type:`success`})}catch(e){console.error(`Failed to delete session:`,e),ae({message:`Failed to delete session`,type:`error`})}},[B,n,ie,ae]),be=e=>{h(e)},xe=(0,y.useMemo)(()=>c.filter(e=>e.type===`response.output_item.added`||e.type===`response.output_item.done`||e.type===`response.created`||e.type===`response.in_progress`||e.type===`response.completed`||e.type===`response.failed`||e.type===`response.workflow_event.completed`||e.type===`response.workflow_event.complete`),[c]),Se=(0,y.useMemo)(()=>{let e=[];return xe.forEach(t=>{if(t.type===`response.output_item.added`||t.type===`response.output_item.done`){let n=t.item;n&&n.type===`executor_action`&&`executor_id`in n&&n.executor_id&&e.push({executorId:String(n.executor_id),message:t.type===`response.output_item.added`?`Executor started`:n.status===`completed`?`Executor completed`:n.status===`failed`?`Executor failed`:`Executor processing`,timestamp:new Date().toISOString(),status:n.status===`completed`?`completed`:n.status===`failed`?`error`:`running`})}else if(t.type===`response.workflow_event.complete`&&`data`in t&&t.data&&typeof t.data==`object`){let n=t.data;n.executor_id!=null&&e.push({executorId:String(n.executor_id),message:String(n.event_type||`Processing`),timestamp:String(n.timestamp||new Date().toISOString()),status:String(n.event_type||``).includes(`Completed`)?`completed`:String(n.event_type||``).includes(`Error`)?`error`:`running`})}}),e},[xe]),Ce=(0,y.useMemo)(()=>u?Se.filter(e=>e.status===`running`).slice(-2).map(e=>e.executorId):[],[Se,u]),we=(0,y.useCallback)(async(n,r)=>{if(!e||e.type!==`workflow`)return;d(!0),p(!1),l([]),D(``),L.current={},R.current=null,z.current=null,ee([]),I({}),t(`clear`);let i=j();try{console.log(`[WorkflowView] Running workflow with:`),console.log(` - Current session ID:`,B?.conversation_id),console.log(` - Input data:`,n);let a={input_data:n,conversation_id:B?.conversation_id||void 0,checkpoint_id:r};B?.conversation_id?Vh.clearStreamingState(B.conversation_id):Vh.clearStreamingState(e.id);let o=Vh.streamWorkflowExecutionOpenAI(e.id,a,i);for await(let e of o){if((e.type===`response.output_item.added`||e.type===`response.output_item.done`||e.type===`response.created`||e.type===`response.in_progress`||e.type===`response.completed`||e.type===`response.failed`||e.type===`response.workflow_event.completed`||e.type===`response.workflow_event.complete`)&&l(t=>{let n=xj(e),r=Math.floor(Date.now()/1e3),i=t.length>0&&t[t.length-1]._uiTimestamp||0,a=n===void 0?Math.max(r,i+1):Math.max(n,i);return[...t,{...e,_uiTimestamp:a}]}),t(e),e.type===`response.output_item.added`){let t=e.item;if(t&&t.type===`executor_action`&&t.executor_id&&t.id&&(R.current=t.id,L.current[t.id]||(L.current[t.id]=``)),t&&t.type===`message`&&`metadata`in t&&t.metadata?.source===`magentic`&&t.id&&(R.current=t.id,L.current[t.id]||(L.current[t.id]=``)),t&&t.type===`message`&&(!(`metadata`in t)||!t.metadata?.source)&&t.metadata?.workflow_output_kind!==`intermediate`&&`content`in t&&Array.isArray(t.content))for(let e of t.content){let t=e.type===`output_text`?e.text:e.type===`refusal`?e.refusal:void 0;if(t){D(e=>e&&e.length>0?e+` -`+t:t);try{let n=e.type===`output_text`?JSON.parse(t):void 0;typeof n==`object`&&n&&(z.current=n)}catch{}}}}if(e.type,e.type,e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let t=e.data;if(t.event_type===`ExecutorInvokedEvent`&&t.executor_id){let e=`fallback_${t.executor_id}_${Date.now()}`;R.current=e,L.current[e]||(L.current[e]=``)}(t.event_type===`WorkflowCompletedEvent`||t.event_type===`WorkflowOutputEvent`)&&t.data&&(typeof t.data==`object`&&(z.current=t.data),R.current=null)}if((e.type===`response.output_text.delta`||e.type===`response.refusal.delta`)&&`delta`in e&&e.delta){let t=e.item_id||R.current;t&&(L.current[t]||(L.current[t]=``),L.current[t]+=e.delta)}if(e.type===`response.request_info.requested`){let t=e;ee(e=>[...e,{request_id:t.request_id,request_data:t.request_data,request_schema:t.request_schema}]);let n=t.request_schema,r={};n.properties&&Object.entries(n.properties).forEach(([e,t])=>{let n=t;n.enum&&n.enum.length>0?r[e]=n.enum[0]:n.default!==void 0&&(r[e]=n.default)}),I(e=>({...e,[t.request_id]:r}))}if(e.type===`error`)break}d(!1)}catch(e){Th(e)?(console.log(`Workflow execution cancelled by user`),p(!0),ee([]),I({})):console.error(`Workflow execution error:`,e),d(!1),N()}},[e,t,B,j,N]),Te=(0,y.useCallback)(async(n,r)=>{if(!(!e||e.type!==`workflow`)){d(!1),p(!1),l([]),D(``),L.current={},R.current=null,z.current=null,ee([]),I({}),t(`clear`);try{let i=await Lh.runWorkflowSync(e.id,{input_data:n,conversation_id:B?.conversation_id||void 0,checkpoint_id:r});if(i.output){for(let e of i.output)if(e.type===`message`&&`content`in e&&Array.isArray(e.content))for(let t of e.content){let e=t.type===`output_text`?t.text:t.type===`refusal`?t.refusal:void 0;if(e){D(t=>t&&t.length>0?t+` +`+t:t);try{let n=e.type===`output_text`?JSON.parse(t):void 0;typeof n==`object`&&n&&(z.current=n)}catch{}}}}if(e.type,e.type,e.type===`response.workflow_event.completed`&&`data`in e&&e.data){let t=e.data;if(t.event_type===`ExecutorInvokedEvent`&&t.executor_id){let e=`fallback_${t.executor_id}_${Date.now()}`;R.current=e,L.current[e]||(L.current[e]=``)}(t.event_type===`WorkflowCompletedEvent`||t.event_type===`WorkflowOutputEvent`)&&t.data&&(typeof t.data==`object`&&(z.current=t.data),R.current=null)}if((e.type===`response.output_text.delta`||e.type===`response.refusal.delta`)&&`delta`in e&&e.delta){let t=e.item_id||R.current;t&&(L.current[t]||(L.current[t]=``),L.current[t]+=e.delta)}if(e.type===`response.request_info.requested`){let t=e;ee(e=>[...e,{request_id:t.request_id,request_data:t.request_data,request_schema:t.request_schema}]);let n=t.request_schema,r={};n.properties&&Object.entries(n.properties).forEach(([e,t])=>{let n=t;n.enum&&n.enum.length>0?r[e]=n.enum[0]:n.default!==void 0&&(r[e]=n.default)}),I(e=>({...e,[t.request_id]:r}))}if(e.type===`error`)break}d(!1)}catch(e){Th(e)?(console.log(`Workflow execution cancelled by user`),p(!0),ee([]),I({})):console.error(`Workflow execution error:`,e),d(!1),N()}},[e,t,B,j,N]),Te=(0,y.useCallback)(async(n,r)=>{if(!(!e||e.type!==`workflow`)){d(!1),p(!1),l([]),D(``),L.current={},R.current=null,z.current=null,ee([]),I({}),t(`clear`);try{let i=await Vh.runWorkflowSync(e.id,{input_data:n,conversation_id:B?.conversation_id||void 0,checkpoint_id:r});if(i.output){for(let e of i.output)if(e.type===`message`&&`content`in e&&Array.isArray(e.content))for(let t of e.content){let e=t.type===`output_text`?t.text:t.type===`refusal`?t.refusal:void 0;if(e){D(t=>t&&t.length>0?t+` -`+e:e);try{let n=t.type===`output_text`?JSON.parse(e):void 0;typeof n==`object`&&n&&(z.current=n)}catch{}}}}let a={type:`response.completed`,response:i,sequence_number:0};l([a]),t(a),await he()}catch(e){console.error(`Workflow execution error:`,e);let n={type:`response.failed`,response:{error:{message:e instanceof Error?e.message:`Workflow execution failed`}},sequence_number:0};l([n]),t(n)}}},[e,B,t,he]),Ee=(0,y.useCallback)(async(e,t)=>{se?await we(e,t):await Te(e,t)},[se,we,Te]),De=(0,y.useCallback)(()=>{for(let e of P){let t=F[e.request_id]||{};if(!vv(e.request_schema,t))return!1}return!0},[P,F]),Oe=(0,y.useCallback)(async()=>{if(!e||e.type!==`workflow`)return;if(!De()){console.warn(`Cannot submit: Not all HIL forms are valid`);return}d(!0),ee([]),I({});let n=j();try{let r={input_data:[{type:`message`,content:[{type:`workflow_hil_response`,responses:F}]}],conversation_id:B?.conversation_id||void 0},i=Lh.streamWorkflowExecutionOpenAI(e.id,r,n),a=!1,o=[];for await(let e of i){if((e.type===`response.output_item.added`||e.type===`response.output_item.done`||e.type===`response.created`||e.type===`response.in_progress`||e.type===`response.completed`||e.type===`response.failed`||e.type===`response.workflow_event.completed`)&&l(t=>{let n=_j(e),r=Math.floor(Date.now()/1e3),i=t.length>0&&t[t.length-1]._uiTimestamp||0,a=n===void 0?Math.max(r,i+1):Math.max(n,i);return[...t,{...e,_uiTimestamp:a}]}),t(e),e.type===`response.request_info.requested`){let t=e;a=!0;let n={request_id:t.request_id,request_data:t.request_data,request_schema:t.request_schema};o.push(n);let r=t.request_schema,i={};r.properties&&Object.entries(r.properties).forEach(([e,t])=>{let n=t;n.enum&&n.enum.length>0?i[e]=n.enum[0]:n.default!==void 0&&(i[e]=n.default)}),I(e=>({...e,[t.request_id]:i}))}if(e.type===`response.output_item.added`){let t=e.item;if(t&&t.type===`executor_action`&&t.executor_id&&t.id&&(R.current=t.id,L.current[t.id]||(L.current[t.id]=``)),t&&t.type===`message`&&`content`in t&&Array.isArray(t.content)&&t.metadata?.workflow_output_kind!==`intermediate`)for(let e of t.content){let t=e.type===`output_text`?e.text:e.type===`refusal`?e.refusal:void 0;if(t){D(e=>e&&e.length>0?e+` +`+e:e);try{let n=t.type===`output_text`?JSON.parse(e):void 0;typeof n==`object`&&n&&(z.current=n)}catch{}}}}let a={type:`response.completed`,response:i,sequence_number:0};l([a]),t(a),await he()}catch(e){console.error(`Workflow execution error:`,e);let n={type:`response.failed`,response:{error:{message:e instanceof Error?e.message:`Workflow execution failed`}},sequence_number:0};l([n]),t(n)}}},[e,B,t,he]),Ee=(0,y.useCallback)(async(e,t)=>{se?await we(e,t):await Te(e,t)},[se,we,Te]),De=(0,y.useCallback)(()=>{for(let e of P){let t=F[e.request_id]||{};if(!Sv(e.request_schema,t))return!1}return!0},[P,F]),Oe=(0,y.useCallback)(async()=>{if(!e||e.type!==`workflow`)return;if(!De()){console.warn(`Cannot submit: Not all HIL forms are valid`);return}d(!0),ee([]),I({});let n=j();try{let r={input_data:[{type:`message`,content:[{type:`workflow_hil_response`,responses:F}]}],conversation_id:B?.conversation_id||void 0},i=Vh.streamWorkflowExecutionOpenAI(e.id,r,n),a=!1,o=[];for await(let e of i){if((e.type===`response.output_item.added`||e.type===`response.output_item.done`||e.type===`response.created`||e.type===`response.in_progress`||e.type===`response.completed`||e.type===`response.failed`||e.type===`response.workflow_event.completed`)&&l(t=>{let n=xj(e),r=Math.floor(Date.now()/1e3),i=t.length>0&&t[t.length-1]._uiTimestamp||0,a=n===void 0?Math.max(r,i+1):Math.max(n,i);return[...t,{...e,_uiTimestamp:a}]}),t(e),e.type===`response.request_info.requested`){let t=e;a=!0;let n={request_id:t.request_id,request_data:t.request_data,request_schema:t.request_schema};o.push(n);let r=t.request_schema,i={};r.properties&&Object.entries(r.properties).forEach(([e,t])=>{let n=t;n.enum&&n.enum.length>0?i[e]=n.enum[0]:n.default!==void 0&&(i[e]=n.default)}),I(e=>({...e,[t.request_id]:i}))}if(e.type===`response.output_item.added`){let t=e.item;if(t&&t.type===`executor_action`&&t.executor_id&&t.id&&(R.current=t.id,L.current[t.id]||(L.current[t.id]=``)),t&&t.type===`message`&&`content`in t&&Array.isArray(t.content)&&t.metadata?.workflow_output_kind!==`intermediate`)for(let e of t.content){let t=e.type===`output_text`?e.text:e.type===`refusal`?e.refusal:void 0;if(t){D(e=>e&&e.length>0?e+` -`+t:t);try{let n=e.type===`output_text`?JSON.parse(t):void 0;typeof n==`object`&&n&&(z.current=n)}catch{}}}}if((e.type===`response.output_text.delta`||e.type===`response.refusal.delta`)&&`delta`in e&&e.delta){let t=R.current;t&&(L.current[t]||(L.current[t]=``),L.current[t]+=e.delta)}e.type===`response.completed`&&await he(),e.type===`response.failed`&&await he()}a&&ee(o),d(!1),await he()}catch(e){Th(e)?(console.log(`HIL submission cancelled by user`),p(!0)):console.error(`HIL submission error:`,e),d(!1),N(),await he()}},[e,F,t,B,De,j,N,he]);return i?(0,T.jsx)(uv,{message:`Loading workflow...`,description:`Fetching workflow structure and configuration`}):o?(0,T.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,T.jsxs)(`div`,{className:`text-center max-w-md p-6`,children:[(0,T.jsx)(`div`,{className:`text-red-500 mb-4`,children:(0,T.jsx)(`svg`,{className:`w-16 h-16 mx-auto`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`,children:(0,T.jsx)(`path`,{strokeLinecap:`round`,strokeLinejoin:`round`,strokeWidth:2,d:`M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})})}),(0,T.jsx)(`h3`,{className:`text-lg font-semibold mb-2`,children:`Failed to Load Workflow`}),(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground mb-4`,children:o}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`This may not be a valid workflow entity. Check the file contains a workflow export.`})]})}):!n?.workflow_dump&&!Se.length?(0,T.jsx)(uv,{message:`Initializing workflow...`,description:`Setting up workflow execution environment`}):(0,T.jsxs)(`div`,{className:`workflow-view flex flex-col h-full`,children:[(0,T.jsxs)(`div`,{className:`border-b pb-2 p-4 flex-shrink-0`,children:[(0,T.jsxs)(`div`,{className:`flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 min-w-0`,children:[(0,T.jsx)(`h2`,{className:`font-semibold text-sm truncate`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Tu,{className:`h-4 w-4 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`truncate`,children:e.name||e.id})]})}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>_(!0),className:`h-6 w-6 p-0 flex-shrink-0`,title:`View workflow details`,children:(0,T.jsx)(Jl,{className:`h-4 w-4`})}),e.source!==`in_memory`&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:pe,disabled:x,className:`h-6 w-6 p-0 flex-shrink-0`,title:x?`Reloading...`:`Reload entity code (hot reload)`,children:(0,T.jsx)(uu,{className:`h-4 w-4 ${x?`animate-spin`:``}`})})]}),n&&(0,T.jsxs)(`div`,{className:`flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0`,children:[(0,T.jsxs)(J_,{value:B?.conversation_id||``,onValueChange:_e,disabled:H,children:[(0,T.jsx)(X_,{className:`w-full sm:w-64`,children:(0,T.jsx)(Y_,{placeholder:H?`Loading...`:V.length===0?`No checkpoint storages`:`Select checkpoint storage`,children:B&&(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs`,children:[(0,T.jsx)(`span`,{className:`truncate`,children:B.metadata.name||`Checkpoint Storage ${B.conversation_id.slice(-8)}`}),B.metadata.checkpoint_summary&&B.metadata.checkpoint_summary.count>0&&(0,T.jsxs)(`div`,{className:`flex items-center gap-1 flex-shrink-0`,children:[(0,T.jsx)(ct,{variant:`secondary`,className:`h-4 px-1.5 text-[10px]`,children:B.metadata.checkpoint_summary.count}),B.metadata.checkpoint_summary.has_pending_hil&&(0,T.jsx)(ct,{variant:`secondary`,className:`h-4 px-1.5 text-[10px]`,children:`HIL`})]})]})})}),(0,T.jsx)(Z_,{children:V.map(e=>(0,T.jsx)(Q_,{value:e.conversation_id,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between w-full gap-2`,children:[(0,T.jsx)(`span`,{className:`truncate`,children:e.metadata.name||`Checkpoint Storage ${e.conversation_id.slice(-8)}`}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1 flex-shrink-0`,children:[e.created_at&&(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:new Date(e.created_at*1e3).toLocaleTimeString()}),e.metadata.checkpoint_summary&&e.metadata.checkpoint_summary.count>0&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(ct,{variant:`secondary`,className:`h-4 px-1.5 text-[10px]`,children:e.metadata.checkpoint_summary.count}),e.metadata.checkpoint_summary.has_pending_hil&&(0,T.jsx)(ct,{variant:`secondary`,className:`h-4 px-1.5 text-[10px]`,children:`HIL`})]})]})]})},e.conversation_id))})]}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>b(!0),disabled:!B,className:`h-9 w-9 p-0 flex-shrink-0`,title:`View checkpoint details`,children:(0,T.jsx)(Jl,{className:`h-4 w-4`})}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:ye,disabled:!B||H,className:`h-9 w-9 p-0`,title:`Delete current session`,children:(0,T.jsx)(xu,{className:`h-4 w-4 `})}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:ve,disabled:H,className:`h-9 px-3`,title:`New session`,children:(0,T.jsx)(lu,{className:`h-4 w-4`})}),C&&(0,T.jsx)(Iv,{inputSchema:n.input_schema,onRun:Ee,onCancel:M,isSubmitting:u,isCancelling:A,workflowState:u?`running`:Se.length>0?`completed`:`ready`,checkpoints:O,showCheckpoints:!1})]})]}),e.description&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:e.description})]}),P.length>0&&(0,T.jsx)(`div`,{className:`bg-orange-100 dark:bg-orange-950/30 border-b border-orange-300 dark:border-orange-800 px-4 py-2`,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Ol,{className:`w-4 h-4 text-orange-600 dark:text-orange-400`}),(0,T.jsxs)(`span`,{className:`text-sm font-medium text-orange-900 dark:text-orange-100`,children:[`Workflow is waiting for your input (`,P.length,` `,`request`,P.length>1?`s`:``,`)`]})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[P.length>1&&(0,T.jsxs)(K,{size:`sm`,onClick:Oe,disabled:!De()||u,className:`gap-1`,children:[(0,T.jsx)(hu,{className:`w-3.5 h-3.5`}),`Submit All`]}),(0,T.jsx)(K,{size:`sm`,variant:`ghost`,onClick:()=>{document.querySelector(`[data-hil-form]`)?.scrollIntoView({behavior:`smooth`,block:`center`})},className:`text-orange-700 hover:text-orange-900 dark:text-orange-400 dark:hover:text-orange-200`,children:`Jump to input →`})]})]})}),(0,T.jsxs)(`div`,{className:`flex-1 min-h-0 flex gap-0`,children:[(0,T.jsx)(`div`,{className:`flex-1 min-w-0 transition-all duration-300`,children:n?.workflow_dump&&(0,T.jsx)(sj,{workflowDump:n.workflow_dump,events:xe,isStreaming:u,onNodeSelect:be,className:`h-full`,viewOptions:ce,onToggleViewOption:fe,layoutDirection:ue,onLayoutDirectionChange:de,timelineVisible:!0})}),(0,T.jsx)(`div`,{className:`flex-shrink-0 overflow-hidden transition-all duration-300 ease-out border-l`,style:{width:C?`2.5rem`:`28rem`},children:C?(0,T.jsxs)(`div`,{className:`h-full w-10 bg-background flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors`,onClick:()=>w(!1),title:`Expand timeline`,children:[(0,T.jsx)(`div`,{className:`h-8 w-8 flex items-center justify-center`,children:(0,T.jsx)(El,{className:`h-4 w-4 text-muted-foreground`})}),(0,T.jsxs)(`div`,{className:`flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none`,children:[(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground select-none`,style:{writingMode:`vertical-rl`,transform:`rotate(180deg)`},children:`Execution Timeline`}),xe.length>0&&(0,T.jsx)(`div`,{className:`bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center ${u?`animate-pulse`:``}`,style:{fontSize:`10px`},children:xe.length})]})]}):(0,T.jsxs)(`div`,{className:`w-[28rem] h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between p-2 border-b`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`h3`,{className:`text-sm font-medium`,children:`Execution Timeline`}),xe.length>0&&(0,T.jsx)(`div`,{className:`bg-primary text-primary-foreground rounded-full px-2 h-5 flex items-center justify-center ${u?`animate-pulse`:``}`,style:{fontSize:`11px`,minWidth:`20px`},children:xe.length})]}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>w(!0),className:`h-8 w-8 p-0`,title:`Minimize timeline`,children:(0,T.jsx)(Z,{className:`h-4 w-4`})})]}),(0,T.jsx)(`div`,{className:`flex-1 min-h-0 overflow-hidden`,children:(0,T.jsx)(gj,{events:xe,itemOutputs:L.current,currentExecutorId:Ce[Ce.length-1]||null,isStreaming:u,onExecutorClick:be,selectedExecutorId:m,workflowResult:E,pendingHilRequests:P,hilResponses:F,onHilResponseChange:(e,t)=>{I(n=>({...n,[e]:t}))},onHilSubmit:Oe,isSubmittingHil:u,inputSchema:n?.input_schema,onRun:(e,t)=>{Ee(e,t)},onCancel:M,isCancelling:A,workflowState:u?`running`:f?`cancelled`:Se.length>0?`completed`:`ready`,wasCancelled:f,checkpoints:O})})]})})]}),(0,T.jsx)(lj,{workflow:e,open:g,onOpenChange:_}),(0,T.jsx)(uj,{session:B||null,checkpoints:O,open:v,onOpenChange:b})]})}function yj({message:e,type:t=`info`,duration:n=4e3,onClose:r}){let[i,a]=(0,y.useState)(!0);(0,y.useEffect)(()=>{let e=setTimeout(()=>{a(!1),setTimeout(r,300)},n);return()=>clearTimeout(e)},[n,r]);let o={info:`bg-primary/10 border-primary/20`,success:`bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800`,warning:`bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800`,error:`bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800`}[t],s={info:`text-primary`,success:`text-green-800 dark:text-green-200`,warning:`text-orange-800 dark:text-orange-200`,error:`text-red-800 dark:text-red-200`}[t];return(0,T.jsxs)(`div`,{className:`fixed top-4 right-4 z-50 flex items-start gap-3 p-4 rounded-lg border shadow-lg max-w-md transition-all duration-300 ${i?`opacity-100 translate-x-0`:`opacity-0 translate-x-4`} ${o}`,children:[(0,T.jsx)(`p`,{className:`text-sm flex-1 ${s}`,children:e}),(0,T.jsx)(`button`,{onClick:()=>{a(!1),setTimeout(r,300)},className:`flex-shrink-0 hover:opacity-70 transition-opacity ${s}`,children:(0,T.jsx)(Du,{className:`h-4 w-4`})})]})}function bj({toasts:e,onRemove:t}){return(0,T.jsx)(`div`,{className:`fixed top-4 right-4 z-50 flex flex-col gap-2`,children:e.map(e=>(0,T.jsx)(yj,{message:e.message,type:e.type,duration:e.duration,onClose:()=>t(e.id)},e.id))})}Dd();var xj=50;function Sj(){let[e,t]=(0,y.useState)(!1),[n,r]=(0,y.useState)(``),[i,a]=(0,y.useState)(!1),[o,s]=(0,y.useState)(``),c=(0,y.useRef)(null),l=(0,y.useRef)(0),u=Q(e=>e.agents),d=Q(e=>e.workflows),f=Q(e=>e.entities),p=Q(e=>e.selectedAgent),m=Q(e=>e.azureDeploymentEnabled),h=Q(e=>e.isLoadingEntities),g=Q(e=>e.entityError),_=Q(e=>e.oaiMode),v=Q(e=>e.uiMode),b=Q(e=>e.setAgents),x=Q(e=>e.setWorkflows),S=Q(e=>e.setEntities),C=Q(e=>e.selectEntity),w=Q(e=>e.updateAgent),E=Q(e=>e.updateWorkflow),D=Q(e=>e.setIsLoadingEntities),O=Q(e=>e.setEntityError),k=Q(e=>e.showDebugPanel),A=Q(e=>e.debugPanelMinimized),j=Q(e=>e.debugPanelWidth),M=Q(e=>e.debugEvents),N=Q(e=>e.isResizing),P=Q(e=>e.setShowDebugPanel),ee=Q(e=>e.setDebugPanelMinimized),F=Q(e=>e.setDebugPanelWidth),I=Q(e=>e.addDebugEvent),L=Q(e=>e.clearDebugEvents),R=Q(e=>e.setIsResizing),z=Q(e=>e.showAboutModal),B=Q(e=>e.showGallery),V=Q(e=>e.showDeployModal),H=Q(e=>e.showEntityNotFoundToast),U=Q(e=>e.setShowAboutModal),te=Q(e=>e.setShowGallery),ne=Q(e=>e.setShowDeployModal),re=Q(e=>e.setShowEntityNotFoundToast),ie=Q(e=>e.toasts),ae=Q(e=>e.addToast),oe=Q(e=>e.removeToast);(0,y.useEffect)(()=>{(async()=>{try{let e=await Lh.getMeta();if(e.auth_required&&(t(!0),!Lh.getAuthToken())){O(`UNAUTHORIZED`),D(!1);return}Q.getState().setServerMeta({uiMode:e.ui_mode,runtime:e.runtime,capabilities:e.capabilities,authRequired:e.auth_required,version:e.version});let{entities:n,agents:r,workflows:i}=await Lh.getEntities();S(n),b(r),x(i);let a=new URLSearchParams(window.location.search).get(`entity_id`),o;if(a&&(o=n.find(e=>e.id===a),o||re(!0)),!o)if(o=n.length>0?n[0]:void 0,o){let e=new URL(window.location.href);e.searchParams.set(`entity_id`,o.id),window.history.replaceState({},``,e)}else{let e=new URL(window.location.href);e.searchParams.delete(`entity_id`),window.history.replaceState({},``,e)}if(o&&(C(o),o.metadata?.lazy_loaded===!1))try{o.type===`agent`?w(await Lh.getAgentInfo(o.id)):E(await Lh.getWorkflowInfo(o.id))}catch(e){console.error(`Failed to load full info for first entity ${o.id}:`,e);let t=e instanceof Error?e.message:String(e);ae({type:`error`,message:`Failed to load "${o.id}": ${t}`})}D(!1)}catch(e){console.error(`Failed to load agents/workflows:`,e);let n=e instanceof Error?e.message:`Failed to load data`;n===`UNAUTHORIZED`&&t(!0),O(n),D(!1)}})()},[b,x,C,w,E,D,O,re,ae,S]);let se=(0,y.useCallback)(async()=>{if(n.trim()){a(!0),s(``);try{Lh.setAuthToken(n.trim()),await Lh.getEntities(),window.location.reload()}catch(e){Lh.clearAuthToken(),a(!1);let t=e instanceof Error?e.message:`Unknown error`;s(t===`UNAUTHORIZED`?`Invalid token. Please check and try again.`:`Failed to connect: ${t}`)}}},[n]);(0,y.useEffect)(()=>{if(_.enabled&&p?.type===`workflow`){let e=u[0];e&&C(e)}},[_.enabled,p,u,C]);let ce=(0,y.useCallback)(e=>{e.preventDefault(),R(!0);let t=e.clientX,n=j,r=e=>{let r=t-e.clientX;F(Math.max(200,Math.min(window.innerWidth*.5,n+r)))},i=()=>{R(!1),document.removeEventListener(`mousemove`,r),document.removeEventListener(`mouseup`,i)};document.addEventListener(`mousemove`,r),document.addEventListener(`mouseup`,i)},[j]),le=(0,y.useCallback)(async e=>{if(C(e),e.metadata?.lazy_loaded===!1)try{e.type===`agent`?w(await Lh.getAgentInfo(e.id)):E(await Lh.getWorkflowInfo(e.id))}catch(t){console.error(`Failed to load full info for ${e.id}:`,t);let n=t instanceof Error?t.message:String(t);ae({type:`error`,message:`Failed to load "${e.id}": ${n}`})}},[C,w,E,ae]),ue=(0,y.useCallback)(()=>{let e=c.current;e&&(c.current=null,l.current=performance.now(),I(e))},[I]),de=(0,y.useCallback)(e=>{if(e===`clear`){c.current=null,L();return}if(e.type===`response.output_text.delta`&&`delta`in e&&typeof e.delta==`string`&&e.delta.length>0){let t=c.current;t!==null&&t.item_id===e.item_id&&t.output_index===e.output_index&&t.content_index===e.content_index&&t?c.current={...t,delta:t.delta+e.delta,sequence_number:e.sequence_number??t.sequence_number}:(ue(),c.current={...e}),performance.now()-l.current>=xj&&ue();return}ue(),I(e)},[I,L,ue]);if(h)return(0,T.jsxs)(`div`,{className:`h-screen flex flex-col bg-background`,children:[(0,T.jsxs)(`header`,{className:`flex h-14 items-center gap-4 border-b px-4`,children:[(0,T.jsx)(`div`,{className:`w-64 h-9 bg-muted animate-pulse rounded-md`}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 ml-auto`,children:[(0,T.jsx)(`div`,{className:`w-8 h-8 bg-muted animate-pulse rounded-md`}),(0,T.jsx)(`div`,{className:`w-8 h-8 bg-muted animate-pulse rounded-md`})]})]}),(0,T.jsx)(`div`,{className:`flex-1 flex items-center justify-center`,children:(0,T.jsxs)(`div`,{className:`text-center`,children:[(0,T.jsx)(`div`,{className:`text-lg font-medium`,children:`Initializing DevUI...`}),(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground mt-2`,children:`Loading agents and workflows from your configuration`})]})})]});if(g){let t=Lh.getBaseUrl(),a=g===`UNAUTHORIZED`||e,s=`8080`;try{if(t){let e=new URL(t);s=e.port||(e.protocol===`https:`?`443`:`80`)}}catch{}return(0,T.jsxs)(`div`,{className:`h-screen flex flex-col bg-background`,children:[(0,T.jsx)(Od,{agents:[],workflows:[],entities:[],selectedItem:void 0,onSelect:()=>{},isLoading:!1,onSettingsClick:()=>U(!0)}),(0,T.jsx)(`div`,{className:`flex-1 flex items-center justify-center p-8`,children:(0,T.jsxs)(`div`,{className:`text-center space-y-6 max-w-2xl`,children:[(0,T.jsx)(`div`,{className:`flex justify-center`,children:(0,T.jsx)(`div`,{className:`rounded-full bg-muted p-4 animate-pulse`,children:a?(0,T.jsx)($l,{className:`h-12 w-12 text-muted-foreground`}):(0,T.jsx)(gu,{className:`h-12 w-12 text-muted-foreground`})})}),(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(`h2`,{className:`text-2xl font-semibold text-foreground`,children:a?`Authentication Required`:`Can't Connect to Backend`}),(0,T.jsx)(`p`,{className:`text-muted-foreground text-base`,children:a?`This backend requires a bearer token to access.`:`No worries! Just start the DevUI backend server and you'll be good to go.`})]}),a?(0,T.jsxs)(`div`,{className:`space-y-4`,children:[(0,T.jsxs)(`div`,{className:`text-left bg-muted/50 rounded-lg p-4 space-y-3`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium text-foreground`,children:`Enter Authentication Token`}),(0,T.jsx)(Um,{type:`password`,placeholder:`Paste token from server logs`,value:n,onChange:e=>r(e.target.value),onKeyDown:e=>{e.key===`Enter`&&!i&&se()},disabled:i,className:`font-mono text-sm`}),(0,T.jsx)(K,{onClick:se,disabled:!n.trim()||i,className:`w-full`,children:i?`Verifying...`:`Connect`}),o&&(0,T.jsx)(`p`,{className:`text-sm text-red-600 dark:text-red-400 text-center`,children:o})]}),(0,T.jsxs)(`details`,{className:`text-left group`,children:[(0,T.jsxs)(`summary`,{className:`text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2 justify-center`,children:[(0,T.jsx)(X,{className:`h-4 w-4 transition-transform group-open:rotate-180`}),`Where do I find the token?`]}),(0,T.jsxs)(`div`,{className:`mt-3 text-left bg-muted/30 rounded-lg p-3 space-y-2`,children:[(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Look for this in your DevUI server startup logs:`}),(0,T.jsxs)(`code`,{className:`block bg-background px-2 py-1 rounded text-xs font-mono text-foreground`,children:[`🔑 DEV TOKEN (localhost only, shown once):`,(0,T.jsx)(`br`,{}),`\xA0\xA0 abc123xyz...`]})]})]})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{className:`space-y-3`,children:[(0,T.jsxs)(`div`,{className:`text-left bg-muted/50 rounded-lg p-4 space-y-3`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium text-foreground`,children:`Start the backend:`}),(0,T.jsxs)(`code`,{className:`block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground`,children:[`devui ./agents --port `,s]}),(0,T.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Or launch programmatically with`,` `,(0,T.jsx)(`code`,{className:`text-xs`,children:`serve(entities=[agent])`})]})]}),(0,T.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Default:`,` `,(0,T.jsx)(`span`,{className:`font-mono`,children:t})]})]}),g&&(0,T.jsxs)(`details`,{className:`text-left group`,children:[(0,T.jsxs)(`summary`,{className:`text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2`,children:[(0,T.jsx)(X,{className:`h-4 w-4 transition-transform group-open:rotate-180`}),`Error details`]}),(0,T.jsx)(`p`,{className:`mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border`,children:g})]}),(0,T.jsx)(K,{onClick:()=>window.location.reload(),variant:`default`,className:`mt-2`,children:`Retry Connection`})]})]})}),(0,T.jsx)(lh,{open:z,onOpenChange:U})]})}return(0,T.jsxs)(`div`,{className:`h-screen flex flex-col bg-background max-h-screen`,children:[(0,T.jsx)(Od,{agents:u,workflows:d,entities:f,selectedItem:p,onSelect:le,onBrowseGallery:()=>te(!0),isLoading:h,onSettingsClick:()=>U(!0)}),(0,T.jsx)(`div`,{className:`flex flex-1 overflow-hidden`,children:B?(0,T.jsx)(`div`,{className:`flex-1 w-full`,children:(0,T.jsx)(tg,{variant:`route`,onClose:()=>te(!1),hasExistingEntities:u.length>0||d.length>0})}):u.length===0&&d.length===0?(0,T.jsx)(tg,{variant:`inline`}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`div`,{className:`flex-1 min-w-0`,children:p?p.type===`agent`?(0,T.jsx)(lv,{selectedAgent:p,onDebugEvent:de}):(0,T.jsx)(vj,{selectedWorkflow:p,onDebugEvent:de}):(0,T.jsx)(`div`,{className:`flex-1 flex items-center justify-center text-muted-foreground`,children:`Select an agent or workflow to get started.`})}),v===`developer`&&k?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`div`,{className:`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${N?`bg-primary/40`:`bg-border hover:bg-primary/20`}`,onMouseDown:ce,children:(0,T.jsx)(`div`,{className:`absolute inset-y-0 -left-2 -right-2 flex items-center justify-center`,children:(0,T.jsx)(`div`,{className:`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${N?`bg-primary shadow-lg shadow-primary/25`:`bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20`}`})})}),(0,T.jsx)(`div`,{className:`flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]`,style:{width:A?`2.5rem`:`${j}px`},children:A?(0,T.jsxs)(`div`,{className:`h-full w-10 bg-background border-l flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors`,onClick:()=>ee(!1),title:`Expand debug panel`,children:[(0,T.jsx)(`div`,{className:`h-8 w-8 flex items-center justify-center`,children:(0,T.jsx)(El,{className:`h-4 w-4 text-muted-foreground`})}),(0,T.jsxs)(`div`,{className:`flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none`,children:[(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground select-none`,style:{writingMode:`vertical-rl`,transform:`rotate(180deg)`},children:`Debug Panel`}),M.length>0&&(0,T.jsx)(`div`,{className:`bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center`,style:{fontSize:`10px`},children:M.length})]})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Fm,{events:M,isStreaming:!1,onMinimize:()=>ee(!0)}),(0,T.jsx)(`div`,{className:`border-t bg-muted/30 px-3 py-2.5 flex-shrink-0`,children:(0,T.jsxs)(K,{onClick:()=>ne(!0),className:`w-full`,variant:`outline`,size:`sm`,children:[(0,T.jsx)(du,{className:`h-3 w-3 mr-2 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`truncate text-xs`,children:m&&p?.deployment_supported?`Deploy to Azure`:`Deployment Guide`})]})})]})})]}):v===`developer`?(0,T.jsx)(`div`,{className:`flex-shrink-0`,children:(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>P(!0),className:`h-full w-10 rounded-none border-l`,title:`Show debug panel`,children:(0,T.jsx)(ou,{className:`h-4 w-4`})})}):null]})}),(0,T.jsx)(lh,{open:z,onOpenChange:U}),(0,T.jsx)(Rh,{open:V,onClose:()=>ne(!1),agentName:p?.name,entity:p}),H&&(0,T.jsx)(yj,{message:`Entity not found. Showing first available entity instead.`,type:`info`,onClose:()=>re(!1)}),(0,T.jsx)(bj,{toasts:ie,onRemove:oe})]})}function Cj({children:e,attribute:t=`class`,defaultTheme:n=`dark`,enableSystem:r=!0,disableTransitionOnChange:i=!0,...a}){return(0,T.jsx)(Gu,{attribute:t,defaultTheme:n,enableSystem:r,disableTransitionOnChange:i,...a,children:e})}Ch(),(0,b.createRoot)(document.getElementById(`root`)).render((0,T.jsx)(y.StrictMode,{children:(0,T.jsx)(Cj,{attribute:`class`,defaultTheme:`dark`,enableSystem:!0,disableTransitionOnChange:!0,children:(0,T.jsx)(Sj,{})})})); \ No newline at end of file +`+t:t);try{let n=e.type===`output_text`?JSON.parse(t):void 0;typeof n==`object`&&n&&(z.current=n)}catch{}}}}if((e.type===`response.output_text.delta`||e.type===`response.refusal.delta`)&&`delta`in e&&e.delta){let t=R.current;t&&(L.current[t]||(L.current[t]=``),L.current[t]+=e.delta)}e.type===`response.completed`&&await he(),e.type===`response.failed`&&await he()}a&&ee(o),d(!1),await he()}catch(e){Th(e)?(console.log(`HIL submission cancelled by user`),p(!0)):console.error(`HIL submission error:`,e),d(!1),N(),await he()}},[e,F,t,B,De,j,N,he]);return i?(0,T.jsx)(mv,{message:`Loading workflow...`,description:`Fetching workflow structure and configuration`}):o?(0,T.jsx)(`div`,{className:`flex items-center justify-center h-full`,children:(0,T.jsxs)(`div`,{className:`text-center max-w-md p-6`,children:[(0,T.jsx)(`div`,{className:`text-red-500 mb-4`,children:(0,T.jsx)(`svg`,{className:`w-16 h-16 mx-auto`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`,children:(0,T.jsx)(`path`,{strokeLinecap:`round`,strokeLinejoin:`round`,strokeWidth:2,d:`M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`})})}),(0,T.jsx)(`h3`,{className:`text-lg font-semibold mb-2`,children:`Failed to Load Workflow`}),(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground mb-4`,children:o}),(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`This may not be a valid workflow entity. Check the file contains a workflow export.`})]})}):!n?.workflow_dump&&!Se.length?(0,T.jsx)(mv,{message:`Initializing workflow...`,description:`Setting up workflow execution environment`}):(0,T.jsxs)(`div`,{className:`workflow-view flex flex-col h-full`,children:[(0,T.jsxs)(`div`,{className:`border-b pb-2 p-4 flex-shrink-0`,children:[(0,T.jsxs)(`div`,{className:`flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2 min-w-0`,children:[(0,T.jsx)(`h2`,{className:`font-semibold text-sm truncate`,children:(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Tu,{className:`h-4 w-4 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`truncate`,children:e.name||e.id})]})}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>_(!0),className:`h-6 w-6 p-0 flex-shrink-0`,title:`View workflow details`,children:(0,T.jsx)(Jl,{className:`h-4 w-4`})}),e.source!==`in_memory`&&(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:pe,disabled:x,className:`h-6 w-6 p-0 flex-shrink-0`,title:x?`Reloading...`:`Reload entity code (hot reload)`,children:(0,T.jsx)(uu,{className:`h-4 w-4 ${x?`animate-spin`:``}`})})]}),n&&(0,T.jsxs)(`div`,{className:`flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0`,children:[(0,T.jsxs)(Q_,{value:B?.conversation_id||``,onValueChange:_e,disabled:H,children:[(0,T.jsx)(ev,{className:`w-full sm:w-64`,children:(0,T.jsx)($_,{placeholder:H?`Loading...`:V.length===0?`No checkpoint storages`:`Select checkpoint storage`,children:B&&(0,T.jsxs)(`div`,{className:`flex items-center gap-2 text-xs`,children:[(0,T.jsx)(`span`,{className:`truncate`,children:B.metadata.name||`Checkpoint Storage ${B.conversation_id.slice(-8)}`}),B.metadata.checkpoint_summary&&B.metadata.checkpoint_summary.count>0&&(0,T.jsxs)(`div`,{className:`flex items-center gap-1 flex-shrink-0`,children:[(0,T.jsx)(ct,{variant:`secondary`,className:`h-4 px-1.5 text-[10px]`,children:B.metadata.checkpoint_summary.count}),B.metadata.checkpoint_summary.has_pending_hil&&(0,T.jsx)(ct,{variant:`secondary`,className:`h-4 px-1.5 text-[10px]`,children:`HIL`})]})]})})}),(0,T.jsx)(tv,{children:V.map(e=>(0,T.jsx)(nv,{value:e.conversation_id,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between w-full gap-2`,children:[(0,T.jsx)(`span`,{className:`truncate`,children:e.metadata.name||`Checkpoint Storage ${e.conversation_id.slice(-8)}`}),(0,T.jsxs)(`div`,{className:`flex items-center gap-1 flex-shrink-0`,children:[e.created_at&&(0,T.jsx)(`span`,{className:`text-xs text-muted-foreground`,children:new Date(e.created_at*1e3).toLocaleTimeString()}),e.metadata.checkpoint_summary&&e.metadata.checkpoint_summary.count>0&&(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(ct,{variant:`secondary`,className:`h-4 px-1.5 text-[10px]`,children:e.metadata.checkpoint_summary.count}),e.metadata.checkpoint_summary.has_pending_hil&&(0,T.jsx)(ct,{variant:`secondary`,className:`h-4 px-1.5 text-[10px]`,children:`HIL`})]})]})]})},e.conversation_id))})]}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>b(!0),disabled:!B,className:`h-9 w-9 p-0 flex-shrink-0`,title:`View checkpoint details`,children:(0,T.jsx)(Jl,{className:`h-4 w-4`})}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:ye,disabled:!B||H,className:`h-9 w-9 p-0`,title:`Delete current session`,children:(0,T.jsx)(xu,{className:`h-4 w-4 `})}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:ve,disabled:H,className:`h-9 px-3`,title:`New session`,children:(0,T.jsx)(lu,{className:`h-4 w-4`})}),C&&(0,T.jsx)(Bv,{inputSchema:n.input_schema,onRun:Ee,onCancel:M,isSubmitting:u,isCancelling:A,workflowState:u?`running`:Se.length>0?`completed`:`ready`,checkpoints:O,showCheckpoints:!1})]})]}),e.description&&(0,T.jsx)(`p`,{className:`text-sm text-muted-foreground`,children:e.description})]}),P.length>0&&(0,T.jsx)(`div`,{className:`bg-orange-100 dark:bg-orange-950/30 border-b border-orange-300 dark:border-orange-800 px-4 py-2`,children:(0,T.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(Ol,{className:`w-4 h-4 text-orange-600 dark:text-orange-400`}),(0,T.jsxs)(`span`,{className:`text-sm font-medium text-orange-900 dark:text-orange-100`,children:[`Workflow is waiting for your input (`,P.length,` `,`request`,P.length>1?`s`:``,`)`]})]}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[P.length>1&&(0,T.jsxs)(K,{size:`sm`,onClick:Oe,disabled:!De()||u,className:`gap-1`,children:[(0,T.jsx)(hu,{className:`w-3.5 h-3.5`}),`Submit All`]}),(0,T.jsx)(K,{size:`sm`,variant:`ghost`,onClick:()=>{document.querySelector(`[data-hil-form]`)?.scrollIntoView({behavior:`smooth`,block:`center`})},className:`text-orange-700 hover:text-orange-900 dark:text-orange-400 dark:hover:text-orange-200`,children:`Jump to input →`})]})]})}),(0,T.jsxs)(`div`,{className:`flex-1 min-h-0 flex gap-0`,children:[(0,T.jsx)(`div`,{className:`flex-1 min-w-0 transition-all duration-300`,children:n?.workflow_dump&&(0,T.jsx)(dj,{workflowDump:n.workflow_dump,events:xe,isStreaming:u,onNodeSelect:be,className:`h-full`,viewOptions:ce,onToggleViewOption:fe,layoutDirection:ue,onLayoutDirectionChange:de,timelineVisible:!0})}),(0,T.jsx)(`div`,{className:`flex-shrink-0 overflow-hidden transition-all duration-300 ease-out border-l`,style:{width:C?`2.5rem`:`28rem`},children:C?(0,T.jsxs)(`div`,{className:`h-full w-10 bg-background flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors`,onClick:()=>w(!1),title:`Expand timeline`,children:[(0,T.jsx)(`div`,{className:`h-8 w-8 flex items-center justify-center`,children:(0,T.jsx)(El,{className:`h-4 w-4 text-muted-foreground`})}),(0,T.jsxs)(`div`,{className:`flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none`,children:[(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground select-none`,style:{writingMode:`vertical-rl`,transform:`rotate(180deg)`},children:`Execution Timeline`}),xe.length>0&&(0,T.jsx)(`div`,{className:`bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center ${u?`animate-pulse`:``}`,style:{fontSize:`10px`},children:xe.length})]})]}):(0,T.jsxs)(`div`,{className:`w-[28rem] h-full flex flex-col`,children:[(0,T.jsxs)(`div`,{className:`flex items-center justify-between p-2 border-b`,children:[(0,T.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,T.jsx)(`h3`,{className:`text-sm font-medium`,children:`Execution Timeline`}),xe.length>0&&(0,T.jsx)(`div`,{className:`bg-primary text-primary-foreground rounded-full px-2 h-5 flex items-center justify-center ${u?`animate-pulse`:``}`,style:{fontSize:`11px`,minWidth:`20px`},children:xe.length})]}),(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>w(!0),className:`h-8 w-8 p-0`,title:`Minimize timeline`,children:(0,T.jsx)(Z,{className:`h-4 w-4`})})]}),(0,T.jsx)(`div`,{className:`flex-1 min-h-0 overflow-hidden`,children:(0,T.jsx)(bj,{events:xe,itemOutputs:L.current,currentExecutorId:Ce[Ce.length-1]||null,isStreaming:u,onExecutorClick:be,selectedExecutorId:m,workflowResult:E,pendingHilRequests:P,hilResponses:F,onHilResponseChange:(e,t)=>{I(n=>({...n,[e]:t}))},onHilSubmit:Oe,isSubmittingHil:u,inputSchema:n?.input_schema,onRun:(e,t)=>{Ee(e,t)},onCancel:M,isCancelling:A,workflowState:u?`running`:f?`cancelled`:Se.length>0?`completed`:`ready`,wasCancelled:f,checkpoints:O})})]})})]}),(0,T.jsx)(pj,{workflow:e,open:g,onOpenChange:_}),(0,T.jsx)(mj,{session:B||null,checkpoints:O,open:v,onOpenChange:b})]})}function Cj({message:e,type:t=`info`,duration:n=4e3,onClose:r}){let[i,a]=(0,y.useState)(!0);(0,y.useEffect)(()=>{let e=setTimeout(()=>{a(!1),setTimeout(r,300)},n);return()=>clearTimeout(e)},[n,r]);let o={info:`bg-primary/10 border-primary/20`,success:`bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800`,warning:`bg-orange-50 dark:bg-orange-950 border-orange-200 dark:border-orange-800`,error:`bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800`}[t],s={info:`text-primary`,success:`text-green-800 dark:text-green-200`,warning:`text-orange-800 dark:text-orange-200`,error:`text-red-800 dark:text-red-200`}[t];return(0,T.jsxs)(`div`,{className:`fixed top-4 right-4 z-50 flex items-start gap-3 p-4 rounded-lg border shadow-lg max-w-md transition-all duration-300 ${i?`opacity-100 translate-x-0`:`opacity-0 translate-x-4`} ${o}`,children:[(0,T.jsx)(`p`,{className:`text-sm flex-1 ${s}`,children:e}),(0,T.jsx)(`button`,{onClick:()=>{a(!1),setTimeout(r,300)},className:`flex-shrink-0 hover:opacity-70 transition-opacity ${s}`,children:(0,T.jsx)(Du,{className:`h-4 w-4`})})]})}function wj({toasts:e,onRemove:t}){return(0,T.jsx)(`div`,{className:`fixed top-4 right-4 z-50 flex flex-col gap-2`,children:e.map(e=>(0,T.jsx)(Cj,{message:e.message,type:e.type,duration:e.duration,onClose:()=>t(e.id)},e.id))})}Dd();var Tj=50;function Ej(){let[e,t]=(0,y.useState)(!1),[n,r]=(0,y.useState)(``),[i,a]=(0,y.useState)(!1),[o,s]=(0,y.useState)(``),c=(0,y.useRef)(null),l=(0,y.useRef)(0),u=Q(e=>e.agents),d=Q(e=>e.workflows),f=Q(e=>e.entities),p=Q(e=>e.selectedAgent),m=Q(e=>e.azureDeploymentEnabled),h=Q(e=>e.isLoadingEntities),g=Q(e=>e.entityError),_=Q(e=>e.oaiMode),v=Q(e=>e.uiMode),b=Q(e=>e.setAgents),x=Q(e=>e.setWorkflows),S=Q(e=>e.setEntities),C=Q(e=>e.selectEntity),w=Q(e=>e.updateAgent),E=Q(e=>e.updateWorkflow),D=Q(e=>e.setIsLoadingEntities),O=Q(e=>e.setEntityError),k=Q(e=>e.showDebugPanel),A=Q(e=>e.debugPanelMinimized),j=Q(e=>e.debugPanelWidth),M=Q(e=>e.debugEvents),N=Q(e=>e.isResizing),P=Q(e=>e.setShowDebugPanel),ee=Q(e=>e.setDebugPanelMinimized),F=Q(e=>e.setDebugPanelWidth),I=Q(e=>e.addDebugEvent),L=Q(e=>e.clearDebugEvents),R=Q(e=>e.setIsResizing),z=Q(e=>e.showAboutModal),B=Q(e=>e.showGallery),V=Q(e=>e.showDeployModal),H=Q(e=>e.showEntityNotFoundToast),U=Q(e=>e.setShowAboutModal),te=Q(e=>e.setShowGallery),ne=Q(e=>e.setShowDeployModal),re=Q(e=>e.setShowEntityNotFoundToast),ie=Q(e=>e.toasts),ae=Q(e=>e.addToast),oe=Q(e=>e.removeToast);(0,y.useEffect)(()=>{(async()=>{try{let e=await Vh.getMeta();if(e.auth_required&&(t(!0),!Vh.getAuthToken())){O(`UNAUTHORIZED`),D(!1);return}let n={instrumentation:e.capabilities.instrumentation??e.capabilities.tracing??!1,trace_retrieval:e.capabilities.trace_retrieval??!1,openai_proxy:e.capabilities.openai_proxy,deployment:e.capabilities.deployment};Q.getState().setServerMeta({uiMode:e.ui_mode,runtime:e.runtime,capabilities:n,authRequired:e.auth_required,version:e.version});let{entities:r,agents:i,workflows:a}=await Vh.getEntities();S(r),b(i),x(a);let o=new URLSearchParams(window.location.search).get(`entity_id`),s;if(o&&(s=r.find(e=>e.id===o),s||re(!0)),!s)if(s=r.length>0?r[0]:void 0,s){let e=new URL(window.location.href);e.searchParams.set(`entity_id`,s.id),window.history.replaceState({},``,e)}else{let e=new URL(window.location.href);e.searchParams.delete(`entity_id`),window.history.replaceState({},``,e)}if(s&&(C(s),s.metadata?.lazy_loaded===!1))try{s.type===`agent`?w(await Vh.getAgentInfo(s.id)):E(await Vh.getWorkflowInfo(s.id))}catch(e){console.error(`Failed to load full info for first entity ${s.id}:`,e);let t=e instanceof Error?e.message:String(e);ae({type:`error`,message:`Failed to load "${s.id}": ${t}`})}D(!1)}catch(e){console.error(`Failed to load agents/workflows:`,e);let n=e instanceof Error?e.message:`Failed to load data`;n===`UNAUTHORIZED`&&t(!0),O(n),D(!1)}})()},[b,x,C,w,E,D,O,re,ae,S]);let se=(0,y.useCallback)(async()=>{if(n.trim()){a(!0),s(``);try{Vh.setAuthToken(n.trim()),await Vh.getEntities(),window.location.reload()}catch(e){Vh.clearAuthToken(),a(!1);let t=e instanceof Error?e.message:`Unknown error`;s(t===`UNAUTHORIZED`?`Invalid token. Please check and try again.`:`Failed to connect: ${t}`)}}},[n]);(0,y.useEffect)(()=>{if(_.enabled&&p?.type===`workflow`){let e=u[0];e&&C(e)}},[_.enabled,p,u,C]);let ce=(0,y.useCallback)(e=>{e.preventDefault(),R(!0);let t=e.clientX,n=j,r=e=>{let r=t-e.clientX;F(Math.max(200,Math.min(window.innerWidth*.5,n+r)))},i=()=>{R(!1),document.removeEventListener(`mousemove`,r),document.removeEventListener(`mouseup`,i)};document.addEventListener(`mousemove`,r),document.addEventListener(`mouseup`,i)},[j]),le=(0,y.useCallback)(async e=>{if(C(e),e.metadata?.lazy_loaded===!1)try{e.type===`agent`?w(await Vh.getAgentInfo(e.id)):E(await Vh.getWorkflowInfo(e.id))}catch(t){console.error(`Failed to load full info for ${e.id}:`,t);let n=t instanceof Error?t.message:String(t);ae({type:`error`,message:`Failed to load "${e.id}": ${n}`})}},[C,w,E,ae]),ue=(0,y.useCallback)(()=>{let e=c.current;e&&(c.current=null,l.current=performance.now(),I(e))},[I]),de=(0,y.useCallback)(e=>{if(e===`clear`){c.current=null,L();return}if(e.type===`response.output_text.delta`&&`delta`in e&&typeof e.delta==`string`&&e.delta.length>0){let t=c.current;t!==null&&t.item_id===e.item_id&&t.output_index===e.output_index&&t.content_index===e.content_index&&t?c.current={...t,delta:t.delta+e.delta,sequence_number:e.sequence_number??t.sequence_number}:(ue(),c.current={...e}),performance.now()-l.current>=Tj&&ue();return}ue(),I(e)},[I,L,ue]);if(h)return(0,T.jsxs)(`div`,{className:`h-screen flex flex-col bg-background`,children:[(0,T.jsxs)(`header`,{className:`flex h-14 items-center gap-4 border-b px-4`,children:[(0,T.jsx)(`div`,{className:`w-64 h-9 bg-muted animate-pulse rounded-md`}),(0,T.jsxs)(`div`,{className:`flex items-center gap-2 ml-auto`,children:[(0,T.jsx)(`div`,{className:`w-8 h-8 bg-muted animate-pulse rounded-md`}),(0,T.jsx)(`div`,{className:`w-8 h-8 bg-muted animate-pulse rounded-md`})]})]}),(0,T.jsx)(`div`,{className:`flex-1 flex items-center justify-center`,children:(0,T.jsxs)(`div`,{className:`text-center`,children:[(0,T.jsx)(`div`,{className:`text-lg font-medium`,children:`Initializing DevUI...`}),(0,T.jsx)(`div`,{className:`text-sm text-muted-foreground mt-2`,children:`Loading agents and workflows from your configuration`})]})})]});if(g){let t=Vh.getBaseUrl(),a=g===`UNAUTHORIZED`||e,s=`8080`;try{if(t){let e=new URL(t);s=e.port||(e.protocol===`https:`?`443`:`80`)}}catch{}return(0,T.jsxs)(`div`,{className:`h-screen flex flex-col bg-background`,children:[(0,T.jsx)(Od,{agents:[],workflows:[],entities:[],selectedItem:void 0,onSelect:()=>{},isLoading:!1,onSettingsClick:()=>U(!0)}),(0,T.jsx)(`div`,{className:`flex-1 flex items-center justify-center p-8`,children:(0,T.jsxs)(`div`,{className:`text-center space-y-6 max-w-2xl`,children:[(0,T.jsx)(`div`,{className:`flex justify-center`,children:(0,T.jsx)(`div`,{className:`rounded-full bg-muted p-4 animate-pulse`,children:a?(0,T.jsx)($l,{className:`h-12 w-12 text-muted-foreground`}):(0,T.jsx)(gu,{className:`h-12 w-12 text-muted-foreground`})})}),(0,T.jsxs)(`div`,{className:`space-y-2`,children:[(0,T.jsx)(`h2`,{className:`text-2xl font-semibold text-foreground`,children:a?`Authentication Required`:`Can't Connect to Backend`}),(0,T.jsx)(`p`,{className:`text-muted-foreground text-base`,children:a?`This backend requires a bearer token to access.`:`No worries! Just start the DevUI backend server and you'll be good to go.`})]}),a?(0,T.jsxs)(`div`,{className:`space-y-4`,children:[(0,T.jsxs)(`div`,{className:`text-left bg-muted/50 rounded-lg p-4 space-y-3`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium text-foreground`,children:`Enter Authentication Token`}),(0,T.jsx)(Um,{type:`password`,placeholder:`Paste token from server logs`,value:n,onChange:e=>r(e.target.value),onKeyDown:e=>{e.key===`Enter`&&!i&&se()},disabled:i,className:`font-mono text-sm`}),(0,T.jsx)(K,{onClick:se,disabled:!n.trim()||i,className:`w-full`,children:i?`Verifying...`:`Connect`}),o&&(0,T.jsx)(`p`,{className:`text-sm text-red-600 dark:text-red-400 text-center`,children:o})]}),(0,T.jsxs)(`details`,{className:`text-left group`,children:[(0,T.jsxs)(`summary`,{className:`text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2 justify-center`,children:[(0,T.jsx)(X,{className:`h-4 w-4 transition-transform group-open:rotate-180`}),`Where do I find the token?`]}),(0,T.jsxs)(`div`,{className:`mt-3 text-left bg-muted/30 rounded-lg p-3 space-y-2`,children:[(0,T.jsx)(`p`,{className:`text-xs text-muted-foreground`,children:`Look for this in your DevUI server startup logs:`}),(0,T.jsxs)(`code`,{className:`block bg-background px-2 py-1 rounded text-xs font-mono text-foreground`,children:[`🔑 DEV TOKEN (localhost only, shown once):`,(0,T.jsx)(`br`,{}),`\xA0\xA0 abc123xyz...`]})]})]})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsxs)(`div`,{className:`space-y-3`,children:[(0,T.jsxs)(`div`,{className:`text-left bg-muted/50 rounded-lg p-4 space-y-3`,children:[(0,T.jsx)(`p`,{className:`text-sm font-medium text-foreground`,children:`Start the backend:`}),(0,T.jsxs)(`code`,{className:`block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground`,children:[`devui ./agents --port `,s]}),(0,T.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Or launch programmatically with`,` `,(0,T.jsx)(`code`,{className:`text-xs`,children:`serve(entities=[agent])`})]})]}),(0,T.jsxs)(`p`,{className:`text-xs text-muted-foreground`,children:[`Default:`,` `,(0,T.jsx)(`span`,{className:`font-mono`,children:t})]})]}),g&&(0,T.jsxs)(`details`,{className:`text-left group`,children:[(0,T.jsxs)(`summary`,{className:`text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2`,children:[(0,T.jsx)(X,{className:`h-4 w-4 transition-transform group-open:rotate-180`}),`Error details`]}),(0,T.jsx)(`p`,{className:`mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border`,children:g})]}),(0,T.jsx)(K,{onClick:()=>window.location.reload(),variant:`default`,className:`mt-2`,children:`Retry Connection`})]})]})}),(0,T.jsx)(lh,{open:z,onOpenChange:U})]})}return(0,T.jsxs)(`div`,{className:`h-screen flex flex-col bg-background max-h-screen`,children:[(0,T.jsx)(Od,{agents:u,workflows:d,entities:f,selectedItem:p,onSelect:le,onBrowseGallery:()=>te(!0),isLoading:h,onSettingsClick:()=>U(!0)}),(0,T.jsx)(`div`,{className:`flex flex-1 overflow-hidden`,children:B?(0,T.jsx)(`div`,{className:`flex-1 w-full`,children:(0,T.jsx)(ag,{variant:`route`,onClose:()=>te(!1),hasExistingEntities:u.length>0||d.length>0})}):u.length===0&&d.length===0?(0,T.jsx)(ag,{variant:`inline`}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`div`,{className:`flex-1 min-w-0`,children:p?p.type===`agent`?(0,T.jsx)(pv,{selectedAgent:p,onDebugEvent:de}):(0,T.jsx)(Sj,{selectedWorkflow:p,onDebugEvent:de}):(0,T.jsx)(`div`,{className:`flex-1 flex items-center justify-center text-muted-foreground`,children:`Select an agent or workflow to get started.`})}),v===`developer`&&k?(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(`div`,{className:`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${N?`bg-primary/40`:`bg-border hover:bg-primary/20`}`,onMouseDown:ce,children:(0,T.jsx)(`div`,{className:`absolute inset-y-0 -left-2 -right-2 flex items-center justify-center`,children:(0,T.jsx)(`div`,{className:`h-12 w-1 rounded-full transition-all duration-200 ease-in-out ${N?`bg-primary shadow-lg shadow-primary/25`:`bg-primary/30 group-hover:bg-primary group-hover:shadow-md group-hover:shadow-primary/20`}`})})}),(0,T.jsx)(`div`,{className:`flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]`,style:{width:A?`2.5rem`:`${j}px`},children:A?(0,T.jsxs)(`div`,{className:`h-full w-10 bg-background border-l flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors`,onClick:()=>ee(!1),title:`Expand debug panel`,children:[(0,T.jsx)(`div`,{className:`h-8 w-8 flex items-center justify-center`,children:(0,T.jsx)(El,{className:`h-4 w-4 text-muted-foreground`})}),(0,T.jsxs)(`div`,{className:`flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none`,children:[(0,T.jsx)(`div`,{className:`text-xs text-muted-foreground select-none`,style:{writingMode:`vertical-rl`,transform:`rotate(180deg)`},children:`Debug Panel`}),M.length>0&&(0,T.jsx)(`div`,{className:`bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center`,style:{fontSize:`10px`},children:M.length})]})]}):(0,T.jsxs)(T.Fragment,{children:[(0,T.jsx)(Fm,{events:M,isStreaming:!1,onMinimize:()=>ee(!0)}),(0,T.jsx)(`div`,{className:`border-t bg-muted/30 px-3 py-2.5 flex-shrink-0`,children:(0,T.jsxs)(K,{onClick:()=>ne(!0),className:`w-full`,variant:`outline`,size:`sm`,children:[(0,T.jsx)(du,{className:`h-3 w-3 mr-2 flex-shrink-0`}),(0,T.jsx)(`span`,{className:`truncate text-xs`,children:m&&p?.deployment_supported?`Deploy to Azure`:`Deployment Guide`})]})})]})})]}):v===`developer`?(0,T.jsx)(`div`,{className:`flex-shrink-0`,children:(0,T.jsx)(K,{variant:`ghost`,size:`sm`,onClick:()=>P(!0),className:`h-full w-10 rounded-none border-l`,title:`Show debug panel`,children:(0,T.jsx)(ou,{className:`h-4 w-4`})})}):null]})}),(0,T.jsx)(lh,{open:z,onOpenChange:U}),(0,T.jsx)(Hh,{open:V,onClose:()=>ne(!1),agentName:p?.name,entity:p}),H&&(0,T.jsx)(Cj,{message:`Entity not found. Showing first available entity instead.`,type:`info`,onClose:()=>re(!1)}),(0,T.jsx)(wj,{toasts:ie,onRemove:oe})]})}function Dj({children:e,attribute:t=`class`,defaultTheme:n=`dark`,enableSystem:r=!0,disableTransitionOnChange:i=!0,...a}){return(0,T.jsx)(Gu,{attribute:t,defaultTheme:n,enableSystem:r,disableTransitionOnChange:i,...a,children:e})}Ch(),(0,b.createRoot)(document.getElementById(`root`)).render((0,T.jsx)(y.StrictMode,{children:(0,T.jsx)(Dj,{attribute:`class`,defaultTheme:`dark`,enableSystem:!0,disableTransitionOnChange:!0,children:(0,T.jsx)(Ej,{})})})); \ No newline at end of file diff --git a/python/packages/devui/frontend/package.json b/python/packages/devui/frontend/package.json index 902994db3e3..19019ddb87e 100644 --- a/python/packages/devui/frontend/package.json +++ b/python/packages/devui/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "test:tracing": "node --test test/trace-polling.test.mjs", "preview": "vite preview" }, "dependencies": { diff --git a/python/packages/devui/frontend/src/App.tsx b/python/packages/devui/frontend/src/App.tsx index c759f17f405..9de8e6025e6 100644 --- a/python/packages/devui/frontend/src/App.tsx +++ b/python/packages/devui/frontend/src/App.tsx @@ -108,10 +108,18 @@ export default function App() { } } + const instrumentationEnabled = meta.capabilities.instrumentation ?? meta.capabilities.tracing ?? false; + const capabilities = { + instrumentation: instrumentationEnabled, + trace_retrieval: meta.capabilities.trace_retrieval ?? false, + openai_proxy: meta.capabilities.openai_proxy, + deployment: meta.capabilities.deployment, + }; + useDevUIStore.getState().setServerMeta({ uiMode: meta.ui_mode, runtime: meta.runtime, - capabilities: meta.capabilities, + capabilities, authRequired: meta.auth_required, version: meta.version, }); diff --git a/python/packages/devui/frontend/src/components/features/agent/agent-view.tsx b/python/packages/devui/frontend/src/components/features/agent/agent-view.tsx index 248d644644b..002289b2932 100644 --- a/python/packages/devui/frontend/src/components/features/agent/agent-view.tsx +++ b/python/packages/devui/frontend/src/components/features/agent/agent-view.tsx @@ -356,6 +356,50 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { const accumulatedTextRef = useRef(""); const accumulatedPartsRef = useRef([]); const lastAssistantTextRenderAt = useRef(0); + const tracePollControllersRef = useRef(new Set()); + + const abortTracePolls = useCallback(() => { + for (const controller of tracePollControllersRef.current) { + controller.abort(); + } + tracePollControllersRef.current.clear(); + }, []); + + const startTracePolling = useCallback((responseId: string) => { + const traceController = new AbortController(); + tracePollControllersRef.current.add(traceController); + void apiClient.getTraceEvents( + responseId, + useDevUIStore.getState().serverCapabilities.trace_retrieval, + traceController.signal + ) + .then((traceEvents) => { + if (traceController.signal.aborted) { + return; + } + + for (const traceEvent of traceEvents) { + onDebugEvent(traceEvent); + } + }) + .catch((error) => { + if (!isAbortError(error)) { + console.debug("Failed to retrieve response traces", error); + } + }) + .finally(() => { + tracePollControllersRef.current.delete(traceController); + }); + }, [onDebugEvent]); + + const clearDebugEvents = useCallback(() => { + abortTracePolls(); + onDebugEvent("clear"); + }, [abortTracePolls, onDebugEvent]); + + useEffect(() => { + return abortTracePolls; + }, [selectedAgent?.id, abortTracePolls]); const renderAssistantStreamingText = useCallback( ( @@ -707,7 +751,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { // Restore stored traces as debug events for context inspection if (storedTraces.length > 0) { // Clear any previous debug events first - onDebugEvent("clear"); + clearDebugEvents(); for (const trace of storedTraces) { // Convert stored trace back to ResponseTraceComplete event format const traceEvent: ExtendedResponseStreamEvent = { @@ -835,8 +879,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { loadConversations(); // currentConversation is intentionally excluded - this effect should only run when agent changes - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedAgent, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]); + }, [selectedAgent, onDebugEvent, clearDebugEvents, renderAssistantStreamingText, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]); // Removed old input handling functions - now handled by ChatMessageInput component @@ -859,7 +902,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { accumulatedPartsRef.current = []; // Clear debug panel for fresh conversation - onDebugEvent("clear"); + clearDebugEvents(); // Update localStorage cache with new conversation const cachedKey = `devui_convs_${selectedAgent.id}`; @@ -873,7 +916,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { type: "conversation_creation_error", }); } - }, [selectedAgent, onDebugEvent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]); + }, [selectedAgent, availableConversations, clearDebugEvents, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]); // Handle conversation deletion const handleDeleteConversation = useCallback( @@ -918,13 +961,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { } // Clear debug panel - onDebugEvent("clear"); + clearDebugEvents(); } } catch { alert("Failed to delete conversation. Please try again."); } }, - [availableConversations, currentConversation, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming] + [availableConversations, currentConversation, clearDebugEvents, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming] ); // Handle entity reload (hot reload) @@ -974,7 +1017,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { setCurrentConversation(conversation); // Clear debug panel when switching conversations - onDebugEvent("clear"); + clearDebugEvents(); try { // Load conversation history from backend with pagination @@ -1064,7 +1107,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { accumulatedTextRef.current = ""; accumulatedPartsRef.current = []; }, - [availableConversations, onDebugEvent, setCurrentConversation, setChatItems, setIsStreaming] + [availableConversations, onDebugEvent, clearDebugEvents, setCurrentConversation, setChatItems, setIsStreaming] ); // Handle function approval responses @@ -1198,6 +1241,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]); + let traceResponseId: string | undefined; + let responseSignal: AbortSignal | undefined; + try { // If no conversation selected, create one automatically let conversationToUse = currentConversation; @@ -1238,19 +1284,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { lastAssistantTextRenderAt.current = 0; // Create new AbortController for this request - const signal = createAbortSignal(); + responseSignal = createAbortSignal(); // Use OpenAI-compatible API streaming - direct event handling const streamGenerator = apiClient.streamAgentExecutionOpenAI( selectedAgent.id, apiRequest, - signal + responseSignal ); - for await (const openAIEvent of streamGenerator) { // Pass all events to debug panel onDebugEvent(openAIEvent); + if ( + "response" in openAIEvent && + openAIEvent.response && + typeof openAIEvent.response === "object" && + "id" in openAIEvent.response && + typeof openAIEvent.response.id === "string" + ) { + traceResponseId = openAIEvent.response.id; + } + // Handle response.completed event (OpenAI standard) if (openAIEvent.type === "response.completed") { const completedEvent = openAIEvent as import("@/types/openai").ResponseCompletedEvent; @@ -1569,9 +1624,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { } setIsStreaming(false); resetCancelling(); + } finally { + if (traceResponseId && !responseSignal?.aborted) { + startTracePolling(traceResponseId); + } } }, - [selectedAgent, currentConversation, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling] + [selectedAgent, currentConversation, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setIsSubmitting, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling, startTracePolling] ); // Handle non-streaming message sending @@ -1758,6 +1817,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { sequence_number: 0, } as ExtendedResponseStreamEvent); + if (typeof response.id === "string") { + startTracePolling(response.id); + } + } catch (error) { // Show error message const errorMessage = error instanceof Error ? error.message : "Failed to get response"; @@ -1779,7 +1842,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) { setIsSubmitting(false); } }, - [selectedAgent, currentConversation, onDebugEvent, setChatItems, setCurrentConversation, setAvailableConversations, updateConversationUsage, setIsSubmitting] + [selectedAgent, currentConversation, onDebugEvent, setChatItems, setCurrentConversation, setAvailableConversations, updateConversationUsage, setIsSubmitting, startTracePolling] ); diff --git a/python/packages/devui/frontend/src/components/features/agent/context-inspector.tsx b/python/packages/devui/frontend/src/components/features/agent/context-inspector.tsx index 47c0e0f69f4..cbb141a2d14 100644 --- a/python/packages/devui/frontend/src/components/features/agent/context-inspector.tsx +++ b/python/packages/devui/frontend/src/components/features/agent/context-inspector.tsx @@ -668,6 +668,10 @@ function StatCard({ // Main component export function ContextInspector({ events }: ContextInspectorProps) { + const runtime = useDevUIStore((state) => state.runtime); + const traceRetrievalEnabled = useDevUIStore( + (state) => state.serverCapabilities.trace_retrieval + ); // Use persisted store state instead of local useState const viewMode = useDevUIStore((state) => state.contextInspectorViewMode); const setViewMode = useDevUIStore((state) => state.setContextInspectorViewMode); @@ -744,11 +748,19 @@ export function ContextInspector({ events }: ContextInspectorProps) {
No Data
- Run{" "} - - devui --instrumentation - {" "} - and start a conversation. + {traceRetrievalEnabled ? ( + "Start a conversation to inspect OpenTelemetry context from the Aspire Dashboard." + ) : runtime === "dotnet" ? ( + "Trace retrieval is not available for this server." + ) : ( + <> + Run{" "} + + devui --instrumentation + {" "} + and start a conversation. + + )}
); diff --git a/python/packages/devui/frontend/src/components/layout/debug-panel.tsx b/python/packages/devui/frontend/src/components/layout/debug-panel.tsx index 30aeb9ffc4b..1a961078a79 100644 --- a/python/packages/devui/frontend/src/components/layout/debug-panel.tsx +++ b/python/packages/devui/frontend/src/components/layout/debug-panel.tsx @@ -1476,6 +1476,10 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) { // Use persisted store state instead of local useState const subTab = useDevUIStore((state) => state.debugTraceSubTab); const setSubTab = useDevUIStore((state) => state.setDebugTraceSubTab); + const runtime = useDevUIStore((state) => state.runtime); + const traceRetrievalEnabled = useDevUIStore( + (state) => state.serverCapabilities.trace_retrieval + ); // ONLY show actual trace events const traceEvents = events.filter( @@ -1541,11 +1545,19 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
No Data
- Run{" "} - - devui --instrumentation - {" "} - and start a conversation. + {traceRetrievalEnabled ? ( + "Start a conversation to view OpenTelemetry spans from the Aspire Dashboard." + ) : runtime === "dotnet" ? ( + "Trace retrieval is not available for this server." + ) : ( + <> + Run{" "} + + devui --instrumentation + {" "} + and start a conversation. + + )}
) : ( diff --git a/python/packages/devui/frontend/src/services/api.ts b/python/packages/devui/frontend/src/services/api.ts index b540973bd7d..97363a54da9 100644 --- a/python/packages/devui/frontend/src/services/api.ts +++ b/python/packages/devui/frontend/src/services/api.ts @@ -74,6 +74,9 @@ const DEFAULT_API_BASE_URL = const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff) const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff) const STREAMING_STATE_SAVE_INTERVAL_MS = 250; +const TRACE_POLL_ATTEMPTS = 12; +const TRACE_POLL_INTERVAL_MS = 500; +const TRACE_POLL_TIMEOUT_MS = 6000; // Get backend URL from localStorage or default function getBackendUrl(): string { @@ -83,9 +86,47 @@ function getBackendUrl(): string { return DEFAULT_API_BASE_URL; } -// Helper to sleep for a given duration -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +// Helper to sleep for a given duration while remaining responsive to cancellation. +function sleep(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + if (signal.aborted) { + return Promise.reject(new DOMException("Request aborted", "AbortError")); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal.removeEventListener("abort", handleAbort); + resolve(); + }, ms); + const handleAbort = () => { + clearTimeout(timeout); + reject(new DOMException("Request aborted", "AbortError")); + }; + signal.addEventListener("abort", handleAbort, { once: true }); + }); +} + +interface TraceSpanSnapshotData { + span_id?: string; + parent_span_id?: string | null; + end_time?: number; + status?: string; +} + +function getTraceSpanData(event: ExtendedResponseStreamEvent): TraceSpanSnapshotData | undefined { + if ( + event.type !== "response.trace.completed" || + !("data" in event) || + typeof event.data !== "object" || + event.data === null + ) { + return undefined; + } + + return event.data as TraceSpanSnapshotData; } class ApiClient { @@ -179,6 +220,70 @@ class ApiClient { return response.json(); } + async getTraceEvents( + responseId: string, + traceRetrievalEnabled: boolean, + signal?: AbortSignal + ): Promise { + if (!traceRetrievalEnabled) return []; + if (signal?.aborted) throw new DOMException("Request aborted", "AbortError"); + + const headers: Record = {}; + if (this.authToken) { + headers.Authorization = `Bearer ${this.authToken}`; + } + + const spansById = new Map(); + const pollingController = new AbortController(); + const abortPolling = () => pollingController.abort(); + signal?.addEventListener("abort", abortPolling, { once: true }); + // One deadline covers fetches, response bodies, and delays across every attempt. + const deadlineTimer = setTimeout(abortPolling, TRACE_POLL_TIMEOUT_MS); + + // An unchanged subset does not mean the trace is complete: Aspire's batch exporter + // can hold later spans for several seconds. Merge through the entire bounded window. + try { + for (let attempt = 0; attempt < TRACE_POLL_ATTEMPTS; attempt++) { + try { + const response = await fetch( + `${this.baseUrl}/v1/responses/${encodeURIComponent(responseId)}/traces`, + { headers, signal: pollingController.signal } + ); + + if (response.ok) { + const result = await response.json() as { data?: ExtendedResponseStreamEvent[] }; + if (result.data && result.data.length > 0) { + for (const event of result.data) { + const spanId = getTraceSpanData(event)?.span_id; + if (spanId) spansById.set(spanId, event); + } + } + } else if (response.status === 401) { + this.clearAuthToken(); + return []; + } else if (response.status !== 404 && response.status !== 503) { + break; + } + } catch (error) { + if (isAbortError(error) || pollingController.signal.aborted) throw error; + // Keep earlier snapshots and let later attempts recover from transient failures. + } + + if (attempt < TRACE_POLL_ATTEMPTS - 1) { + await sleep(TRACE_POLL_INTERVAL_MS, pollingController.signal); + } + } + } catch (error) { + // Caller cancellation still rejects; our deadline returns the best snapshot collected. + if (signal?.aborted || !pollingController.signal.aborted) throw error; + } finally { + clearTimeout(deadlineTimer); + signal?.removeEventListener("abort", abortPolling); + } + + return Array.from(spansById.values()); + } + // Health check async getHealth(): Promise { return this.request("/health"); @@ -791,7 +896,7 @@ class ApiClient { // Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s const retryDelay = Math.min(RETRY_INTERVAL_MS * Math.pow(2, retryCount - 1), 30000); - await sleep(retryDelay); + await sleep(retryDelay, signal); // Loop will retry with GET if we have response_id, otherwise POST } } diff --git a/python/packages/devui/frontend/src/stores/devuiStore.ts b/python/packages/devui/frontend/src/stores/devuiStore.ts index 1ad63492e9e..b45be4eac9a 100644 --- a/python/packages/devui/frontend/src/stores/devuiStore.ts +++ b/python/packages/devui/frontend/src/stores/devuiStore.ts @@ -110,6 +110,7 @@ interface DevUIState { runtime: "python" | "dotnet"; serverCapabilities: { instrumentation: boolean; + trace_retrieval: boolean; openai_proxy: boolean; deployment: boolean; }; @@ -200,7 +201,18 @@ interface DevUIActions { toggleOAIMode: () => void; // Server Meta Actions - setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { instrumentation: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean; version?: string }) => void; + setServerMeta: (meta: { + uiMode: "developer" | "user"; + runtime: "python" | "dotnet"; + capabilities: { + instrumentation: boolean; + trace_retrieval: boolean; + openai_proxy: boolean; + deployment: boolean; + }; + authRequired: boolean; + version?: string; + }) => void; // Deployment Actions startDeployment: () => void; @@ -290,6 +302,7 @@ export const useDevUIStore = create()( runtime: "python", // Default to Python runtime serverCapabilities: { instrumentation: false, + trace_retrieval: false, openai_proxy: false, deployment: false, }, diff --git a/python/packages/devui/frontend/src/types/index.ts b/python/packages/devui/frontend/src/types/index.ts index dc79cc43d4b..a8f62bfdfa2 100644 --- a/python/packages/devui/frontend/src/types/index.ts +++ b/python/packages/devui/frontend/src/types/index.ts @@ -177,7 +177,9 @@ export interface MetaResponse { framework: string; runtime: "python" | "dotnet"; capabilities: { - instrumentation: boolean; + instrumentation?: boolean; + tracing?: boolean; + trace_retrieval?: boolean; openai_proxy: boolean; deployment: boolean; }; diff --git a/python/packages/devui/frontend/test/trace-polling.test.mjs b/python/packages/devui/frontend/test/trace-polling.test.mjs new file mode 100644 index 00000000000..becc643f5a4 --- /dev/null +++ b/python/packages/devui/frontend/test/trace-polling.test.mjs @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft. All rights reserved. + +import assert from "node:assert/strict"; +import { setImmediate } from "node:timers/promises"; +import { after, before, test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; + +let server; +let ApiClient; +const originalStorage = globalThis.localStorage; + +before(async () => { + globalThis.localStorage = { getItem: () => null, removeItem: () => {} }; + server = await createServer({ + configFile: false, + root: fileURLToPath(new URL("..", import.meta.url)), + resolve: { alias: { "@": fileURLToPath(new URL("../src", import.meta.url)) } }, + server: { middlewareMode: true, hmr: false, ws: false, watch: null }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + ({ ApiClient } = await server.ssrLoadModule("/src/services/api.ts")); +}); + +after(async () => { + await server?.close(); + globalThis.localStorage = originalStorage; +}); + +const span = (id, status = "OK") => ({ + type: "response.trace.completed", + data: { span_id: id, status }, +}); + +async function advanceTime(t, milliseconds) { + while (milliseconds > 0) { + await setImmediate(); + const step = Math.min(milliseconds, 500); + t.mock.timers.tick(step); + milliseconds -= step; + } + await setImmediate(); +} + +async function finishPollWindow(t) { + await advanceTime(t, 6000); +} + +test("merges spans exported five seconds after an unchanged early subset", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let polls = 0; + t.mock.method(globalThis, "fetch", async () => ({ + ok: true, + json: async () => ({ data: ++polls <= 10 ? [span("child")] : [span("parent"), span("child", "ERROR")] }), + })); + const client = new ApiClient("http://localhost"); + + const pending = client.getTraceEvents("resp_delayed", true); + await finishPollWindow(t); + + assert.deepEqual(await pending, [span("child", "ERROR"), span("parent")]); + assert.equal(polls, 12); +}); + +test("keeps earlier spans when subsequent snapshots are partial or temporarily unavailable", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let polls = 0; + t.mock.method(globalThis, "fetch", async () => { + polls++; + return { + ok: polls !== 2, + status: polls === 2 ? 503 : 200, + json: async () => ({ data: polls === 1 ? [span("child")] : [span("parent")] }), + }; + }); + const client = new ApiClient("http://localhost"); + + const pending = client.getTraceEvents("resp_partial", true); + await finishPollWindow(t); + + assert.deepEqual(await pending, [span("child"), span("parent")]); + assert.equal(polls, 12); +}); + +test("retains spans and retries after fetch and JSON failures, including a failed last poll", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let polls = 0; + t.mock.method(globalThis, "fetch", async () => { + polls++; + if (polls === 2 || polls === 12) throw new TypeError("Network connection lost"); + return { + ok: true, + json: async () => { + if (polls === 3) throw new SyntaxError("Incomplete JSON"); + return { data: [span(polls === 1 ? "child" : "parent")] }; + }, + }; + }); + const pending = new ApiClient("http://localhost").getTraceEvents("resp_transient", true); + await finishPollWindow(t); + + assert.deepEqual(await pending, [span("child"), span("parent")]); + assert.equal(polls, 12); +}); + +test("slow timeout responses share a six-second deadline and cancel the active request", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const controller = new AbortController(); + t.after(() => controller.abort()); + let activeRequests = 0; + let polls = 0; + const fetch = t.mock.method(globalThis, "fetch", async (_url, { signal }) => { + if (++polls === 1) return { ok: true, json: async () => ({ data: [span("child")] }) }; + activeRequests++; + try { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve({ ok: false, status: 503 }); + }, 2000); + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException("Request aborted", "AbortError")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); + } finally { + activeRequests--; + } + }); + const outcomes = []; + new ApiClient("http://localhost").getTraceEvents("resp_slow", true, controller.signal) + .then((value) => outcomes.push({ value }), (error) => outcomes.push({ error })); + + await advanceTime(t, 5999); + assert.deepEqual(outcomes, []); + assert.equal(activeRequests, 1); + await advanceTime(t, 1); + assert.deepEqual(outcomes, [{ value: [span("child")] }]); + assert.equal(activeRequests, 0); + assert.equal(fetch.mock.callCount(), 4); + await finishPollWindow(t); + assert.equal(fetch.mock.callCount(), 4); +}); + +for (const stalledPhase of ["fetch", "body"]) { + test(`deadline cancels a stalled ${stalledPhase} and returns previously collected spans`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const controller = new AbortController(); + t.after(() => controller.abort()); + let aborted = false; + let polls = 0; + const fetch = t.mock.method(globalThis, "fetch", async (_url, { signal }) => { + if (++polls === 1) return { ok: true, json: async () => ({ data: [span("child")] }) }; + const stall = () => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + reject(new DOMException("Request aborted", "AbortError")); + }, { once: true }); + }); + return stalledPhase === "fetch" ? stall() : { ok: true, json: stall }; + }); + const outcomes = []; + new ApiClient("http://localhost").getTraceEvents("resp_stalled", true, controller.signal) + .then((value) => outcomes.push({ value }), (error) => outcomes.push({ error })); + await finishPollWindow(t); + + assert.deepEqual(outcomes, [{ value: [span("child")] }]); + assert.equal(aborted, true); + assert.equal(fetch.mock.callCount(), 2); + }); + + test(`caller cancellation interrupts a stalled ${stalledPhase} and rejects instead of publishing spans`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const controller = new AbortController(); + let aborted = false; + const fetch = t.mock.method(globalThis, "fetch", async (_url, { signal }) => { + const stall = () => new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + aborted = true; + reject(new DOMException("Request aborted", "AbortError")); + }, { once: true }); + }); + return stalledPhase === "fetch" ? stall() : { ok: true, json: stall }; + }); + const pending = new ApiClient("http://localhost").getTraceEvents("resp_cancelled", true, controller.signal); + const rejected = assert.rejects(pending, { name: "AbortError" }); + await advanceTime(t, 500); + controller.abort(); + await rejected; + await finishPollWindow(t); + + assert.equal(aborted, true); + assert.equal(fetch.mock.callCount(), 1); + }); +} + +test("an already cancelled request never starts trace polling", async (t) => { + const fetch = t.mock.method(globalThis, "fetch"); + const controller = new AbortController(); + controller.abort(); + + await assert.rejects(new ApiClient("http://localhost").getTraceEvents("resp_cancelled", true, controller.signal), { name: "AbortError" }); + assert.equal(fetch.mock.callCount(), 0); +}); + +test("a non-retryable response stops polling without discarding collected spans", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let polls = 0; + const fetch = t.mock.method(globalThis, "fetch", async () => ++polls === 1 + ? { ok: true, json: async () => ({ data: [span("child")] }) } + : { ok: false, status: 400 }); + const pending = new ApiClient("http://localhost").getTraceEvents("resp_unavailable", true); + await finishPollWindow(t); + + assert.deepEqual(await pending, [span("child")]); + assert.equal(fetch.mock.callCount(), 2); + assert.equal(fetch.mock.calls[0].arguments[1].signal.aborted, false); +}); + +test("cancellation during the poll interval stops requests and rejects the result", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const fetch = t.mock.method(globalThis, "fetch", async () => ({ ok: true, json: async () => ({ data: [span("child")] }) })); + const controller = new AbortController(); + const client = new ApiClient("http://localhost"); + const pending = client.getTraceEvents("resp_cancelled", true, controller.signal); + const rejected = assert.rejects(pending, { name: "AbortError" }); + await setImmediate(); + + controller.abort(); + await rejected; + await finishPollWindow(t); + + assert.equal(fetch.mock.callCount(), 1); +}); + +test("each call uses the supplied capability, including when it is disabled after a previous request", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const fetch = t.mock.method(globalThis, "fetch", async () => ({ ok: true, json: async () => ({ data: [span("child")] }) })); + const client = new ApiClient("http://localhost"); + + assert.deepEqual(await client.getTraceEvents("resp_disabled", false), []); + assert.equal(fetch.mock.callCount(), 0); + const pending = client.getTraceEvents("resp_enabled", true); + await finishPollWindow(t); + assert.deepEqual(await pending, [span("child")]); + assert.equal(fetch.mock.callCount(), 12); + assert.deepEqual(await client.getTraceEvents("resp_disabled_again", false), []); + assert.equal(fetch.mock.callCount(), 12); +}); + +test("an unauthorized trace request clears the token and stops polling", async (t) => { + const fetch = t.mock.method(globalThis, "fetch", async () => ({ ok: false, status: 401 })); + const client = new ApiClient("http://localhost"); + const clearAuthToken = t.mock.method(client, "clearAuthToken"); + + assert.deepEqual(await client.getTraceEvents("resp_unauthorized", true), []); + assert.equal(fetch.mock.callCount(), 1); + assert.equal(clearAuthToken.mock.callCount(), 1); +}); From 6129a8c9a1a028b9e5be344ccf62bed0114e2135 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 15 Sep 2026 08:57:36 +0000 Subject: [PATCH 082/108] Python: Add MongoDB vector store connector (#8184) * Python: Add MongoDB vector store connector Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Fix MongoDB vector validation edge cases Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address MongoDB review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f * Raise MongoDB connector core floor Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 52e1edc1-384b-4163-b8bd-f659a510ad1f --- .../workflows/python-integration-tests.yml | 51 + .github/workflows/python-merge-tests.yml | 61 + python/PACKAGE_STATUS.md | 1 + python/packages/mongodb/LICENSE | 21 + python/packages/mongodb/README.md | 109 ++ .../agent_framework_mongodb/__init__.py | 16 + .../agent_framework_mongodb/_vector_store.py | 1192 +++++++++++++++++ .../mongodb/agent_framework_mongodb/py.typed | 0 python/packages/mongodb/pyproject.toml | 67 + python/packages/mongodb/samples/README.md | 11 + .../mongodb/samples/mongodb_vectors.py | 70 + .../mongodb/tests/mongodb/conftest.py | 122 ++ .../mongodb/tests/mongodb/test_filters.py | 223 +++ .../mongodb/tests/mongodb/test_integration.py | 293 ++++ .../mongodb/tests/mongodb/test_settings.py | 146 ++ .../tests/mongodb/test_vector_store.py | 613 +++++++++ python/pyproject.toml | 1 + python/uv.lock | 16 + 18 files changed, 3013 insertions(+) create mode 100644 python/packages/mongodb/LICENSE create mode 100644 python/packages/mongodb/README.md create mode 100644 python/packages/mongodb/agent_framework_mongodb/__init__.py create mode 100644 python/packages/mongodb/agent_framework_mongodb/_vector_store.py create mode 100644 python/packages/mongodb/agent_framework_mongodb/py.typed create mode 100644 python/packages/mongodb/pyproject.toml create mode 100644 python/packages/mongodb/samples/README.md create mode 100644 python/packages/mongodb/samples/mongodb_vectors.py create mode 100644 python/packages/mongodb/tests/mongodb/conftest.py create mode 100644 python/packages/mongodb/tests/mongodb/test_filters.py create mode 100644 python/packages/mongodb/tests/mongodb/test_integration.py create mode 100644 python/packages/mongodb/tests/mongodb/test_settings.py create mode 100644 python/packages/mongodb/tests/mongodb/test_vector_store.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 6d3c6b8be08..30c29ec14b1 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -84,6 +84,55 @@ jobs: path: ./python/pytest.xml if-no-files-found: ignore + # MongoDB tests use the official disposable Atlas Local image and need no cloud credentials. + python-tests-mongodb: + name: Python Integration Tests - MongoDB + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + services: + mongodb: + image: mongodb/mongodb-atlas-local@sha256:82da14850e350f15aae720ebba130ade6a607335877486a63252b3ad8ef3b12a + ports: + - 27017:27017 + env: + MONGODB_TEST_URI: "mongodb://localhost:27017/?directConnection=true" + MONGODB_TEST_DATABASE: af_vectors_test + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.checkout-ref }} + persist-credentials: false + - name: Set up python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + - name: Test MongoDB integration + run: > + uv run --directory packages/mongodb poe test-integration + --junitxml=${{ github.workspace }}/python/pytest-mongodb.xml + - name: Surface failing tests + if: always() + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 + with: + path: ./python/pytest-mongodb.xml + summary: true + display-options: fEX + fail-on-empty: false + title: MongoDB integration test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-results-mongodb + path: ./python/pytest-mongodb.xml + if-no-files-found: ignore + # Unit tests: all non-integration tests across all packages python-tests-unit: name: Python Integration Tests - Unit @@ -630,6 +679,7 @@ jobs: python-tests-foundry-hosting, python-tests-cosmos, python-tests-qdrant, + python-tests-mongodb, python-tests-github-copilot, python-tests-postgres, python-tests-redis, @@ -697,6 +747,7 @@ jobs: python-tests-foundry-hosting, python-tests-cosmos, python-tests-qdrant, + python-tests-mongodb, python-tests-github-copilot, python-tests-postgres, python-tests-redis, diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 300705bdbdd..1891b4dcc0e 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -40,6 +40,7 @@ jobs: foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }} cosmosChanged: ${{ steps.filter.outputs.cosmos }} qdrantChanged: ${{ steps.filter.outputs.qdrant }} + mongodbChanged: ${{ steps.filter.outputs.mongodb }} redisChanged: ${{ steps.filter.outputs.redis }} githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }} postgresChanged: ${{ steps.filter.outputs.postgres }} @@ -91,6 +92,10 @@ jobs: - 'python/packages/qdrant/**' - '.github/workflows/python-merge-tests.yml' - '.github/workflows/python-integration-tests.yml' + mongodb: + - 'python/packages/mongodb/**' + - '.github/workflows/python-merge-tests.yml' + - '.github/workflows/python-integration-tests.yml' redis: - 'python/packages/redis/**' - 'python/packages/core/agent_framework/redis/**' @@ -159,6 +164,60 @@ jobs: path: ./python/pytest.xml if-no-files-found: ignore + # MongoDB tests use the official disposable Atlas Local image and need no cloud credentials. + python-tests-mongodb: + name: Python Integration Tests - MongoDB + needs: paths-filter + if: > + needs.paths-filter.outputs.pythonChanges == 'true' && + (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || + needs.paths-filter.outputs.mongodbChanged == 'true' || + needs.paths-filter.outputs.coreChanged == 'true') + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + services: + mongodb: + image: mongodb/mongodb-atlas-local@sha256:82da14850e350f15aae720ebba130ade6a607335877486a63252b3ad8ef3b12a + ports: + - 27017:27017 + env: + MONGODB_TEST_URI: "mongodb://localhost:27017/?directConnection=true" + MONGODB_TEST_DATABASE: af_vectors_test + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + - name: Test MongoDB integration + run: > + uv run --directory packages/mongodb poe test-integration + --junitxml=${{ github.workspace }}/python/pytest-mongodb.xml + - name: Surface failing tests + if: always() + uses: pmeier/pytest-results-action@fdc7f18d9934e38aca411ca9557e6577bd25ca9c # v0.9.0 + with: + path: ./python/pytest-mongodb.xml + summary: true + display-options: fEX + fail-on-empty: false + title: MongoDB integration test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: test-results-mongodb + path: ./python/pytest-mongodb.xml + if-no-files-found: ignore + # Unit tests: always run all non-integration tests across all packages python-tests-unit: name: Python Tests - Unit @@ -831,6 +890,7 @@ jobs: python-tests-foundry-hosting, python-tests-cosmos, python-tests-qdrant, + python-tests-mongodb, python-tests-github-copilot, python-tests-postgres, python-tests-redis, @@ -895,6 +955,7 @@ jobs: python-tests-foundry-hosting, python-tests-cosmos, python-tests-qdrant, + python-tests-mongodb, python-tests-github-copilot, python-tests-postgres, python-tests-redis, diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index d232cab0565..51ff0f2f7a6 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -44,6 +44,7 @@ Status is grouped into these buckets: | `agent-framework-lab` | `python/packages/lab` | `beta` | | `agent-framework-mem0` | `python/packages/mem0` | `beta` | | `agent-framework-mistral` | `python/packages/mistral` | `beta` | +| `agent-framework-mongodb` | `python/packages/mongodb` | `alpha` | | `agent-framework-monty` | `python/packages/monty` | `beta` | | `agent-framework-ollama` | `python/packages/ollama` | `beta` | | `agent-framework-openai` | `python/packages/openai` | `released` | diff --git a/python/packages/mongodb/LICENSE b/python/packages/mongodb/LICENSE new file mode 100644 index 00000000000..96a394c43c3 --- /dev/null +++ b/python/packages/mongodb/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/python/packages/mongodb/README.md b/python/packages/mongodb/README.md new file mode 100644 index 00000000000..d44314c2e22 --- /dev/null +++ b/python/packages/mongodb/README.md @@ -0,0 +1,109 @@ +# MongoDB vector stores + +An alpha MongoDB integration for [Microsoft Agent Framework](https://learn.microsoft.com/agent-framework/). +`MongoDBCollection` provides async BSON CRUD and dense-vector search, +`MongoDBStore` shares an official async PyMongo client across collections, and +`MongoDBSettings` defines connection configuration. + +## Installation + +```bash +pip install agent-framework-mongodb --pre +``` + +Requires Python 3.10+, PyMongo 4.13.2+, and a MongoDB deployment with +[MongoDB Vector Search](https://www.mongodb.com/docs/vector-search/). Search-index +creation requires the corresponding database permissions. + +## Connection + +Set `MONGODB_URI` and `MONGODB_DATABASE_NAME`; `MONGODB_APP_NAME` is optional. +Constructors resolve explicit arguments first, then an explicitly selected +`.env` file, then process environment variables. URIs accept `str` or AF +`SecretString` and are unwrapped only for PyMongo. + +You can instead inject an `AsyncMongoClient` and pass `database_name`. An +injected client bypasses URI, app-name, and `.env` loading and always remains +caller-owned. Connector-created clients are closed by `close()` or async context +exit. Collections created by a store borrow its already-resolved client. + +## Example + +```python +import asyncio +from dataclasses import dataclass +from typing import Annotated + +from agent_framework import Filter, VectorStoreField, vectorstoremodel +from agent_framework_mongodb import MongoDBStore + + +@vectorstoremodel(collection_name="articles") +@dataclass +class Article: + id: Annotated[str, VectorStoreField("key")] + topic: Annotated[str, VectorStoreField("data", is_indexed=True)] + text: Annotated[str, VectorStoreField("data")] + embedding: Annotated[list[float] | None, VectorStoreField("vector", dimensions=3)] = None + + +async def main() -> None: + async with MongoDBStore() as store: + collection = store.get_collection(Article) + await collection.ensure_collection_exists() + await collection.upsert( + [Article("1", "database", "MongoDB stores BSON documents", [1, 0, 0])], + generate_vectors=False, + ) + results = await collection.search( + vector=[1, 0, 0], + filter=Filter("topic", "eq", "database"), + ) + async for result in results: + print(result["record"].text, result["score"]) + + +asyncio.run(main()) +``` + +## Capabilities and limits + +- String, signed 64-bit integer, and BSON `ObjectId` `_id` values retain native + identity. Only `ObjectId` keys can be generated. Typed `ObjectId` models must + register an encoder and decoder that preserve `ObjectId`; IDs are never stringified + or copied to hidden fields. +- Multiple top-level dense-vector fields are supported through one vector-search + index per field. Use `provider_annotations={"mongodb.index_name": "..."}` to + select an existing index name. Dimensions must be 1-8192. Metrics are cosine, + dot product, and Euclidean; returned `vectorSearchScore` values are native + normalized relevance scores where larger is better. +- `ensure_collection_exists()` creates missing indexes, waits for readiness, and + validates existing semantic definitions. It never updates or migrates an + incompatible collection or index. Newly written documents become searchable + asynchronously, even after an index is queryable. +- Retrieval filters preserve whole-value equality, missing/null, direct array + membership, boolean/number, and literal text semantics with server-side + expressions. Collection filters require fields declared as `list` and reject + mapping and non-list collection operands whose identity BSON cannot preserve. + Vector prefilters are intentionally narrower: indexed scalar equality, range, + membership, AND, and OR only. Nested paths, NOT, null/missing, list membership, + and literal/analyzed text are rejected for vector search. +- Keyword-hybrid search, sparse/binary vectors, provider-side embedding + generation, and automatic schema migration are not supported. +- ANN defaults `numCandidates` to the MongoDB recommendation of 20 times + `skip + top`, capped at MongoDB's maximum of 10,000. Explicit values must be + 1-10,000 and at least `skip + top`; larger result windows require exact search. + The vector stage selects its result window before score thresholding, skip, + and limit, so selective thresholds can underfill a page. Deep offsets increase + server cost; result prefixes are never materialized by the connector. +- Every record is fully validated against BSON's signed 64-bit integer and 16 MiB + document limits before any batch write. PyMongo then chunks bulk writes using + negotiated server limits. A server error can leave a successful prefix + (`ordered=True`) or subset (`ordered=False`) persisted. + +## Documentation + +- [Microsoft Agent Framework documentation](https://learn.microsoft.com/agent-framework/) +- [MongoDB Vector Search documentation](https://www.mongodb.com/docs/vector-search/) +- [PyMongo async documentation](https://pymongo.readthedocs.io/en/stable/api/pymongo/asynchronous/) +- [MongoDB limits and thresholds](https://www.mongodb.com/docs/manual/reference/limits/) diff --git a/python/packages/mongodb/agent_framework_mongodb/__init__.py b/python/packages/mongodb/agent_framework_mongodb/__init__.py new file mode 100644 index 00000000000..65366ad0ceb --- /dev/null +++ b/python/packages/mongodb/agent_framework_mongodb/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Async MongoDB vector collections and stores.""" + +from __future__ import annotations + +import importlib.metadata + +from ._vector_store import MongoDBCollection, MongoDBSettings, MongoDBStore + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = ["MongoDBCollection", "MongoDBSettings", "MongoDBStore", "__version__"] diff --git a/python/packages/mongodb/agent_framework_mongodb/_vector_store.py b/python/packages/mongodb/agent_framework_mongodb/_vector_store.py new file mode 100644 index 00000000000..91e122979ba --- /dev/null +++ b/python/packages/mongodb/agent_framework_mongodb/_vector_store.py @@ -0,0 +1,1192 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Async MongoDB collection lifecycle, BSON storage, filtering, and vector search.""" + +from __future__ import annotations + +import asyncio +import hashlib +import math +import re +from collections.abc import AsyncIterator, Collection, Mapping, Sequence +from datetime import datetime +from typing import Any, ClassVar, Generic, TypeAlias, cast + +from agent_framework import ( + BaseVectorCollection, + BaseVectorSearch, + BaseVectorStore, + Filter, + FilterGroup, + SearchResults, + SecretString, + VectorStoreCollectionDefinition, + VectorStoreField, + load_settings, +) +from agent_framework._telemetry import FeatureIndex, mark_feature_used +from agent_framework._vector_filters import FilterExpression +from agent_framework._vectors import EmbeddingClient, SearchType, Vector +from agent_framework.exceptions import IntegrationInvalidResponseException +from bson import BSON, ObjectId +from bson.errors import InvalidDocument +from bson.regex import Regex +from pymongo import AsyncMongoClient, ReplaceOne +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.asynchronous.database import AsyncDatabase +from pymongo.errors import CollectionInvalid, OperationFailure +from pymongo.operations import SearchIndexModel +from typing_extensions import TypedDict, TypeVar + +KeyT = TypeVar("KeyT", default=Any) +ModelT = TypeVar("ModelT", default=Any) +MongoDocument: TypeAlias = dict[str, Any] +MongoClient: TypeAlias = AsyncMongoClient[MongoDocument] +MongoDatabase: TypeAlias = AsyncDatabase[MongoDocument] +MongoCollection: TypeAlias = AsyncCollection[MongoDocument] + + +class MongoDBSettings(TypedDict, total=False): + """MongoDB settings loaded from explicit values, a selected .env file, or ``MONGODB_`` variables. + + Keys: + uri: Required MongoDB connection URI from ``MONGODB_URI``, stored as an AF ``SecretString``. + database_name: Required database from ``MONGODB_DATABASE_NAME``. + app_name: Optional driver application name from ``MONGODB_APP_NAME``. + """ + + uri: SecretString | None + database_name: str | None + app_name: str | None + + +_BSON_MAX_DOCUMENT_SIZE = 16 * 1024 * 1024 +_BSON_INT64_MIN = -(2**63) +_BSON_INT64_MAX = 2**63 - 1 +_MAX_NUM_CANDIDATES = 10_000 +_SCORE_FIELD = "__af_vector_search_score" +_VECTOR_FILTER_TYPES = frozenset({"str", "int", "float", "bool", "ObjectId", "datetime"}) +_LIST_TYPES = frozenset({"list", "tuple", "set", "Sequence"}) +_DISTANCES = { + "DEFAULT": "cosine", + "cosine_similarity": "cosine", + "cosine_distance": "cosine", + "dot_prod": "dotProduct", + "euclidean_distance": "euclidean", +} +_INDEX_TERMINAL_FAILURES = frozenset({"FAILED", "DELETING"}) +_FALSE_EXPRESSION: MongoDocument = {"$literal": False} + + +def _validate_operation_options( + options: Mapping[str, Any] | None, + allowed: set[str] | None = None, +) -> dict[str, Any]: + result = dict(options or {}) + if unknown := result.keys() - (allowed or set()): + raise NotImplementedError(f"Unsupported MongoDB operation option(s): {', '.join(sorted(unknown))}.") + return result + + +def _validate_non_empty_string(value: Any, name: str) -> str: + if not isinstance(value, str) or not value.strip() or "\0" in value: + raise ValueError(f"{name} must be a non-empty string without NUL characters.") + return value + + +def _create_client( + *, + uri: str | SecretString | None, + database_name: str | None, + app_name: str | None, + async_client: MongoClient | None, + env_file_path: str | None, + env_file_encoding: str | None, +) -> tuple[MongoClient, MongoDatabase, str, bool]: + if async_client is not None: + if any(value is not None for value in (uri, app_name, env_file_path, env_file_encoding)): + raise ValueError("async_client cannot be combined with uri, app_name, env_file_path, or env_file_encoding.") + resolved_database_name = _validate_non_empty_string(database_name, "database_name") + return ( + async_client, + async_client.get_database(resolved_database_name), + resolved_database_name, + False, + ) + + settings = load_settings( + MongoDBSettings, + env_prefix="MONGODB_", + uri=uri, + database_name=database_name, + app_name=app_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + resolved_uri = settings.get("uri") + if not isinstance(resolved_uri, SecretString) or not resolved_uri.get_secret_value().strip(): + raise ValueError("MongoDB uri is required and must be a non-empty string or SecretString.") + resolved_database_name = _validate_non_empty_string(settings.get("database_name"), "database_name") + resolved_app_name = settings.get("app_name") + if resolved_app_name is not None: + _validate_non_empty_string(resolved_app_name, "app_name") + client: MongoClient = AsyncMongoClient( + resolved_uri.get_secret_value(), + appname=resolved_app_name, + ) + return client, client.get_database(resolved_database_name), resolved_database_name, True + + +def _prepare_int64(value: Any, name: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or not _BSON_INT64_MIN <= value <= _BSON_INT64_MAX: + raise ValueError(f"{name} must be a signed 64-bit integer, not a boolean.") + return value + + +def _prepare_key(value: Any, key_type: str | None, *, allow_none: bool = False) -> str | int | ObjectId | None: + if value is None and allow_none: + return None + if key_type == "str" and isinstance(value, str): + return value + if key_type == "int": + return _prepare_int64(value, "MongoDB integer keys") + if key_type == "ObjectId" and isinstance(value, ObjectId): + return value + expected = key_type or "str, int, or ObjectId" + raise TypeError(f"MongoDB key values must match the declared '{expected}' key type.") + + +def _prepare_bson_value(value: Any, *, name: str) -> Any: + if value is None or isinstance(value, (str, bool, bytes, ObjectId, datetime)): + return value + if isinstance(value, int): + return _prepare_int64(value, name) + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError(f"{name} must not contain non-finite numbers.") + return value + if isinstance(value, Mapping): + result: MongoDocument = {} + for key, item in cast(Mapping[Any, Any], value).items(): + if not isinstance(key, str) or "\0" in key: + raise TypeError(f"{name} document keys must be strings without NUL characters.") + result[key] = _prepare_bson_value(item, name=name) + return result + if isinstance(value, Collection) and not isinstance(value, (str, bytes, bytearray, Mapping)): + return [_prepare_bson_value(item, name=name) for item in cast(Collection[Any], value)] + raise TypeError(f"{name} contains unsupported BSON value type '{type(cast(object, value)).__name__}'.") + + +def _prepare_dense_vector(value: Any, name: str) -> list[float]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + raise TypeError(f"MongoDB vector '{name}' must be a dense numeric sequence.") + vector: list[float] = [] + for item in cast(Sequence[Any], value): + if isinstance(item, bool) or not isinstance(item, (int, float)): + raise TypeError(f"MongoDB vector '{name}' must contain numbers, not booleans.") + try: + number = float(item) + except OverflowError as exc: + raise ValueError(f"MongoDB vector '{name}' must contain finite numbers.") from exc + if not math.isfinite(number): + raise ValueError(f"MongoDB vector '{name}' must contain finite numbers.") + vector.append(number) + return vector + + +def _prepare_data_value(field: VectorStoreField, value: Any) -> Any: + if value is None: + return None + kind = field.type_ + name = f"MongoDB field '{field.name}'" + if kind == "str": + if not isinstance(value, str): + raise TypeError(f"{name} must contain a string.") + return value + if kind == "int": + return _prepare_int64(value, name) + if kind == "float": + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{name} must contain a number, not a boolean.") + try: + number = float(value) + except OverflowError as exc: + raise ValueError(f"{name} must contain a finite number.") from exc + if not math.isfinite(number): + raise ValueError(f"{name} must contain a finite number.") + return number + if kind == "bool": + if not isinstance(value, bool): + raise TypeError(f"{name} must contain a boolean.") + return value + if kind in _LIST_TYPES: + if not isinstance(value, Collection) or isinstance(value, (str, bytes, bytearray, Mapping)): + raise TypeError(f"{name} must contain a collection.") + return _prepare_bson_value(value, name=name) + if kind == "dict": + if not isinstance(value, Mapping): + raise TypeError(f"{name} must contain a mapping.") + return _prepare_bson_value(value, name=name) + if kind == "bytes": + if not isinstance(value, bytes): + raise TypeError(f"{name} must contain bytes.") + return value + if kind == "ObjectId": + if not isinstance(value, ObjectId): + raise TypeError(f"{name} must contain a bson.ObjectId.") + return value + if kind == "datetime": + if isinstance(value, str): + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + if not isinstance(value, datetime): + raise TypeError(f"{name} must contain a datetime.") + return value + return _prepare_bson_value(value, name=name) + + +def _validate_bson_document(document: MongoDocument, *, record_index: int) -> None: + try: + size = len(BSON.encode(document)) + except (InvalidDocument, OverflowError, TypeError, ValueError) as exc: + raise ValueError(f"MongoDB record {record_index} cannot be represented as BSON: {exc}") from exc + if size > _BSON_MAX_DOCUMENT_SIZE: + raise ValueError( + f"MongoDB record {record_index} encodes to {size} bytes and exceeds the 16 MiB BSON document limit." + ) + + +def _validate_bson_query_value(value: Any, *, name: str) -> Any: + prepared = _prepare_bson_value(value, name=name) + try: + _validate_bson_document({"value": prepared}, record_index=0) + except ValueError as exc: + raise ValueError(f"{name} is not a valid MongoDB query value.") from exc + return prepared + + +def _field_type_expression(name: str) -> MongoDocument: + return {"$type": f"${name}"} + + +def _field_exists_expression(name: str) -> MongoDocument: + return {"$ne": [_field_type_expression(name), "missing"]} + + +def _field_type_guard(field: VectorStoreField, name: str) -> MongoDocument: + kind = field.type_ + if kind == "int": + return {"$in": [_field_type_expression(name), ["int", "long"]]} + if kind == "float": + return {"$eq": [_field_type_expression(name), "double"]} + if kind == "str": + return {"$eq": [_field_type_expression(name), "string"]} + if kind == "bool": + return {"$eq": [_field_type_expression(name), "bool"]} + if kind == "bytes": + return {"$eq": [_field_type_expression(name), "binData"]} + if kind == "ObjectId": + return {"$eq": [_field_type_expression(name), "objectId"]} + if kind == "datetime": + return {"$eq": [_field_type_expression(name), "date"]} + if kind == "list": + return {"$isArray": f"${name}"} + if kind in _LIST_TYPES: + raise NotImplementedError( + f"MongoDB filters require field '{field.name}' to use declared type 'list'; " + f"'{kind}' loses container identity in BSON." + ) + if kind == "dict": + return {"$eq": [_field_type_expression(name), "object"]} + raise NotImplementedError(f"MongoDB filters require a supported declared type for field '{field.name}'.") + + +def _literal(value: Any) -> MongoDocument: + return {"$literal": value} + + +def _value_matches_field_type(field: VectorStoreField, value: Any) -> bool: + if value is None: + return True + kind = field.type_ + if kind == "str": + return isinstance(value, str) + if kind == "int": + if isinstance(value, int) and not isinstance(value, bool): + return True + return isinstance(value, float) and math.isfinite(value) and value.is_integer() + if kind == "float": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if kind == "bool": + return isinstance(value, bool) + if kind == "bytes": + return isinstance(value, bytes) + if kind == "ObjectId": + return isinstance(value, ObjectId) + if kind == "datetime": + return isinstance(value, datetime) + if kind == "list": + return isinstance(value, list) + if kind == "dict": + return isinstance(value, Mapping) + return False + + +def _prepare_filter_operand(field: VectorStoreField, value: Any) -> Any: + prepared = _validate_bson_query_value(value, name=f"Filter value for '{field.name}'") + if field.type_ == "int" and isinstance(prepared, float): + if not prepared.is_integer(): + raise TypeError(f"Filter value for integer field '{field.name}' must be an integer.") + integer = int(prepared) + if not _BSON_INT64_MIN <= integer <= _BSON_INT64_MAX: + raise ValueError(f"Filter value for integer field '{field.name}' exceeds signed 64-bit range.") + return integer + return prepared + + +def _prepare_collection_filter_operand(value: Any, *, field_name: str) -> Any: + if isinstance(value, Mapping): + raise NotImplementedError( + f"MongoDB collection filters do not support mapping operands for field '{field_name}' " + "because BSON document equality is key-order-sensitive." + ) + if isinstance(value, list): + return [_prepare_collection_filter_operand(item, field_name=field_name) for item in cast(list[Any], value)] + if isinstance(value, Collection) and not isinstance(value, (str, bytes, bytearray)): + raise NotImplementedError( + f"MongoDB collection filters support only scalar and nested-list operands for field '{field_name}'." + ) + return _validate_bson_query_value(value, name=f"Filter value for '{field_name}'") + + +def _prepare_filter_field( + definition: VectorStoreCollectionDefinition, + field_name: str, +) -> tuple[VectorStoreField, str]: + if "." in field_name: + raise NotImplementedError("MongoDB portable filters support declared top-level fields only.") + field = definition.try_get_field(field_name) + if field is None: + raise ValueError(f"Unknown MongoDB filter field '{field_name}'.") + if field.field_type == "vector": + raise NotImplementedError("MongoDB portable filters do not operate on vector fields.") + return field, "_id" if field.field_type == "key" else field.storage_name or field.name + + +def _prepare_retrieval_filter_condition( + expression: Filter, + definition: VectorStoreCollectionDefinition, +) -> MongoDocument: + field, name = _prepare_filter_field(definition, expression.field_name) + operator, value = expression.operator, expression.value + exists = _field_exists_expression(name) + actual = f"${name}" + + if operator == "exists": + return exists + if operator == "is_null": + return {"$and": [exists, {"$eq": [actual, None]}]} + if operator == "is_not_null": + return {"$and": [exists, {"$ne": [actual, None]}]} + + type_guard = _field_type_guard(field, name) + if operator in {"eq", "ne"}: + if value is None: + equality: MongoDocument = {"$and": [exists, {"$eq": [actual, None]}]} + elif not _value_matches_field_type(field, value): + if ( + field.type_ == "list" + and isinstance(value, Collection) + and not isinstance(value, (str, bytes, bytearray)) + ): + _prepare_collection_filter_operand(value, field_name=field.name) + equality = _FALSE_EXPRESSION + elif field.type_ == "dict": + raise NotImplementedError("MongoDB mapping equality does not match portable mapping semantics.") + else: + prepared = ( + _prepare_collection_filter_operand(value, field_name=field.name) + if field.type_ == "list" + else _prepare_filter_operand(field, value) + ) + equality = {"$and": [type_guard, {"$eq": [actual, _literal(prepared)]}]} + return equality if operator == "eq" else {"$and": [exists, {"$not": [equality]}]} + + if operator in {"in", "not_in"}: + if field.type_ == "dict": + raise NotImplementedError("MongoDB mapping membership does not match portable mapping semantics.") + prepared_values: list[Any] = [] + for item in cast(Collection[Any], value): + if item is None: + continue + if field.type_ == "list" and isinstance(item, Collection) and not isinstance(item, (str, bytes, bytearray)): + prepared_item = _prepare_collection_filter_operand(item, field_name=field.name) + elif _value_matches_field_type(field, item): + prepared_item = _prepare_filter_operand(field, item) + else: + continue + if _value_matches_field_type(field, item): + prepared_values.append(prepared_item) + membership: MongoDocument = { + "$and": [ + type_guard, + {"$in": [actual, _literal(prepared_values)]}, + ] + } + return membership if operator == "in" else {"$and": [type_guard, {"$not": [membership]}]} + + if operator in {"gt", "gte", "lt", "lte", "between"}: + if field.type_ not in {"str", "int", "float", "datetime"}: + raise NotImplementedError(f"MongoDB ordered filters do not support declared type '{field.type_}'.") + operands = cast(Sequence[Any], value) if operator == "between" else [value] + if any(item is None or not _value_matches_field_type(field, item) for item in operands): + raise TypeError(f"MongoDB ordered filter operands must match field '{field.name}'.") + prepared = [_prepare_filter_operand(field, item) for item in operands] + comparison = ( + { + "$and": [ + {"$gte": [actual, _literal(prepared[0])]}, + {"$lte": [actual, _literal(prepared[1])]}, + ] + } + if operator == "between" + else {f"${operator}": [actual, _literal(prepared[0])]} + ) + return {"$and": [type_guard, comparison]} + + if operator in {"starts_with", "ends_with", "contains_text"}: + if field.type_ != "str": + raise TypeError("MongoDB literal text filters require a declared scalar string field.") + if not isinstance(value, str): + raise TypeError("MongoDB literal text filters require a string operand.") + escaped = re.escape(value) + pattern = f"^{escaped}" if operator == "starts_with" else f"{escaped}$" if operator == "ends_with" else escaped + return { + "$cond": [ + type_guard, + {"$regexMatch": {"input": actual, "regex": Regex(pattern)}}, + False, + ] + } + + if operator in {"contains", "contains_any", "contains_all"}: + if field.type_ != "list": + raise TypeError("MongoDB collection filters require a field declared as list.") + operands = [value] if operator == "contains" else list(cast(Collection[Any], value)) + prepared = [_prepare_collection_filter_operand(item, field_name=field.name) for item in operands] + if operator == "contains_all": + predicate: MongoDocument = {"$setIsSubset": [_literal(prepared), actual]} + else: + predicate = { + "$anyElementTrue": { + "$map": { + "input": actual, + "as": "item", + "in": {"$in": ["$$item", _literal(prepared)]}, + } + } + } + return {"$cond": [type_guard, predicate, False]} + + raise NotImplementedError(f"Unsupported MongoDB portable filter operator '{operator}'.") + + +def _prepare_vector_filter_condition( + expression: FilterExpression, + definition: VectorStoreCollectionDefinition, +) -> MongoDocument: + if isinstance(expression, FilterGroup): + if expression.operator == "not": + raise NotImplementedError("MongoDB vector prefilters do not support portable NOT semantics.") + operator = "$and" if expression.operator == "and" else "$or" + return {operator: [_prepare_vector_filter_condition(item, definition) for item in expression.filters]} + + field, name = _prepare_filter_field(definition, expression.field_name) + if field.field_type == "data" and not field.is_indexed: + raise NotImplementedError(f"MongoDB vector prefilter field '{field.name}' must declare is_indexed=True.") + if field.type_ not in _VECTOR_FILTER_TYPES: + raise NotImplementedError(f"MongoDB vector prefilters do not support declared type '{field.type_}'.") + operator, value = expression.operator, expression.value + if operator not in {"eq", "gt", "gte", "lt", "lte", "between", "in"}: + raise NotImplementedError( + f"MongoDB vector prefilters do not faithfully support portable operator '{operator}'." + ) + if operator == "eq": + if value is None: + raise NotImplementedError("MongoDB vector prefilters do not index null values.") + prepared = _prepare_filter_operand(field, value) + return {name: {"$eq": prepared}} if _value_matches_field_type(field, value) else {"_id": {"$in": []}} + if operator == "in": + values = list(cast(Collection[Any], value)) + if any(item is None for item in values): + raise NotImplementedError("MongoDB vector prefilters do not index null values.") + prepared = [_prepare_filter_operand(field, item) for item in values if _value_matches_field_type(field, item)] + return {name: {"$in": prepared}} + operands = cast(Sequence[Any], value) if operator == "between" else [value] + if any(item is None or not _value_matches_field_type(field, item) for item in operands): + raise TypeError(f"MongoDB ordered vector prefilter operands must match field '{field.name}'.") + prepared = [_prepare_filter_operand(field, item) for item in operands] + condition = {"$gte": prepared[0], "$lte": prepared[1]} if operator == "between" else {f"${operator}": prepared[0]} + return {name: condition} + + +def _prepare_filter_expression( + expression: FilterExpression, + definition: VectorStoreCollectionDefinition, +) -> MongoDocument: + if isinstance(expression, FilterGroup): + children = [_prepare_filter_expression(item, definition) for item in expression.filters] + if expression.operator == "not": + return {"$not": [children[0]]} + return {("$and" if expression.operator == "and" else "$or"): children} + return _prepare_retrieval_filter_condition(expression, definition) + + +def _default_index_name(collection_name: str, field_name: str) -> str: + digest = hashlib.sha256(f"{collection_name}\0{field_name}".encode()).hexdigest()[:12] + readable = re.sub(r"[^A-Za-z0-9_-]", "_", field_name)[:32] or "vector" + return f"af_{readable}_{digest}" + + +def _semantic_index_definition(definition: Mapping[str, Any]) -> tuple[tuple[tuple[str, Any], ...], ...]: + fields = definition.get("fields") + if not isinstance(fields, Sequence) or isinstance(fields, (str, bytes, bytearray)): + raise ValueError("MongoDB vector search index definition must contain a fields array.") + normalized: list[tuple[tuple[str, Any], ...]] = [] + for raw_field in cast(Sequence[Any], fields): + if not isinstance(raw_field, Mapping): + raise ValueError("MongoDB vector search index fields must be documents.") + field = cast(Mapping[str, Any], raw_field) + kind = field.get("type") + path = field.get("path") + if kind == "filter": + normalized.append((("path", path), ("type", kind))) + elif kind == "vector": + semantic: list[tuple[str, Any]] = [ + ("numDimensions", field.get("numDimensions")), + ("path", path), + ("similarity", field.get("similarity")), + ("type", kind), + ] + quantization = field.get("quantization") + if quantization not in (None, "none"): + semantic.append(("quantization", quantization)) + normalized.append(tuple(sorted(semantic))) + else: + normalized.append(tuple(sorted((str(key), value) for key, value in field.items()))) + return tuple(sorted(normalized, key=repr)) + + +class MongoDBCollection( + BaseVectorCollection[KeyT, ModelT], + BaseVectorSearch[KeyT, ModelT], + Generic[KeyT, ModelT], +): + """Store BSON documents and search dense vectors with MongoDB Vector Search.""" + + supported_key_types: ClassVar[set[str] | None] = {"str", "int", "ObjectId"} + supported_vector_types: ClassVar[set[str] | None] = {"float", "float16", "float32", "float64"} + supported_search_types: ClassVar[set[SearchType]] = {"vector"} + + def __init__( + self, + record_type: type[ModelT], + *, + uri: str | SecretString | None = None, + database_name: str | None = None, + app_name: str | None = None, + async_client: MongoClient | None = None, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a MongoDB collection without performing network I/O. + + Args: + record_type: Registered application model, or dict with an explicit definition. + uri: MongoDB URI override; otherwise loaded from ``MONGODB_URI``. + database_name: Database name, or ``MONGODB_DATABASE_NAME`` for a connector-created client. + app_name: Optional driver app name, or ``MONGODB_APP_NAME``. + async_client: Existing PyMongo async client. It bypasses settings and remains caller-owned. + definition: Explicit dictionary schema. + collection_name: Collection name, overriding the model definition. + embedding_generator: Default local embedding client. + env_file_path: Optional .env file selected explicitly. + env_file_encoding: Encoding for the selected .env file. + """ + super().__init__( + record_type, + definition=definition, + collection_name=collection_name, + embedding_generator=embedding_generator, + managed_client=async_client is None, + ) + self._validate_unique_index_names() + self.async_client, self._database, self.database_name, self._owns_client = _create_client( + uri=uri, + database_name=database_name, + app_name=app_name, + async_client=async_client, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + self._collection: MongoCollection = self._database.get_collection(self.collection_name) + self._closed = False + + def _validate_data_model(self) -> None: + super()._validate_data_model() + key = self.definition.key_field + if key.is_auto_generated and key.type_ != "ObjectId": + raise NotImplementedError("MongoDB auto-generated keys require a bson.ObjectId key field.") + for field in self.definition.fields: + name = field.storage_name or field.name + if not name or "\0" in name or "." in name or name.startswith("$") or name == _SCORE_FIELD: + raise ValueError( + "MongoDB storage names must be top-level names without NUL, dots, '$' prefixes, or reserved names." + ) + if field.field_type != "key" and name == "_id": + raise ValueError("MongoDB reserves storage name '_id' for the key field.") + annotations = field.provider_annotations + unknown = annotations.keys() - ({"mongodb.index_name"} if field.field_type == "vector" else set()) + if unknown: + raise NotImplementedError(f"Unsupported MongoDB field annotation(s): {', '.join(sorted(unknown))}.") + if field.field_type == "vector": + if not isinstance(field.dimensions, int) or isinstance(field.dimensions, bool): + raise TypeError("MongoDB vector dimensions must be an integer.") + if not 1 <= field.dimensions <= 8192: + raise ValueError("MongoDB vector dimensions must be between 1 and 8192.") + if field.index_kind not in {"default", "hnsw"}: + raise NotImplementedError(f"MongoDB vector index kind '{field.index_kind}' is not supported.") + if field.distance_function not in _DISTANCES: + raise NotImplementedError( + f"MongoDB distance function '{field.distance_function}' is not supported." + ) + index_name = annotations.get("mongodb.index_name") + if index_name is not None: + _validate_non_empty_string(index_name, "mongodb.index_name") + elif field.field_type == "data": + if field.is_full_text_indexed: + raise NotImplementedError( + "MongoDB Search text indexes and keyword-hybrid search are not supported." + ) + if field.is_indexed and field.type_ not in _VECTOR_FILTER_TYPES: + raise NotImplementedError( + f"MongoDB vector filter indexes do not support declared type '{field.type_}'." + ) + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Close a connector-owned client on context exit.""" + await self.close() + + async def close(self) -> None: + """Close a connector-created client once, leaving injected clients open.""" + if self._owns_client and not self._closed: + await self.async_client.close() + self._closed = True + + async def collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> bool: + """Return whether the MongoDB collection exists.""" + _validate_operation_options(operation_options) + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + return bool(await self._database.list_collection_names(filter={"name": self.collection_name})) + + def _index_name(self, field: VectorStoreField) -> str: + configured = field.provider_annotations.get("mongodb.index_name") + return ( + cast(str, configured) + if configured is not None + else _default_index_name(self.collection_name, field.storage_name or field.name) + ) + + def _validate_unique_index_names(self) -> None: + names = [self._index_name(field) for field in self.definition.vector_fields] + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError(f"MongoDB vector index names must be unique: {', '.join(duplicates)}.") + + def _index_definition(self, field: VectorStoreField) -> MongoDocument: + fields: list[MongoDocument] = [ + { + "type": "vector", + "path": field.storage_name or field.name, + "numDimensions": field.dimensions, + "similarity": _DISTANCES[cast(str, field.distance_function)], + }, + {"type": "filter", "path": "_id"}, + ] + fields.extend( + {"type": "filter", "path": data_field.storage_name or data_field.name} + for data_field in self.definition.data_fields + if data_field.is_indexed + ) + return {"fields": fields} + + async def _list_search_index(self, name: str) -> Mapping[str, Any] | None: + cursor = await self._collection.list_search_indexes(name=name) + indexes = [index async for index in cast(AsyncIterator[Mapping[str, Any]], cursor)] + if len(indexes) > 1: + raise IntegrationInvalidResponseException( + f"MongoDB returned multiple vector search indexes named '{name}'." + ) + return indexes[0] if indexes else None + + def _validate_search_index( + self, + field: VectorStoreField, + index: Mapping[str, Any], + ) -> bool: + name = self._index_name(field) + if index.get("name") != name or index.get("type") not in (None, "vectorSearch"): + raise ValueError(f"Existing MongoDB search index '{name}' has an incompatible type.") + definition = index.get("latestDefinition") or index.get("definition") + if definition is None: + return False + if not isinstance(definition, Mapping) or _semantic_index_definition( + cast(Mapping[str, Any], definition) + ) != _semantic_index_definition(self._index_definition(field)): + raise ValueError(f"Existing MongoDB vector search index '{name}' does not match the model definition.") + return True + + async def _wait_for_search_index( + self, + field: VectorStoreField, + *, + timeout: float, + poll_interval: float, + ) -> None: + deadline = asyncio.get_running_loop().time() + timeout + name = self._index_name(field) + while True: + index = await self._list_search_index(name) + if index is not None: + definition_available = self._validate_search_index(field, index) + if definition_available and index.get("queryable") is True: + return + status = str(index.get("status", "")).upper() + if status in _INDEX_TERMINAL_FAILURES: + raise ValueError(f"MongoDB vector search index '{name}' entered terminal status '{status}'.") + remaining = deadline - asyncio.get_running_loop().time() + if remaining <= 0: + raise TimeoutError(f"MongoDB vector search index '{name}' was not queryable within {timeout} seconds.") + await asyncio.sleep(min(poll_interval, remaining)) + + async def ensure_collection_exists(self, *, operation_options: Mapping[str, Any] | None = None) -> None: + """Create the collection and missing vector indexes, validating existing definitions without migration.""" + options = _validate_operation_options( + operation_options, + {"create_indexes", "index_timeout", "poll_interval"}, + ) + create_indexes = options.get("create_indexes", True) + if not isinstance(create_indexes, bool): + raise TypeError("create_indexes must be a boolean.") + timeout = options.get("index_timeout", 120.0) + poll_interval = options.get("poll_interval", 0.5) + if ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or not math.isfinite(timeout) + or timeout <= 0 + ): + raise ValueError("index_timeout must be a positive finite number.") + if ( + isinstance(poll_interval, bool) + or not isinstance(poll_interval, (int, float)) + or not math.isfinite(poll_interval) + or poll_interval <= 0 + ): + raise ValueError("poll_interval must be a positive finite number.") + if not await self.collection_exists(): + try: + await self._database.create_collection(self.collection_name, check_exists=False) + except (CollectionInvalid, OperationFailure): + if not await self.collection_exists(): + raise + if not create_indexes: + return + for field in self.definition.vector_fields: + name = self._index_name(field) + existing = await self._list_search_index(name) + if existing is None: + model = SearchIndexModel( + definition=self._index_definition(field), + name=name, + type="vectorSearch", + ) + try: + await self._collection.create_search_index(model=model) + except OperationFailure: + existing = await self._list_search_index(name) + if existing is None: + raise + if existing is not None: + self._validate_search_index(field, existing) + await self._wait_for_search_index( + field, + timeout=float(timeout), + poll_interval=float(poll_interval), + ) + + async def ensure_collection_deleted(self, *, operation_options: Mapping[str, Any] | None = None) -> None: + """Drop this collection if it exists.""" + _validate_operation_options(operation_options) + if await self.collection_exists(): + await self._database.drop_collection(self.collection_name) + + @staticmethod + def _to_builtin_mapping(record: Any) -> Mapping[str, Any]: + if isinstance(record, Mapping): + return dict(cast(Mapping[str, Any], record)) + raise TypeError("MongoDB vector records must serialize to mappings.") + + def _serialize_dicts_to_store_models( + self, + records: Sequence[dict[str, Any]], + *, + context: Mapping[str, Any] | None = None, + ) -> Sequence[MongoDocument]: + del context + documents: list[MongoDocument] = [] + key_storage_name = self.definition.key_field_storage_name + for index, record in enumerate(records): + document: MongoDocument = {} + for field in self.definition.fields: + storage_name = field.storage_name or field.name + if field.field_type == "key": + value = record.get(storage_name) + if value is None and field.is_auto_generated: + value = ObjectId() + document["_id"] = _prepare_key(value, field.type_) + elif field.field_type == "vector": + value = record[storage_name] + document[storage_name] = None if value is None else _prepare_dense_vector(value, field.name) + else: + document[storage_name] = _prepare_data_value(field, record[storage_name]) + if key_storage_name != "_id": + document.pop(key_storage_name, None) + _validate_bson_document(document, record_index=index) + documents.append(document) + return documents + + def _deserialize_store_models_to_dicts( + self, + records: Sequence[Any], + *, + context: Mapping[str, Any] | None = None, + ) -> Sequence[dict[str, Any]]: + del context + result: list[dict[str, Any]] = [] + key_name = self.definition.key_field_storage_name + for raw_record in records: + if not isinstance(raw_record, Mapping): + raise TypeError("MongoDB responses must contain BSON documents.") + record = dict(cast(Mapping[str, Any], raw_record)) + if "_id" not in record: + raise IntegrationInvalidResponseException("MongoDB response is missing the '_id' field.") + record[key_name] = record.pop("_id") + result.append(record) + return result + + def _prepare_filter(self, filter: FilterExpression | None) -> MongoDocument: + if filter is None: + return {} + return {"$expr": _prepare_filter_expression(filter, self.definition)} + + def _prepare_vector_filter(self, filter: FilterExpression | None) -> MongoDocument | None: + if filter is None: + return None + return _prepare_vector_filter_condition(filter, self.definition) + + def _prepare_projection(self, include_vectors: bool) -> MongoDocument | None: + if include_vectors: + return None + return {field.storage_name or field.name: 0 for field in self.definition.vector_fields} + + def _prepare_order_by(self, order_by: Mapping[str, bool] | None) -> list[tuple[str, int]]: + result: list[tuple[str, int]] = [] + for name, ascending in (order_by or {}).items(): + if not isinstance(ascending, bool): + raise TypeError("MongoDB order directions must be booleans.") + field, storage_name = _prepare_filter_field(self.definition, name) + if field.type_ not in _VECTOR_FILTER_TYPES: + raise NotImplementedError( + f"MongoDB deterministic ordering does not support declared type '{field.type_}'." + ) + result.append((storage_name, 1 if ascending else -1)) + if "_id" not in {name for name, _ in result}: + result.append(("_id", 1)) + return result + + async def _inner_upsert( + self, + records: Sequence[Any], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[KeyT]: + options = _validate_operation_options(operation_options, {"ordered", "bypass_document_validation"}) + ordered = options.get("ordered", True) + bypass_document_validation = options.get("bypass_document_validation") + if not isinstance(ordered, bool): + raise TypeError("ordered must be a boolean.") + if bypass_document_validation is not None and not isinstance(bypass_document_validation, bool): + raise TypeError("bypass_document_validation must be a boolean or None.") + documents: list[MongoDocument] = [] + keys: list[KeyT] = [] + for index, raw_record in enumerate(records): + if not isinstance(raw_record, Mapping): + raise TypeError("MongoDB upsert records must be BSON documents.") + document = dict(cast(Mapping[str, Any], raw_record)) + _validate_bson_document(document, record_index=index) + key = _prepare_key(document.get("_id"), self.definition.key_field.type_) + document["_id"] = key + documents.append(document) + keys.append(cast(KeyT, key)) + if not ordered and len(keys) != len(set(keys)): + raise ValueError("MongoDB unordered upserts do not support duplicate keys in one batch.") + if not documents: + return [] + requests = [ + ReplaceOne({"_id": key}, document, upsert=True) for key, document in zip(keys, documents, strict=True) + ] + await self._collection.bulk_write( + requests, + ordered=ordered, + bypass_document_validation=bypass_document_validation, + ) + return keys + + async def _inner_get( + self, + *, + keys: Sequence[KeyT] | None = None, + filter: FilterExpression | None = None, + top: int = 10, + skip: int = 0, + order_by: Mapping[str, bool] | None = None, + include_vectors: bool = False, + operation_options: Mapping[str, Any] | None = None, + ) -> Sequence[MongoDocument]: + _validate_operation_options(operation_options) + projection = self._prepare_projection(include_vectors) + if keys is not None: + if order_by: + raise ValueError("order_by applies only to filtered retrieval, not key lookup.") + if skip: + raise ValueError("MongoDB key retrieval cannot be combined with skip.") + prepared_keys = [_prepare_key(key, self.definition.key_field.type_) for key in keys] + if not prepared_keys: + return [] + cursor = self._collection.find({"_id": {"$in": prepared_keys}}, projection=projection) + records = [record async for record in cursor] + by_key = {record["_id"]: record for record in records} + return [by_key[key] for key in prepared_keys if key in by_key] + native_filter = self._prepare_filter(filter) + sort = self._prepare_order_by(order_by) + if top == 0: + return [] + cursor = self._collection.find(native_filter, projection=projection).sort(sort).skip(skip).limit(top) + return [record async for record in cursor] + + async def _inner_delete( + self, + keys: Sequence[KeyT], + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + _validate_operation_options(operation_options) + prepared_keys = [_prepare_key(key, self.definition.key_field.type_) for key in keys] + if prepared_keys: + await self._collection.delete_many({"_id": {"$in": prepared_keys}}) + + async def _inner_search( + self, + *, + search_type: SearchType, + filter: FilterExpression | None = None, + values: Any | None = None, + vector: Vector | None = None, + top: int = 3, + skip: int = 0, + include_vectors: bool = False, + vector_property_name: str | None = None, + additional_property_name: str | None = None, + score_threshold: float | None = None, + operation_options: Mapping[str, Any] | None = None, + ) -> SearchResults[MongoDocument]: + del values + options = _validate_operation_options(operation_options, {"exact", "num_candidates"}) + if search_type != "vector" or additional_property_name is not None: + raise NotImplementedError("MongoDB keyword-hybrid search is not supported by this connector.") + if vector is None: + raise NotImplementedError( + "MongoDB has no configured server-side vectorization; supply a vector or embedding generator." + ) + field = self.definition.try_get_vector_field(vector_property_name) + if field is None: + raise ValueError("Select a vector_property_name from the MongoDB collection definition.") + exact = options.get("exact", False) + if not isinstance(exact, bool): + raise TypeError("exact must be a boolean.") + window = _prepare_int64(skip + top, "MongoDB vector search skip + top") + has_explicit_num_candidates = "num_candidates" in options + explicit_num_candidates = options.get("num_candidates") + if exact and has_explicit_num_candidates: + raise ValueError("num_candidates cannot be supplied when exact=True.") + if not exact and window > _MAX_NUM_CANDIDATES: + raise ValueError( + "MongoDB ANN search requires skip + top to be at most 10000; use exact=True for a larger window." + ) + if has_explicit_num_candidates: + num_candidates = _prepare_int64(explicit_num_candidates, "num_candidates") + if not 1 <= num_candidates <= _MAX_NUM_CANDIDATES: + raise ValueError("num_candidates must be between 1 and 10000.") + if num_candidates < window: + raise ValueError("num_candidates must be greater than or equal to skip + top.") + else: + num_candidates = None if exact else min(20 * window, _MAX_NUM_CANDIDATES) + if score_threshold is not None: + if ( + isinstance(score_threshold, bool) + or not isinstance(score_threshold, (int, float)) + or not math.isfinite(score_threshold) + or not 0 <= score_threshold <= 1 + ): + raise ValueError("MongoDB score_threshold must be a finite number between 0 and 1.") + score_threshold = float(score_threshold) + query = _prepare_dense_vector(vector, field.name) + native_filter = self._prepare_vector_filter(filter) + if top == 0: + return SearchResults([]) + search: MongoDocument = { + "index": self._index_name(field), + "path": field.storage_name or field.name, + "queryVector": query, + "limit": window, + } + if exact: + search["exact"] = True + else: + search["numCandidates"] = num_candidates + if native_filter is not None: + search["filter"] = native_filter + pipeline: list[MongoDocument] = [ + {"$vectorSearch": search}, + {"$set": {_SCORE_FIELD: {"$meta": "vectorSearchScore"}}}, + ] + if score_threshold is not None: + pipeline.append({"$match": {_SCORE_FIELD: {"$gte": score_threshold}}}) + if skip: + pipeline.append({"$skip": skip}) + pipeline.append({"$limit": top}) + projection = self._prepare_projection(include_vectors) + if projection: + pipeline.append({"$project": projection}) + cursor = await self._collection.aggregate(pipeline) + return SearchResults([record async for record in cursor]) + + def _get_record_from_result(self, result: Any) -> MongoDocument: + if not isinstance(result, Mapping): + raise IntegrationInvalidResponseException("Expected a MongoDB BSON search result.") + return dict(cast(Mapping[str, Any], result)) + + def _get_score_from_result(self, result: Any) -> float: + record = self._get_record_from_result(result) + score = record.get(_SCORE_FIELD) + if isinstance(score, bool) or not isinstance(score, (int, float)) or not math.isfinite(score): + raise IntegrationInvalidResponseException("MongoDB search result is missing a finite vectorSearchScore.") + return float(score) + + +class MongoDBStore(BaseVectorStore): + """Create MongoDB collection clients sharing one official async PyMongo client.""" + + def __init__( + self, + *, + uri: str | SecretString | None = None, + database_name: str | None = None, + app_name: str | None = None, + async_client: MongoClient | None = None, + embedding_generator: EmbeddingClient | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a MongoDB vector store. + + Args: + uri: MongoDB URI override; otherwise loaded from ``MONGODB_URI``. + database_name: Database name, or ``MONGODB_DATABASE_NAME`` for a connector-created client. + app_name: Optional driver app name, or ``MONGODB_APP_NAME``. + async_client: Existing PyMongo async client. It bypasses settings and remains caller-owned. + embedding_generator: Default embedding client inherited by collections. + env_file_path: Optional .env file selected explicitly. + env_file_encoding: Encoding for the selected .env file. + """ + client, database, resolved_database_name, owns_client = _create_client( + uri=uri, + database_name=database_name, + app_name=app_name, + async_client=async_client, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + super().__init__(embedding_generator=embedding_generator, managed_client=owns_client) + self.async_client = client + self._database = database + self.database_name = resolved_database_name + self._owns_client = owns_client + self._closed = False + + def get_collection( + self, + record_type: type[ModelT], + *, + definition: VectorStoreCollectionDefinition | None = None, + collection_name: str | None = None, + embedding_generator: EmbeddingClient | None = None, + ) -> MongoDBCollection[Any, ModelT]: + """Create a collection that borrows this store's resolved client and database.""" + return MongoDBCollection( + record_type, + async_client=self.async_client, + database_name=self.database_name, + definition=definition, + collection_name=collection_name, + embedding_generator=embedding_generator if embedding_generator is not None else self.embedding_generator, + ) + + async def list_collection_names(self, *, operation_options: Mapping[str, Any] | None = None) -> Sequence[str]: + """List MongoDB collection names in the configured database.""" + _validate_operation_options(operation_options) + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + return await self._database.list_collection_names() + + async def collection_exists( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> bool: + """Check collection existence without downloading the complete collection list.""" + _validate_operation_options(operation_options) + mark_feature_used(FeatureIndex.CORE_VECTOR_STORES) + return bool(await self._database.list_collection_names(filter={"name": collection_name})) + + async def _inner_ensure_collection_deleted( + self, + collection_name: str, + *, + operation_options: Mapping[str, Any] | None = None, + ) -> None: + _validate_operation_options(operation_options) + await self._database.drop_collection(collection_name) + + async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: + """Close a connector-owned client on context exit.""" + await self.close() + + async def close(self) -> None: + """Close a connector-created client once, leaving injected clients open.""" + if self._owns_client and not self._closed: + await self.async_client.close() + self._closed = True diff --git a/python/packages/mongodb/agent_framework_mongodb/py.typed b/python/packages/mongodb/agent_framework_mongodb/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/mongodb/pyproject.toml b/python/packages/mongodb/pyproject.toml new file mode 100644 index 00000000000..c6eb8a6eab0 --- /dev/null +++ b/python/packages/mongodb/pyproject.toml @@ -0,0 +1,67 @@ +[project] +name = "agent-framework-mongodb" +description = "MongoDB vector stores for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0a260909" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.18.0,<2", + "pymongo>=4.13.2,<5", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +timeout = 120 +markers = [ + "integration: requires a disposable MongoDB Atlas or Atlas Local deployment", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.ruff.lint.per-file-ignores] +"samples/**" = ["D", "INP", "commented-out-code", "RUF", "S", "print", "CPY"] +"tests/**" = ["D", "INP", "TD", "commented-out-code", "RUF", "S"] + +[tool.coverage.run] +omit = ["**/__init__.py"] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_mongodb"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.test] +help = "Run MongoDB connector unit tests." +cmd = 'pytest -m "not integration" --cov=agent_framework_mongodb --cov-report=term-missing:skip-covered tests' + +[tool.poe.tasks.test-integration] +help = "Run tests against MONGODB_TEST_URI and MONGODB_TEST_DATABASE." +cmd = 'pytest -m integration tests' + +[build-system] +requires = ["flit-core>=3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/mongodb/samples/README.md b/python/packages/mongodb/samples/README.md new file mode 100644 index 00000000000..2a704a8b0d0 --- /dev/null +++ b/python/packages/mongodb/samples/README.md @@ -0,0 +1,11 @@ +# MongoDB samples + +`mongodb_vectors.py` demonstrates deterministic dense vectors, indexed metadata +prefiltering, multiple vector fields, and vector exclusion during ordinary +retrieval. It creates and deletes a unique collection. + +Run MongoDB Atlas or +[Atlas Local](https://www.mongodb.com/docs/atlas/cli/current/atlas-cli-deploy-local/), +set `MONGODB_URI` and `MONGODB_DATABASE_NAME`, and follow the +[package connection guidance](../README.md#connection). No embedding service is +required. diff --git a/python/packages/mongodb/samples/mongodb_vectors.py b/python/packages/mongodb/samples/mongodb_vectors.py new file mode 100644 index 00000000000..11ab014131e --- /dev/null +++ b/python/packages/mongodb/samples/mongodb_vectors.py @@ -0,0 +1,70 @@ +# /// script +# dependencies = [ +# "agent-framework-core>=1.18.0,<2", +# "agent-framework-mongodb>=1.0.0a260909,<2", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Annotated +from uuid import uuid4 + +from agent_framework import Filter, VectorStoreField, vectorstoremodel + +from agent_framework_mongodb import MongoDBStore + +"""Store and search deterministic vectors with MongoDB Vector Search.""" + + +@vectorstoremodel +@dataclass +class Note: + id: Annotated[str, VectorStoreField("key")] + text: Annotated[str, VectorStoreField("data", storage_name="body")] + category: Annotated[str, VectorStoreField("data", is_indexed=True)] + title_vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=3)] = None + body_vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=3)] = None + + +async def main() -> None: + # 1. Resolve the URI and database through MongoDBSettings. + async with MongoDBStore() as store: + collection = store.get_collection(Note, collection_name=f"af_demo_{uuid4().hex}") + await collection.ensure_collection_exists() + try: + # 2. Store two independent vector fields without calling an embedding service. + await collection.upsert( + [ + Note("mongodb", "MongoDB supports vector search", "database", [1, 0, 0], [0, 1, 0]), + Note("travel", "A travel journal", "travel", [0, 1, 0], [1, 0, 0]), + ], + generate_vectors=False, + ) + + # 3. Select one vector index and apply an indexed metadata prefilter. + results = await collection.search( + vector=[1, 0, 0], + vector_property_name="title_vector", + filter=Filter("category", "eq", "database"), + ) + async for result in results: + print(result["record"].text, result["score"]) + + # 4. Retrieval excludes vectors unless explicitly requested. + note = (await collection.get(["mongodb"], include_vectors=True))[0] + print(note.body_vector) + finally: + await collection.ensure_collection_deleted() + + +if __name__ == "__main__": + asyncio.run(main()) + +# Expected output includes: +# MongoDB supports vector search +# [0.0, 1.0, 0.0] diff --git a/python/packages/mongodb/tests/mongodb/conftest.py b/python/packages/mongodb/tests/mongodb/conftest.py new file mode 100644 index 00000000000..8f0c8c91e27 --- /dev/null +++ b/python/packages/mongodb/tests/mongodb/conftest.py @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterable +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from agent_framework import VectorStoreCollectionDefinition, VectorStoreField +from pymongo import AsyncMongoClient +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.asynchronous.database import AsyncDatabase + +from agent_framework_mongodb import MongoDBCollection + + +class AsyncCursor: + def __init__(self, values: Iterable[dict[str, Any]]) -> None: + self.values = list(values) + self.sort_spec: list[tuple[str, int]] | None = None + self.skip_count = 0 + self.limit_count: int | None = None + + def sort(self, spec: list[tuple[str, int]]) -> AsyncCursor: + self.sort_spec = spec + return self + + def skip(self, count: int) -> AsyncCursor: + self.skip_count = count + return self + + def limit(self, count: int) -> AsyncCursor: + self.limit_count = count + return self + + def __aiter__(self) -> AsyncIterator[dict[str, Any]]: + async def iterate() -> AsyncIterator[dict[str, Any]]: + values = self.values[self.skip_count :] + if self.limit_count is not None: + values = values[: self.limit_count] + for value in values: + yield value + + return iterate() + + +@pytest.fixture +def cursor_factory(): + return AsyncCursor + + +@pytest.fixture(autouse=True) +def clear_mongodb_environment(monkeypatch): + for name in ("MONGODB_URI", "MONGODB_DATABASE_NAME", "MONGODB_APP_NAME"): + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def definition() -> VectorStoreCollectionDefinition: + return VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="int", storage_name="document_key"), + VectorStoreField("data", name="text", type_="str", storage_name="body"), + VectorStoreField("data", name="number", type_="float", is_indexed=True), + VectorStoreField("data", name="integer", type_="int", is_indexed=True), + VectorStoreField("data", name="flag", type_="bool", is_indexed=True), + VectorStoreField("data", name="tags", type_="list"), + VectorStoreField( + "vector", + name="embedding", + storage_name="dense_text", + dimensions=3, + provider_annotations={"mongodb.index_name": "text_vector_index"}, + ), + VectorStoreField( + "vector", + name="image", + storage_name="dense_image", + dimensions=3, + distance_function="dot_prod", + ), + ]) + + +@pytest.fixture +def record(): + def make(id: int = 1, **values: Any) -> dict[str, Any]: + return { + "id": id, + "text": "hello", + "number": float(id), + "integer": id, + "flag": True, + "tags": ["one", "two"], + "embedding": [1.0, 0.0, 0.0], + "image": [0.0, 1.0, 0.0], + } | values + + return make + + +@pytest.fixture +def mongo_mocks(): + client = AsyncMock(spec=AsyncMongoClient) + database = AsyncMock(spec=AsyncDatabase) + native_collection = AsyncMock(spec=AsyncCollection) + client.get_database.return_value = database + database.get_collection.return_value = native_collection + database.list_collection_names.return_value = ["test"] + return client, database, native_collection + + +@pytest.fixture +def collection(definition, mongo_mocks): + client, _, _ = mongo_mocks + return MongoDBCollection( + dict, + definition=definition, + collection_name="test", + async_client=client, + database_name="vectors", + ) diff --git a/python/packages/mongodb/tests/mongodb/test_filters.py b/python/packages/mongodb/tests/mongodb/test_filters.py new file mode 100644 index 00000000000..9b5e2f6c33e --- /dev/null +++ b/python/packages/mongodb/tests/mongodb/test_filters.py @@ -0,0 +1,223 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import math + +import pytest +from agent_framework import Filter, FilterGroup, VectorStoreCollectionDefinition, VectorStoreField +from bson import ObjectId +from bson.regex import Regex + + +def test_scalar_equality_uses_expr_type_and_existence_guards(collection): + integer = collection._prepare_filter(Filter("integer", "eq", 1.0)) + assert integer == { + "$expr": { + "$and": [ + {"$in": [{"$type": "$integer"}, ["int", "long"]]}, + {"$eq": ["$integer", {"$literal": 1}]}, + ] + } + } + boolean = collection._prepare_filter(Filter("integer", "eq", True)) + assert boolean == {"$expr": {"$literal": False}} + + +def test_null_missing_and_negation_are_distinct(collection): + assert collection._prepare_filter(Filter("text", "is_null")) == { + "$expr": { + "$and": [ + {"$ne": [{"$type": "$body"}, "missing"]}, + {"$eq": ["$body", None]}, + ] + } + } + not_equal = collection._prepare_filter(Filter("text", "ne", "x")) + assert not_equal == { + "$expr": { + "$and": [ + {"$ne": [{"$type": "$body"}, "missing"]}, + { + "$not": [ + { + "$and": [ + {"$eq": [{"$type": "$body"}, "string"]}, + {"$eq": ["$body", {"$literal": "x"}]}, + ] + } + ] + }, + ] + } + } + assert collection._prepare_filter(Filter("text", "exists")) == {"$expr": {"$ne": [{"$type": "$body"}, "missing"]}} + + +def test_list_whole_equality_and_membership_remain_separate(collection): + whole = collection._prepare_filter(Filter("tags", "eq", ["one"])) + assert whole == { + "$expr": { + "$and": [ + {"$isArray": "$tags"}, + {"$eq": ["$tags", {"$literal": ["one"]}]}, + ] + } + } + contains = collection._prepare_filter(Filter("tags", "contains", "one")) + assert contains == { + "$expr": { + "$cond": [ + {"$isArray": "$tags"}, + { + "$anyElementTrue": { + "$map": { + "input": "$tags", + "as": "item", + "in": {"$in": ["$$item", {"$literal": ["one"]}]}, + } + } + }, + False, + ] + } + } + assert collection._prepare_filter(Filter("tags", "contains_all", [])) == { + "$expr": { + "$cond": [ + {"$isArray": "$tags"}, + {"$setIsSubset": [{"$literal": []}, "$tags"]}, + False, + ] + } + } + + +@pytest.mark.parametrize("field_type", ["tuple", "set", "Sequence"]) +def test_non_list_collection_fields_reject_filters(mongo_mocks, field_type): + client, _, native_collection = mongo_mocks + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="int"), + VectorStoreField("data", name="items", type_=field_type), + ]) + from agent_framework_mongodb import MongoDBCollection + + collection = MongoDBCollection( + dict, + definition=definition, + collection_name="collection_shapes", + async_client=client, + database_name="vectors", + ) + with pytest.raises(NotImplementedError, match="container identity"): + collection._prepare_filter(Filter("items", "eq", ["one"])) + native_collection.find.assert_not_called() + + +@pytest.mark.parametrize( + "expression", + [ + Filter("tags", "eq", [{"b": 2, "a": 1}]), + Filter("tags", "in", [[{"a": 1}]]), + Filter("tags", "contains", {"a": 1}), + Filter("tags", "contains", ("one",)), + Filter("tags", "contains_any", [{"a": 1}]), + ], +) +async def test_collection_filters_reject_lossy_operand_shapes(collection, mongo_mocks, expression): + _, _, native_collection = mongo_mocks + with pytest.raises(NotImplementedError): + await collection.get(filter=expression) + native_collection.find.assert_not_called() + + +def test_in_not_in_ignore_null_and_incompatible_operands(collection): + assert collection._prepare_filter(Filter("integer", "in", [1, 1.0, True, None, "1"])) == { + "$expr": { + "$and": [ + {"$in": [{"$type": "$integer"}, ["int", "long"]]}, + {"$in": ["$integer", {"$literal": [1, 1]}]}, + ] + } + } + not_in = collection._prepare_filter(Filter("integer", "not_in", [])) + assert not_in["$expr"]["$and"][0] == {"$in": [{"$type": "$integer"}, ["int", "long"]]} + + +@pytest.mark.parametrize( + ("operator", "value", "pattern"), + [ + ("starts_with", "^.*[x]$", r"^\^\.\*\[x\]\$"), + ("ends_with", "^.*[x]$", r"\^\.\*\[x\]\$$"), + ("contains_text", "^.*[x]$", r"\^\.\*\[x\]\$"), + ], +) +def test_literal_text_uses_escaped_regex(collection, operator, value, pattern): + native = collection._prepare_filter(Filter("text", operator, value)) + regex = native["$expr"]["$cond"][1]["$regexMatch"]["regex"] + assert isinstance(regex, Regex) + assert regex.pattern == pattern + + +def test_groups_preserve_whole_not_semantics(collection): + expression = FilterGroup( + "not", + [FilterGroup("or", [Filter("text", "eq", "x"), Filter("integer", "gt", 2)])], + ) + native = collection._prepare_filter(expression) + assert "$not" in native["$expr"] + assert "$or" in native["$expr"]["$not"][0] + + +@pytest.mark.parametrize("value", [2**63, -(2**63) - 1, math.inf, math.nan]) +async def test_invalid_query_numbers_fail_before_io(collection, mongo_mocks, value): + _, _, native_collection = mongo_mocks + with pytest.raises(ValueError): + await collection.get(filter=Filter("integer", "eq", value)) + native_collection.find.assert_not_called() + + +@pytest.mark.parametrize( + "expression", + [ + Filter("tags", "eq", ["one"]), + Filter("tags", "contains", "one"), + Filter("text", "contains_text", "one"), + Filter("text", "is_null"), + Filter("text", "exists"), + Filter("text", "ne", "one"), + Filter("text", "not_in", ["one"]), + FilterGroup("not", [Filter("text", "eq", "one")]), + ], +) +def test_vector_prefilter_rejects_non_equivalent_operations(collection, expression): + with pytest.raises(NotImplementedError): + collection._prepare_vector_filter(expression) + + +def test_vector_prefilter_requires_indexed_scalar_field(collection): + with pytest.raises(NotImplementedError, match="is_indexed"): + collection._prepare_vector_filter(Filter("text", "eq", "hello")) + assert collection._prepare_vector_filter(Filter("number", "between", [1, 3])) == {"number": {"$gte": 1, "$lte": 3}} + assert collection._prepare_vector_filter(Filter("integer", "eq", True)) == {"_id": {"$in": []}} + + +def test_object_id_filter_keeps_native_identity(mongo_mocks): + client, _, _ = mongo_mocks + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="ObjectId"), + VectorStoreField("vector", name="vector", dimensions=2), + ]) + from agent_framework_mongodb import MongoDBCollection + + collection = MongoDBCollection( + dict, + definition=definition, + collection_name="objects", + async_client=client, + database_name="vectors", + ) + key = ObjectId() + assert collection._prepare_filter(Filter("id", "eq", key))["$expr"]["$and"][1] == { + "$eq": ["$_id", {"$literal": key}] + } diff --git a/python/packages/mongodb/tests/mongodb/test_integration.py b/python/packages/mongodb/tests/mongodb/test_integration.py new file mode 100644 index 00000000000..a86bdb4b8ac --- /dev/null +++ b/python/packages/mongodb/tests/mongodb/test_integration.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass +from typing import Any +from uuid import uuid4 + +import pytest +from agent_framework import ( + Filter, + FilterGroup, + InMemoryCollection, + SecretString, + VectorStoreCollectionDefinition, + VectorStoreField, + register_vectorstoremodel, +) +from bson import ObjectId +from pymongo import AsyncMongoClient +from pymongo.errors import InvalidOperation + +from agent_framework_mongodb import MongoDBCollection, MongoDBStore + +pytestmark = [ + pytest.mark.integration, + pytest.mark.flaky, + pytest.mark.skipif( + not os.getenv("MONGODB_TEST_URI"), + reason="Set MONGODB_TEST_URI to a disposable Atlas or Atlas Local deployment.", + ), +] + + +@pytest.fixture +async def mongodb_client(): + client: AsyncMongoClient[dict[str, Any]] = AsyncMongoClient(os.environ["MONGODB_TEST_URI"]) + try: + yield client + finally: + await client.close() + + +@pytest.fixture +def integration_definition() -> VectorStoreCollectionDefinition: + return VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="int"), + VectorStoreField("data", name="text", type_="str"), + VectorStoreField("data", name="category", type_="str", is_indexed=True), + VectorStoreField("data", name="number", type_="float", is_indexed=True), + VectorStoreField("data", name="integer", type_="int", is_indexed=True), + VectorStoreField("data", name="flag", type_="bool", is_indexed=True), + VectorStoreField("data", name="tags", type_="list"), + VectorStoreField("vector", name="vector", dimensions=3), + ]) + + +@pytest.fixture +async def collections(mongodb_client, integration_definition): + name = f"af_mongodb_test_{uuid4().hex}" + database_name = os.getenv("MONGODB_TEST_DATABASE", "af_vectors_test") + mongodb = MongoDBCollection( + dict, + definition=integration_definition, + collection_name=name, + async_client=mongodb_client, + database_name=database_name, + ) + memory = InMemoryCollection(dict, definition=integration_definition, collection_name=name) + await mongodb.ensure_collection_exists(operation_options={"index_timeout": 300}) + await memory.ensure_collection_exists() + try: + yield mongodb, memory + finally: + await mongodb.ensure_collection_deleted() + + +async def test_crud_search_and_filter_parity(collections): + mongodb, memory = collections + records: list[dict[str, Any]] = [ + { + "id": 1, + "text": "alpha .* [literal]", + "category": "database", + "number": 1.0, + "integer": 1, + "flag": True, + "tags": ["one", ["nested"]], + "vector": [1.0, 0.0, 0.0], + }, + { + "id": 2, + "text": "beta", + "category": "database", + "number": 2.0, + "integer": 2, + "flag": False, + "tags": [], + "vector": [0.9, 0.1, 0.0], + }, + { + "id": 3, + "text": None, + "category": "travel", + "number": 3.0, + "integer": 3, + "flag": True, + "tags": ["two"], + "vector": [0.0, 1.0, 0.0], + }, + ] + for collection in (mongodb, memory): + await collection.upsert(records, generate_vectors=False) + + expressions = [ + Filter("integer", "eq", 1), + Filter("integer", "ne", 1), + Filter("number", "eq", 1), + Filter("number", "gt", 1), + Filter("number", "gte", 2), + Filter("number", "lt", 3), + Filter("number", "lte", 2), + Filter("number", "between", [1, 2]), + Filter("integer", "in", [1, 3]), + Filter("integer", "not_in", [1, 3]), + Filter("flag", "eq", 1), + Filter("text", "eq", "beta"), + Filter("text", "ne", "beta"), + Filter("tags", "eq", []), + Filter("tags", "ne", []), + Filter("tags", "in", [[], ["two"]]), + Filter("tags", "not_in", [["one"]]), + Filter("tags", "contains", ["nested"]), + Filter("tags", "contains_any", ["two", "missing"]), + Filter("tags", "contains_all", []), + Filter("text", "starts_with", "alpha"), + Filter("text", "ends_with", "beta"), + Filter("text", "contains_text", ".* [literal]"), + Filter("text", "is_null"), + Filter("text", "is_not_null"), + FilterGroup("and", [Filter("category", "eq", "database"), Filter("number", "gte", 2)]), + FilterGroup("or", [Filter("integer", "eq", 1), Filter("integer", "eq", 3)]), + FilterGroup("not", [Filter("flag", "eq", True)]), + ] + for expression in expressions: + mongo_ids = [item["id"] for item in await mongodb.get(filter=expression, top=10)] + memory_ids = [item["id"] for item in await memory.get(filter=expression, top=10)] + assert mongo_ids == memory_ids, expression + + loaded = await mongodb.get([2, 1], include_vectors=True) + assert [item["id"] for item in loaded] == [2, 1] + assert loaded[0]["vector"] == [0.9, 0.1, 0.0] + assert "vector" not in (await mongodb.get([1]))[0] + + results = [] + for _ in range(40): + results = [ + item + async for item in await mongodb.search( + vector=[1, 0, 0], + filter=Filter("category", "eq", "database"), + score_threshold=0.5, + operation_options={"exact": True}, + ) + ] + if results: + break + await asyncio.sleep(0.25) + assert [item["record"]["id"] for item in results] == [1, 2] + assert all(0 <= item["score"] <= 1 for item in results) + + ann_results = [ + item + async for item in await mongodb.search( + vector=[1, 0, 0], + filter=Filter("category", "eq", "database"), + top=1, + skip=1, + operation_options={"num_candidates": 40}, + ) + ] + assert [item["record"]["id"] for item in ann_results] == [2] + + await mongodb.delete([1, 999]) + assert await mongodb.get([1]) == [] + + +async def test_missing_fields_do_not_match_null_or_negative_filters(collections): + mongodb, _ = collections + await mongodb._collection.insert_one({ + "_id": 100, + "category": "external", + "number": 100.0, + "integer": 100, + "flag": False, + "tags": [], + "vector": [0.0, 0.0, 1.0], + }) + assert await mongodb.get(filter=Filter("text", "is_null")) == [] + assert await mongodb.get(filter=Filter("text", "ne", "anything")) == [] + assert await mongodb.get(filter=Filter("text", "not_in", ["anything"])) == [] + assert [item["id"] for item in await mongodb.get(filter=Filter("text", "exists"))] == [] + + +async def test_realistic_multiple_1536_dimension_indexes(mongodb_client): + dimensions = 1536 + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="int"), + VectorStoreField("data", name="group", type_="int", is_indexed=True), + VectorStoreField("vector", name="first", dimensions=dimensions), + VectorStoreField("vector", name="second", dimensions=dimensions, distance_function="dot_prod"), + ]) + collection = MongoDBCollection( + dict, + definition=definition, + collection_name=f"af_mongodb_scale_{uuid4().hex}", + async_client=mongodb_client, + database_name=os.getenv("MONGODB_TEST_DATABASE", "af_vectors_test"), + ) + await collection.ensure_collection_exists(operation_options={"create_indexes": False}) + try: + records: list[dict[str, Any]] = [ + { + "id": index, + "group": index % 2, + "first": [1.0 if position == index % dimensions else 0.0 for position in range(dimensions)], + "second": [1.0 if position == (index + 1) % dimensions else 0.0 for position in range(dimensions)], + } + for index in range(1000) + ] + assert len(await collection.upsert(records, generate_vectors=False)) == 1000 + await collection.ensure_collection_exists(operation_options={"index_timeout": 300}) + results = [ + item + async for item in await collection.search( + vector=records[10]["second"], + vector_property_name="second", + top=2, + operation_options={"exact": True}, + ) + ] + assert results[0]["record"]["id"] == 10 + finally: + await collection.ensure_collection_deleted() + + +async def test_generated_object_id_custom_codec_round_trip(mongodb_client): + @dataclass + class Document: + id: ObjectId | None + text: str + vector: list[float] | None = None + + register_vectorstoremodel( + Document, + definition=VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="ObjectId", is_auto_generated=True), + VectorStoreField("data", name="text", type_="str"), + VectorStoreField("vector", name="vector", dimensions=3), + ]), + encoder=lambda item: {"id": item.id, "text": item.text, "vector": item.vector}, + decoder=lambda item: Document(item["id"], item["text"], item.get("vector")), + ) + collection = MongoDBCollection( + Document, + collection_name=f"af_mongodb_objectid_{uuid4().hex}", + async_client=mongodb_client, + database_name=os.getenv("MONGODB_TEST_DATABASE", "af_vectors_test"), + ) + await collection.ensure_collection_exists(operation_options={"index_timeout": 300}) + try: + key = (await collection.upsert([Document(None, "hello", [1, 0, 0])], generate_vectors=False))[0] + assert isinstance(key, ObjectId) + assert await collection.get([key]) == [Document(key, "hello")] + assert await collection.get([key], include_vectors=True) == [Document(key, "hello", [1.0, 0.0, 0.0])] + await collection.delete([key]) + assert await collection.get([key]) == [] + finally: + await collection.ensure_collection_deleted() + + +async def test_connector_owned_uri_client_lifecycle(): + store = MongoDBStore( + uri=SecretString(os.environ["MONGODB_TEST_URI"]), + database_name=os.getenv("MONGODB_TEST_DATABASE", "af_vectors_test"), + app_name="agent-framework-mongodb-integration", + ) + async with store: + await store.list_collection_names() + with pytest.raises(InvalidOperation, match="after close"): + await store.list_collection_names() diff --git a/python/packages/mongodb/tests/mongodb/test_settings.py b/python/packages/mongodb/tests/mongodb/test_settings.py new file mode 100644 index 00000000000..931b6dfec08 --- /dev/null +++ b/python/packages/mongodb/tests/mongodb/test_settings.py @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from functools import partial +from unittest.mock import AsyncMock, patch + +import pytest +from agent_framework import SecretString, load_settings +from pymongo import AsyncMongoClient +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.asynchronous.database import AsyncDatabase + +from agent_framework_mongodb import MongoDBCollection, MongoDBSettings, MongoDBStore + + +@pytest.fixture(params=["collection", "store"]) +def connector_factory(request, definition): + if request.param == "collection": + return partial(MongoDBCollection, dict, definition=definition, collection_name="test") + return MongoDBStore + + +def _client_mock(): + client = AsyncMock(spec=AsyncMongoClient) + database = AsyncMock(spec=AsyncDatabase) + database.get_collection.return_value = AsyncMock(spec=AsyncCollection) + client.get_database.return_value = database + return client + + +def test_settings_mask_uri(monkeypatch): + monkeypatch.setenv("MONGODB_URI", "mongodb://user:password@example.test") + monkeypatch.setenv("MONGODB_DATABASE_NAME", "vectors") + settings = load_settings(MongoDBSettings, env_prefix="MONGODB_") + uri = settings["uri"] + assert isinstance(uri, SecretString) + assert uri.get_secret_value() == "mongodb://user:password@example.test" + assert "password" not in repr(settings) + + +async def test_environment_settings_and_owned_close(connector_factory, monkeypatch): + monkeypatch.setenv("MONGODB_URI", "mongodb://environment.test") + monkeypatch.setenv("MONGODB_DATABASE_NAME", "environment_db") + monkeypatch.setenv("MONGODB_APP_NAME", "environment-app") + client = _client_mock() + with patch("agent_framework_mongodb._vector_store.AsyncMongoClient", return_value=client) as factory: + async with connector_factory(): + pass + factory.assert_called_once_with("mongodb://environment.test", appname="environment-app") + client.get_database.assert_called_once_with("environment_db") + client.close.assert_awaited_once() + + +@pytest.mark.parametrize("encoding", ["utf-8", "utf-16"]) +async def test_env_file_overrides_environment(connector_factory, monkeypatch, tmp_path, encoding): + monkeypatch.setenv("MONGODB_URI", "mongodb://environment.test") + monkeypatch.setenv("MONGODB_DATABASE_NAME", "environment_db") + env_file = tmp_path / "mongodb.env" + env_file.write_text( + "MONGODB_URI=mongodb://file.test\nMONGODB_DATABASE_NAME=file_db\nMONGODB_APP_NAME=file-app\n", + encoding=encoding, + ) + client = _client_mock() + with patch("agent_framework_mongodb._vector_store.AsyncMongoClient", return_value=client) as factory: + async with connector_factory(env_file_path=str(env_file), env_file_encoding=encoding): + pass + factory.assert_called_once_with("mongodb://file.test", appname="file-app") + client.get_database.assert_called_once_with("file_db") + + +async def test_explicit_settings_override_file_and_environment(connector_factory, monkeypatch, tmp_path): + monkeypatch.setenv("MONGODB_URI", "mongodb://environment.test") + monkeypatch.setenv("MONGODB_DATABASE_NAME", "environment_db") + env_file = tmp_path / "mongodb.env" + env_file.write_text("MONGODB_URI=mongodb://file.test\nMONGODB_DATABASE_NAME=file_db\n") + client = _client_mock() + with patch("agent_framework_mongodb._vector_store.AsyncMongoClient", return_value=client) as factory: + async with connector_factory( + uri=SecretString("mongodb://explicit.test"), + database_name="explicit_db", + app_name="explicit-app", + env_file_path=str(env_file), + ): + pass + factory.assert_called_once_with("mongodb://explicit.test", appname="explicit-app") + client.get_database.assert_called_once_with("explicit_db") + + +@pytest.mark.parametrize( + "settings", + [ + {}, + {"uri": "mongodb://localhost"}, + {"database_name": "vectors"}, + {"uri": "", "database_name": "vectors"}, + {"uri": "mongodb://localhost", "database_name": ""}, + ], +) +def test_required_settings_fail_before_sdk_creation(connector_factory, settings): + with ( + patch("agent_framework_mongodb._vector_store.AsyncMongoClient") as factory, + pytest.raises(ValueError), + ): + connector_factory(**settings) + factory.assert_not_called() + + +async def test_injected_client_bypasses_settings_and_remains_borrowed(connector_factory, monkeypatch): + monkeypatch.setenv("MONGODB_URI", "mongodb://unrelated.test") + monkeypatch.setenv("MONGODB_DATABASE_NAME", "unrelated") + client = _client_mock() + with patch("agent_framework_mongodb._vector_store.load_settings") as loader: + async with connector_factory(async_client=client, database_name="vectors") as connector: + assert connector.async_client is client + loader.assert_not_called() + client.close.assert_not_awaited() + + +@pytest.mark.parametrize( + "settings", + [ + {"uri": "mongodb://unused"}, + {"app_name": "unused"}, + {"env_file_path": "unused.env"}, + {"env_file_encoding": "utf-16"}, + ], +) +def test_injected_client_rejects_connection_settings(connector_factory, settings): + client = _client_mock() + with ( + patch("agent_framework_mongodb._vector_store.load_settings") as loader, + pytest.raises(ValueError, match="async_client cannot be combined"), + ): + connector_factory(async_client=client, database_name="vectors", **settings) + loader.assert_not_called() + + +def test_store_collections_reuse_resolved_client_without_settings(definition): + client = _client_mock() + store = MongoDBStore(async_client=client, database_name="vectors") + with patch("agent_framework_mongodb._vector_store.load_settings") as loader: + collection = store.get_collection(dict, definition=definition, collection_name="test") + loader.assert_not_called() + assert collection.async_client is client + assert not collection.managed_client diff --git a/python/packages/mongodb/tests/mongodb/test_vector_store.py b/python/packages/mongodb/tests/mongodb/test_vector_store.py new file mode 100644 index 00000000000..7037676de05 --- /dev/null +++ b/python/packages/mongodb/tests/mongodb/test_vector_store.py @@ -0,0 +1,613 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Annotated, Any, cast +from unittest.mock import AsyncMock + +import pytest +from agent_framework import ( + Embedding, + Filter, + GeneratedEmbeddings, + VectorStoreCollectionDefinition, + VectorStoreField, + register_vectorstoremodel, + vectorstoremodel, +) +from agent_framework.exceptions import IntegrationException, IntegrationInvalidResponseException +from bson import ObjectId +from pymongo import ReplaceOne +from pymongo.errors import OperationFailure + +from agent_framework_mongodb import MongoDBCollection, MongoDBStore +from agent_framework_mongodb._vector_store import ( + _BSON_INT64_MAX, + _default_index_name, + _semantic_index_definition, +) + + +def _ready_index(collection: MongoDBCollection[Any, Any], field_name: str) -> dict[str, Any]: + field = collection.definition.try_get_vector_field(field_name) + assert field is not None + return { + "name": collection._index_name(field), + "type": "vectorSearch", + "queryable": True, + "status": "READY", + "latestDefinition": collection._index_definition(field), + "extraStatusMetadata": {"ignored": True}, + } + + +def test_model_validation_and_index_mapping(collection): + assert collection._index_name(collection.definition.vector_fields[0]) == "text_vector_index" + generated = collection._index_name(collection.definition.vector_fields[1]) + assert generated.startswith("af_dense_image_") + first = collection._index_definition(collection.definition.vector_fields[0]) + assert first == { + "fields": [ + { + "type": "vector", + "path": "dense_text", + "numDimensions": 3, + "similarity": "cosine", + }, + {"type": "filter", "path": "_id"}, + {"type": "filter", "path": "number"}, + {"type": "filter", "path": "integer"}, + {"type": "filter", "path": "flag"}, + ] + } + + +@pytest.mark.parametrize("field_type", ["data", "vector"]) +def test_non_key_fields_cannot_use_reserved_id_storage_name(mongo_mocks, field_type): + client, _, native_collection = mongo_mocks + field = ( + VectorStoreField("data", name="value", type_="str", storage_name="_id") + if field_type == "data" + else VectorStoreField("vector", name="value", dimensions=2, storage_name="_id") + ) + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="int"), + field, + ]) + with pytest.raises(ValueError, match="reserves storage name '_id'"): + MongoDBCollection( + dict, + definition=definition, + collection_name="reserved_id", + async_client=client, + database_name="vectors", + ) + native_collection.bulk_write.assert_not_awaited() + + +@pytest.mark.parametrize("include_default_collision", [False, True]) +def test_duplicate_resolved_index_names_fail_before_client_access(mongo_mocks, include_default_collision): + client, _, native_collection = mongo_mocks + collection_name = "duplicate_indexes" + first_name = _default_index_name(collection_name, "first") if include_default_collision else "shared" + first_annotations = {} if include_default_collision else {"mongodb.index_name": first_name} + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="int"), + VectorStoreField( + "vector", + name="first", + dimensions=2, + provider_annotations=first_annotations, + ), + VectorStoreField( + "vector", + name="second", + dimensions=2, + provider_annotations={"mongodb.index_name": first_name}, + ), + ]) + with pytest.raises(ValueError, match="vector index names must be unique"): + MongoDBCollection( + dict, + definition=definition, + collection_name=collection_name, + async_client=client, + database_name="vectors", + ) + client.get_database.assert_not_called() + native_collection.create_search_index.assert_not_awaited() + + +def test_invalid_dimensions_rejected(mongo_mocks): + client, _, native_collection = mongo_mocks + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="str"), + VectorStoreField("vector", name="vector", dimensions=8193), + ]) + with pytest.raises(ValueError, match="dimensions"): + MongoDBCollection( + dict, + definition=definition, + collection_name="test", + async_client=client, + database_name="vectors", + ) + native_collection.create_search_index.assert_not_awaited() + + +def test_index_semantics_ignore_order_and_status_metadata(collection): + expected = collection._index_definition(collection.definition.vector_fields[0]) + reordered = {"fields": list(reversed(expected["fields"]))} + assert _semantic_index_definition(expected) == _semantic_index_definition(reordered) + index = _ready_index(collection, "embedding") + index["latestDefinition"] = reordered + assert collection._validate_search_index(collection.definition.vector_fields[0], index) + + +def test_incompatible_index_rejected(collection): + index = _ready_index(collection, "embedding") + index["latestDefinition"]["fields"][0]["numDimensions"] = 42 + with pytest.raises(ValueError, match="does not match"): + collection._validate_search_index(collection.definition.vector_fields[0], index) + + +async def test_create_collection_and_indexes_with_readiness_poll(collection, mongo_mocks): + _, database, native_collection = mongo_mocks + database.list_collection_names.side_effect = [[], ["test"]] + ready_indexes = [_ready_index(collection, "embedding"), _ready_index(collection, "image")] + collection._list_search_index = AsyncMock(side_effect=[None, ready_indexes[0], None, ready_indexes[1]]) + await collection.ensure_collection_exists(operation_options={"poll_interval": 0.001}) + database.create_collection.assert_awaited_once_with("test", check_exists=False) + assert native_collection.create_search_index.await_count == 2 + models = [call.kwargs["model"].document for call in native_collection.create_search_index.await_args_list] + assert [model["name"] for model in models] == [ + "text_vector_index", + collection._index_name(collection.definition.vector_fields[1]), + ] + + +async def test_collection_and_index_races_are_reconciled(collection, mongo_mocks): + _, database, native_collection = mongo_mocks + database.list_collection_names.side_effect = [[], ["test"]] + database.create_collection.side_effect = OperationFailure("namespace exists", code=48) + ready = _ready_index(collection, "embedding") + collection.definition = VectorStoreCollectionDefinition( + collection.definition.fields[:6] + (collection.definition.fields[6],) + ) + collection._list_search_index = AsyncMock(side_effect=[None, ready, ready]) + native_collection.create_search_index.side_effect = OperationFailure("index exists", code=68) + await collection.ensure_collection_exists(operation_options={"poll_interval": 0.001}) + native_collection.create_search_index.assert_awaited_once() + + +async def test_index_readiness_timeout_is_bounded(collection): + collection.definition = VectorStoreCollectionDefinition( + collection.definition.fields[:6] + (collection.definition.fields[6],) + ) + collection._list_search_index = AsyncMock( + return_value={ + **_ready_index(collection, "embedding"), + "queryable": False, + "status": "BUILDING", + } + ) + with pytest.raises(TimeoutError, match="not queryable"): + await collection.ensure_collection_exists(operation_options={"index_timeout": 0.002, "poll_interval": 0.001}) + + +async def test_index_terminal_failure_stops_polling(collection): + field = collection.definition.vector_fields[0] + collection._list_search_index = AsyncMock( + return_value={ + **_ready_index(collection, "embedding"), + "queryable": False, + "status": "FAILED", + } + ) + with pytest.raises(ValueError, match="terminal status"): + await collection._wait_for_search_index(field, timeout=1, poll_interval=0.001) + + +async def test_late_invalid_batch_has_no_write(collection, mongo_mocks, record): + _, _, native_collection = mongo_mocks + with pytest.raises(ValueError): + await collection.upsert( + [record(1), record(2, integer=2**63)], + generate_vectors=False, + ) + native_collection.bulk_write.assert_not_awaited() + + +async def test_undeclared_non_bson_dict_field_is_ignored(collection, mongo_mocks, record): + _, _, native_collection = mongo_mocks + value = record(1) | {"application_only": object()} + assert await collection.upsert([value], generate_vectors=False) == [1] + document = native_collection.bulk_write.await_args.args[0][0]._doc + assert "application_only" not in document + + +async def test_undeclared_non_bson_custom_encoder_field_is_ignored(mongo_mocks): + client, _, native_collection = mongo_mocks + + @dataclass + class External: + key: int + text: str + + register_vectorstoremodel( + External, + definition=VectorStoreCollectionDefinition([ + VectorStoreField("key", name="key", type_="int"), + VectorStoreField("data", name="text", type_="str"), + ]), + encoder=lambda item: {"key": item.key, "text": item.text, "application_only": object()}, + decoder=lambda item: External(item["key"], item["text"]), + ) + collection = MongoDBCollection( + External, + collection_name="custom_extra", + async_client=client, + database_name="vectors", + ) + assert await collection.upsert([External(1, "hello")], generate_vectors=False) == [1] + document = native_collection.bulk_write.await_args.args[0][0]._doc + assert document == {"_id": 1, "text": "hello"} + + +async def test_declared_non_bson_value_still_fails_before_io(mongo_mocks): + client, _, native_collection = mongo_mocks + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="int"), + VectorStoreField("data", name="value"), + ]) + collection = MongoDBCollection( + dict, + definition=definition, + collection_name="declared_invalid", + async_client=client, + database_name="vectors", + ) + with pytest.raises(TypeError, match="unsupported BSON value"): + await collection.upsert([{"id": 1, "value": object()}], generate_vectors=False) + native_collection.bulk_write.assert_not_awaited() + + +@pytest.mark.parametrize("key", [True, 1.5, 2**63, "1", None]) +async def test_strict_crud_keys_fail_before_io(collection, mongo_mocks, record, key): + _, _, native_collection = mongo_mocks + with pytest.raises((TypeError, ValueError)): + await collection.upsert([record(1), record(key)], generate_vectors=False) + with pytest.raises((TypeError, ValueError)): + await collection.get([key]) + with pytest.raises(IntegrationException): + await collection.delete([key]) + native_collection.bulk_write.assert_not_awaited() + native_collection.find.assert_not_called() + native_collection.delete_many.assert_not_awaited() + + +async def test_oversized_document_has_no_write(collection, mongo_mocks, record): + _, _, native_collection = mongo_mocks + with pytest.raises(ValueError, match="16 MiB"): + await collection.upsert( + [record(1), record(2, text="x" * (16 * 1024 * 1024))], + generate_vectors=False, + ) + native_collection.bulk_write.assert_not_awaited() + + +@pytest.mark.parametrize("vector", [[1, 2], [math.nan, 0, 0], [True, 0, 0], b"abc"]) +async def test_invalid_vectors_fail_before_dispatch(collection, mongo_mocks, record, vector): + _, _, native_collection = mongo_mocks + with pytest.raises((TypeError, ValueError)): + await collection.upsert([record(1), record(2, embedding=vector)], generate_vectors=False) + native_collection.bulk_write.assert_not_awaited() + + +async def test_bulk_upsert_aliases_and_options(collection, mongo_mocks, record): + _, _, native_collection = mongo_mocks + records = [record(1), record(2)] + assert await collection.upsert( + records, + generate_vectors=False, + operation_options={"ordered": False, "bypass_document_validation": True}, + ) == [1, 2] + requests = native_collection.bulk_write.await_args.args[0] + assert len(requests) == 2 and all(isinstance(request, ReplaceOne) for request in requests) + assert native_collection.bulk_write.await_args.kwargs == { + "ordered": False, + "bypass_document_validation": True, + } + replacement = requests[0]._doc + assert replacement["_id"] == 1 + assert "document_key" not in replacement + assert replacement["body"] == "hello" + assert set(replacement) >= {"dense_text", "dense_image"} + + +async def test_unordered_duplicate_keys_rejected(collection, mongo_mocks, record): + _, _, native_collection = mongo_mocks + with pytest.raises(ValueError, match="duplicate keys"): + await collection.upsert( + [record(1), record(1, text="replacement")], + generate_vectors=False, + operation_options={"ordered": False}, + ) + native_collection.bulk_write.assert_not_awaited() + + +async def test_selective_embedding_generation(collection, mongo_mocks, record): + _, _, native_collection = mongo_mocks + generator = AsyncMock() + generator.get_embeddings.return_value = GeneratedEmbeddings([Embedding(vector=[2.0, 0, 0])]) + collection.embedding_generator = generator + value = record(1, embedding="source", image=[0, 1, 0]) + await collection.upsert([value], generate_vectors=["embedding"]) + generator.get_embeddings.assert_awaited_once_with(["source"], options={"dimensions": 3}) + replacement = native_collection.bulk_write.await_args.args[0][0]._doc + assert replacement["dense_text"] == [2, 0, 0] + assert replacement["dense_image"] == [0, 1, 0] + assert value["embedding"] == "source" + + +async def test_generated_object_id_dict_crud_and_projection(mongo_mocks, cursor_factory): + client, _, native_collection = mongo_mocks + definition = VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="ObjectId", is_auto_generated=True), + VectorStoreField("data", name="text", type_="str"), + VectorStoreField("vector", name="vector", dimensions=2), + ]) + collection = MongoDBCollection( + dict, + definition=definition, + collection_name="objects", + async_client=client, + database_name="vectors", + ) + keys = await collection.upsert([{"text": "hello", "vector": [1, 0]}], generate_vectors=False) + assert len(keys) == 1 and isinstance(keys[0], ObjectId) + native_collection.find.return_value = cursor_factory([{"_id": keys[0], "text": "hello"}]) + assert await collection.get(keys) == [{"id": keys[0], "text": "hello"}] + assert native_collection.find.call_args.kwargs["projection"] == {"vector": 0} + await collection.delete(keys) + native_collection.delete_many.assert_awaited_once_with({"_id": {"$in": keys}}) + + +async def test_typed_object_id_requires_and_supports_custom_codec(mongo_mocks, cursor_factory): + client, _, native_collection = mongo_mocks + + @dataclass + class Document: + id: ObjectId | None + text: str + vector: list[float] | None = None + + register_vectorstoremodel( + Document, + definition=VectorStoreCollectionDefinition([ + VectorStoreField("key", name="id", type_="ObjectId", is_auto_generated=True), + VectorStoreField("data", name="text", type_="str"), + VectorStoreField("vector", name="vector", dimensions=2), + ]), + encoder=lambda item: {"id": item.id, "text": item.text, "vector": item.vector}, + decoder=lambda item: Document(item["id"], item["text"], item.get("vector")), + ) + collection = MongoDBCollection( + Document, + collection_name="typed_objects", + async_client=client, + database_name="vectors", + ) + key = (await collection.upsert([Document(None, "hello", [1, 0])], generate_vectors=False))[0] + native_collection.find.return_value = cursor_factory([{"_id": key, "text": "hello", "vector": [1.0, 0.0]}]) + assert (await collection.get([key], include_vectors=True))[0] == Document(key, "hello", [1.0, 0.0]) + + +async def test_default_typed_object_id_codec_fails_before_write(mongo_mocks): + client, _, native_collection = mongo_mocks + + @vectorstoremodel + @dataclass + class Document: + id: Annotated[ObjectId, VectorStoreField("key")] + text: Annotated[str, VectorStoreField("data")] + + collection = MongoDBCollection( + Document, + collection_name="default_objects", + async_client=client, + database_name="vectors", + ) + with pytest.raises(NotImplementedError): + await collection.upsert([Document(ObjectId(), "hello")], generate_vectors=False) + native_collection.bulk_write.assert_not_awaited() + + +async def test_get_preserves_key_order_and_vector_projection(collection, mongo_mocks, cursor_factory): + _, _, native_collection = mongo_mocks + native_collection.find.return_value = cursor_factory([ + { + "_id": 2, + "body": "two", + "number": 2.0, + "integer": 2, + "flag": True, + "tags": [], + }, + { + "_id": 1, + "body": "one", + "number": 1.0, + "integer": 1, + "flag": False, + "tags": [], + }, + ]) + values = await collection.get([1, 2]) + assert [value["id"] for value in values] == [1, 2] + assert native_collection.find.call_args.kwargs["projection"] == {"dense_text": 0, "dense_image": 0} + + +async def test_filtered_get_uses_native_sort_skip_limit(collection, mongo_mocks, cursor_factory): + _, _, native_collection = mongo_mocks + cursor = cursor_factory([]) + native_collection.find.return_value = cursor + await collection.get(filter=Filter("text", "eq", "hello"), order_by={"number": False}, skip=7, top=4) + assert cursor.sort_spec == [("number", -1), ("_id", 1)] + assert cursor.skip_count == 7 and cursor.limit_count == 4 + + +async def test_search_ann_default_pipeline_and_threshold_order(collection, mongo_mocks, cursor_factory): + _, _, native_collection = mongo_mocks + native_collection.aggregate.return_value = cursor_factory([]) + await collection._inner_search( + search_type="vector", + vector=[1, 0, 0], + filter=Filter("integer", "eq", 1), + top=3, + skip=2, + score_threshold=0.75, + ) + pipeline = native_collection.aggregate.await_args.args[0] + assert pipeline[0] == { + "$vectorSearch": { + "index": "text_vector_index", + "path": "dense_text", + "queryVector": [1.0, 0.0, 0.0], + "limit": 5, + "numCandidates": 100, + "filter": {"integer": {"$eq": 1}}, + } + } + assert [next(iter(stage)) for stage in pipeline] == [ + "$vectorSearch", + "$set", + "$match", + "$skip", + "$limit", + "$project", + ] + + +async def test_search_exact_omits_num_candidates(collection, mongo_mocks, cursor_factory): + _, _, native_collection = mongo_mocks + native_collection.aggregate.return_value = cursor_factory([]) + await collection._inner_search( + search_type="vector", + vector=[1, 0, 0], + top=3, + skip=1, + operation_options={"exact": True}, + ) + search = native_collection.aggregate.await_args.args[0][0]["$vectorSearch"] + assert search["exact"] is True + assert "numCandidates" not in search + + +async def test_search_explicit_max_num_candidates(collection, mongo_mocks, cursor_factory): + _, _, native_collection = mongo_mocks + native_collection.aggregate.return_value = cursor_factory([]) + window = 501 + await collection._inner_search( + search_type="vector", + vector=[1, 0, 0], + top=window, + operation_options={"num_candidates": 10_000}, + ) + search = native_collection.aggregate.await_args.args[0][0]["$vectorSearch"] + assert search["limit"] == window and search["numCandidates"] == 10_000 + + +async def test_search_default_num_candidates_is_capped(collection, mongo_mocks, cursor_factory): + _, _, native_collection = mongo_mocks + native_collection.aggregate.return_value = cursor_factory([]) + await collection._inner_search(search_type="vector", vector=[1, 0, 0], top=501) + search = native_collection.aggregate.await_args.args[0][0]["$vectorSearch"] + assert search["limit"] == 501 and search["numCandidates"] == 10_000 + + +async def test_search_exact_allows_large_representable_window(collection, mongo_mocks, cursor_factory): + _, _, native_collection = mongo_mocks + native_collection.aggregate.return_value = cursor_factory([]) + window = 2**55 + await collection._inner_search( + search_type="vector", + vector=[1, 0, 0], + top=window, + operation_options={"exact": True}, + ) + search = native_collection.aggregate.await_args.args[0][0]["$vectorSearch"] + assert search["limit"] == window and search["exact"] is True + assert "numCandidates" not in search + + +async def test_zero_top_search_validates_but_does_not_aggregate(collection, mongo_mocks): + _, _, native_collection = mongo_mocks + results = await collection._inner_search( + search_type="vector", + vector=[1, 0, 0], + top=0, + operation_options={"exact": True}, + ) + assert [item async for item in results] == [] + native_collection.aggregate.assert_not_awaited() + + +@pytest.mark.parametrize( + ("top", "skip", "options", "message"), + [ + (3, 0, {"exact": True, "num_candidates": 60}, "cannot be supplied"), + (3, 0, {"exact": True, "num_candidates": None}, "cannot be supplied"), + (3, 2, {"num_candidates": 4}, "greater than or equal"), + (3, 0, {"num_candidates": None}, "signed 64-bit"), + (3, 0, {"num_candidates": 0}, "between 1 and 10000"), + (3, 0, {"num_candidates": 10_001}, "between 1 and 10000"), + (10_001, 0, {}, "at most 10000"), + (_BSON_INT64_MAX, 1, {"exact": True}, "signed 64-bit"), + ], +) +async def test_search_window_validation_before_aggregation(collection, mongo_mocks, top, skip, options, message): + _, _, native_collection = mongo_mocks + with pytest.raises(ValueError, match=message): + await collection._inner_search( + search_type="vector", + vector=[1, 0, 0], + top=top, + skip=skip, + operation_options=options, + ) + native_collection.aggregate.assert_not_awaited() + + +async def test_search_result_score_and_record(collection): + key = 1 + result = { + "_id": key, + "body": "hello", + "number": 1.0, + "integer": 1, + "flag": True, + "tags": [], + "__af_vector_search_score": 0.9, + } + assert collection._get_score_from_result(result) == 0.9 + assert collection._get_record_from_result(result) == result + with pytest.raises(IntegrationInvalidResponseException): + collection._get_score_from_result({**result, "__af_vector_search_score": math.nan}) + + +async def test_store_lifecycle(mongo_mocks, definition): + client, database, _ = mongo_mocks + store = MongoDBStore(async_client=client, database_name="vectors") + database.list_collection_names.side_effect = [["one", "two"], ["one"], ["one"], []] + assert await store.list_collection_names() == ["one", "two"] + assert await store.collection_exists("one") + await store.ensure_collection_deleted("one") + database.drop_collection.assert_awaited_once_with("one") + await store.ensure_collection_deleted("missing") + collection = store.get_collection(dict, definition=definition, collection_name="test") + assert collection.async_client is client + await store.close() + cast(AsyncMock, client.close).assert_not_awaited() diff --git a/python/pyproject.toml b/python/pyproject.toml index ff5b6f046ae..72d7318281d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -100,6 +100,7 @@ agent-framework-hosting-telegram = { workspace = true } agent-framework-hyperlight = { workspace = true } agent-framework-mem0 = { workspace = true } agent-framework-mistral = { workspace = true } +agent-framework-mongodb = { workspace = true } agent-framework-monty = { workspace = true } agent-framework-ollama = { workspace = true } agent-framework-openai = { workspace = true } diff --git a/python/uv.lock b/python/uv.lock index f74bf0f7179..53e9c4254bd 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -58,6 +58,7 @@ members = [ "agent-framework-hyperlight", "agent-framework-mem0", "agent-framework-mistral", + "agent-framework-mongodb", "agent-framework-monty", "agent-framework-ollama", "agent-framework-openai", @@ -850,6 +851,21 @@ requires-dist = [ { name = "mistralai", specifier = ">=2.9.2,<3" }, ] +[[package]] +name = "agent-framework-mongodb" +version = "1.0.0a260909" +source = { editable = "packages/mongodb" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pymongo", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "pymongo", specifier = ">=4.13.2,<5" }, +] + [[package]] name = "agent-framework-monty" version = "1.0.0b260730" From fe8497a92ac8938ee2d0bfe251d43ec8ca76bfa9 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Tue, 15 Sep 2026 09:36:11 +0000 Subject: [PATCH 083/108] Python: Preserve MessageText values during formula preprocessing (#8366) * Python: Preserve MessageText values during formula preprocessing Process only original formula text and bind extracted messages through temporary state instead of embedding message content in formula source. Avoid temporary-name collisions and cover literal handling, nested calls, and cleanup regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Respect quoted identifiers and comments in MessageText scanning Share quoted-token and comment handling between formula scanning and call-boundary matching. Cover identifier escapes and comment boundaries with preprocessing and real-PowerFx regressions, and replace the test append-return lambda with a typed helper for mypy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Wait for retired output cleanup in Hyperlight test Assert the rejected sandbox entry is evicted, then drain asynchronous disposal before checking that its output directory is gone. Avoid racing a directory listing against background deletion without changing runtime behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Drop local Hyperlight test fix in favor of upstream Revert 6d8ca2dd1d54f93f2878a2b29f98b0ad6a94bc1e. The cleanup test is now fixed upstream; remove our independent correction before merging main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Preserve MessageText expressions in interpolated strings Traverse only original expression positions so interpolation expressions are preprocessed without reinterpreting literal text or message values. Move quoted-token handling to module scope and add focused regression coverage for the new interpolation contexts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_workflows/_declarative_base.py | 202 +++++++----- .../test_declarative_state_path_safety.py | 302 +++++++++++++++++- .../declarative/tests/test_graph_coverage.py | 8 +- 3 files changed, 408 insertions(+), 104 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py index b640fe00b7e..7b346f12a4b 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -31,7 +31,7 @@ import re import sys import uuid -from collections.abc import Mapping +from collections.abc import Iterator, Mapping from dataclasses import dataclass, field from decimal import Decimal as _Decimal from enum import Enum @@ -67,6 +67,73 @@ _SAFE_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$") +def _skip_powerfx_opaque_token(formula: str, start: int) -> int: + """Return the end of an ordinary quoted token or comment, or start if neither.""" + quote = formula[start] + if quote in ('"', "'"): + pos = start + 1 + while pos < len(formula): + if formula[pos] == quote: + if pos + 1 < len(formula) and formula[pos + 1] == quote: + pos += 2 + continue + return pos + 1 + pos += 1 + return pos + if formula.startswith("//", start): + pos = start + 2 + while pos < len(formula) and formula[pos] not in "\r\n": + pos += 1 + return pos + if formula.startswith("/*", start): + end = formula.find("*/", start + 2) + return len(formula) if end == -1 else end + 2 + return start + + +def _iter_powerfx_expression_indices(formula: str) -> Iterator[int]: + """Yield code positions, excluding literals/comments but including interpolation expressions.""" + # None denotes interpolated text; integers track record braces in expression sections. + scopes: list[int | None] = [0] + cursor = 0 + while cursor < len(formula): + depth = scopes[-1] + char = formula[cursor] + if depth is None: + if formula.startswith(('""', "{{", "}}"), cursor): + cursor += 2 + continue + if char == "{": + scopes.append(0) + elif char == '"': + scopes.pop() + cursor += 1 + continue + + if formula.startswith('$"', cursor): + scopes.append(None) + cursor += 2 + continue + + token_end = _skip_powerfx_opaque_token(formula, cursor) + if token_end != cursor: + cursor = token_end + continue + + if char == "{": + scopes[-1] = depth + 1 + elif char == "}": + if depth > 0: + scopes[-1] = depth - 1 + elif len(scopes) > 1: + scopes.pop() + cursor += 1 + continue + + yield cursor + cursor += 1 + + @dataclass(frozen=True) class DeclarativeEnvConfig: """Configuration that populates the PowerFx ``Env`` symbol for a workflow. @@ -667,11 +734,11 @@ def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str When they appear nested inside other functions (e.g., Upper(MessageText(...))), we need to evaluate them first and replace with the result. - For long strings (>500 chars), the result is stored in a temporary state variable - to avoid exceeding PowerFx's 1000 character expression limit. This is a limitation - of the Python PowerFx wrapper (powerfx package), which doesn't expose the - MaximumExpressionLength configuration that the .NET PowerFxConfig provides. - The .NET implementation defaults to 10,000 characters, while Python defaults to 1,000. + Results are stored in temporary state variables so untrusted message text is + passed to PowerFx as data rather than inserted into formula source. + Temporary names avoid existing Local keys and references in the original formula. + Literal text, quoted identifiers, and comments are left untouched; expression + sections inside PowerFx interpolated strings are preprocessed as code. Args: formula: The PowerFx formula to pre-process @@ -684,90 +751,53 @@ def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str Returns: The rewritten formula. """ - import re - - # Threshold for storing in state vs embedding as literal. - # The Python PowerFx wrapper defaults to a 1000 char expression limit (vs 10,000 in .NET). - # We use 500 to leave room for the rest of the expression around the replaced value. - MAX_INLINE_LENGTH = 500 - temp_var_counter = 0 - - # Custom functions that need pre-processing: (regex pattern, handler) - custom_functions = [ - (r"MessageText\(", self._eval_and_replace_message_text), - ] - - for pattern, handler in custom_functions: - # Find all occurrences of the custom function - while True: - match = re.search(pattern, formula) - if not match: + reserved_names = {name.casefold() for name in self.get_state_data().get("Local", {})} + # Reserve formula references too, so previously undefined names stay undefined. + folded_formula = formula.casefold() + function_name = "MessageText" + call_prefix = f"{function_name}(" + result: list[str] = [] + copied_until = 0 + positions = _iter_powerfx_expression_indices(formula) + + for cursor in positions: + if not formula.startswith(call_prefix, cursor): + continue + + paren_start = cursor + len(function_name) + depth = 1 + for pos in positions: + if pos <= paren_start: + continue + char = formula[pos] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: break + else: + break - # Find the matching closing parenthesis - start = match.start() - paren_start = match.end() - 1 # Position of opening ( - depth = 1 - pos = paren_start + 1 - in_string = False - escape_next = False - - while pos < len(formula) and depth > 0: - char = formula[pos] - if escape_next: - escape_next = False - pos += 1 - continue - if char == "\\": - escape_next = True - pos += 1 - continue - if char == '"' and not escape_next: - in_string = not in_string - elif not in_string: - if char == "(": - depth += 1 - elif char == ")": - depth -= 1 - pos += 1 - - if depth != 0: - # Malformed expression, skip + inner_expr = formula[paren_start + 1 : pos] + replacement = self._eval_and_replace_message_text(inner_expr) + while True: + temp_var_name = f"_TempMessageText{temp_var_counter}" + temp_var_counter += 1 + folded_name = temp_var_name.casefold() + if folded_name not in reserved_names and folded_name not in folded_formula: break - - # Extract the inner expression (between parentheses) - end = pos - inner_expr = formula[paren_start + 1 : end - 1] - - # Evaluate and get replacement - replacement = handler(inner_expr) - - # Replace in formula - if isinstance(replacement, str): - if len(replacement) > MAX_INLINE_LENGTH: - # Store long results in an underscore-prefixed temp key; - # record the prior value so eval() can restore it. - temp_var_name = f"_TempMessageText{temp_var_counter}" - temp_var_counter += 1 - temp_var_path = f"Local.{temp_var_name}" - temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING))) - self.set(temp_var_path, replacement) - replacement_str = temp_var_path - logger.debug( - f"Stored long MessageText result ({len(replacement)} chars) " - f"in temp variable {temp_var_name}" - ) - else: - # Short strings can be embedded directly - escaped = replacement.replace('"', '""') - replacement_str = f'"{escaped}"' - else: - replacement_str = str(replacement) if replacement is not None else '""' - - formula = formula[:start] + replacement_str + formula[end:] - - return formula + temp_var_path = f"Local.{temp_var_name}" + temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING))) + self.set(temp_var_path, replacement) + result.append(formula[copied_until:cursor]) + result.append(temp_var_path) + logger.debug(f"Stored MessageText result ({len(replacement)} chars) in temp variable {temp_var_name}") + copied_until = pos + 1 + + result.append(formula[copied_until:]) + return "".join(result) def _eval_and_replace_message_text(self, inner_expr: str) -> str: """Evaluate MessageText() and return the text result. diff --git a/python/packages/declarative/tests/test_declarative_state_path_safety.py b/python/packages/declarative/tests/test_declarative_state_path_safety.py index 2446fc3cf4d..40683e5e2ac 100644 --- a/python/packages/declarative/tests/test_declarative_state_path_safety.py +++ b/python/packages/declarative/tests/test_declarative_state_path_safety.py @@ -301,6 +301,165 @@ def test_end_to_end_send_activity_payload_neutralized( # --------------------------------------------------------------------------- +class TestMessageTextPreprocessing: + def test_replacement_text_is_not_rescanned_as_formula( + self, + state: DeclarativeWorkflowState, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + calls: list[str] = [] + + def replace_message_text(inner_expr: str) -> str: + calls.append(inner_expr) + if inner_expr == "Local.Messages": + return "MessageText(Local.Secret)" + pytest.fail(f"Replacement text was re-scanned: {inner_expr}") + + monkeypatch.setattr(state, "_eval_and_replace_message_text", replace_message_text) + temp_writes: list[tuple[str, Any]] = [] + + formula = state._preprocess_custom_functions("Upper(MessageText(Local.Messages))", temp_writes) + + assert formula == "Upper(Local._TempMessageText0)" + assert calls == ["Local.Messages"] + assert state.get("Local._TempMessageText0") == "MessageText(Local.Secret)" + + def test_interpolation_preprocesses_only_original_expression_sections( + self, state: DeclarativeWorkflowState, monkeypatch: pytest.MonkeyPatch + ) -> None: + calls: list[str] = [] + message_text = '$"MessageText(Local.Secret) {MessageText(Local.Secret)}"' + + def replace_message_text(inner_expr: str) -> str: + calls.append(inner_expr) + return message_text + + monkeypatch.setattr(state, "_eval_and_replace_message_text", replace_message_text) + temp_writes: list[tuple[str, Any]] = [] + formula = '$"Literal MessageText(Local.Messages) {{MessageText(Local.Messages)}} {MessageText(Local.Messages)}"' + + result = state._preprocess_custom_functions(formula, temp_writes) + + assert result == ( + '$"Literal MessageText(Local.Messages) {{MessageText(Local.Messages)}} {Local._TempMessageText0}"' + ) + assert calls == ["Local.Messages"] + assert state.get("Local._TempMessageText0") == message_text + assert temp_writes == [("Local._TempMessageText0", state._MISSING)] + + def test_message_text_inside_string_literal_is_not_preprocessed( + self, + state: DeclarativeWorkflowState, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + calls: list[str] = [] + + def replace_message_text(inner_expr: str) -> str: + calls.append(inner_expr) + return "hello" + + monkeypatch.setattr(state, "_eval_and_replace_message_text", replace_message_text) + + formula = state._preprocess_custom_functions( + 'Concatenate("MessageText(Local.Secret)", MessageText(Local.Messages))', + [], + ) + + assert formula == 'Concatenate("MessageText(Local.Secret)", Local._TempMessageText0)' + assert calls == ["Local.Messages"] + + @pytest.mark.parametrize( + "formula", + [ + 'Upper("Say ""MessageText(ignored)""")', + "Upper(Local.'MessageText(ignored)')", + "Upper(Local.'Archived'' MessageText(ignored)')", + "1 // MessageText(ignored)", + "1 // MessageText(ignored)\n + 2", + "1 // MessageText(ignored)\r + 2", + "1 // MessageText(ignored)\r\n + 2", + "/* MessageText(ignored) */ 1", + "1 /* unmatched ) ' \" MessageText(ignored) */", + ], + ) + def test_quoted_tokens_and_comments_are_not_preprocessed( + self, state: DeclarativeWorkflowState, monkeypatch: pytest.MonkeyPatch, formula: str + ) -> None: + def unexpected_call(inner_expr: str) -> str: + pytest.fail(f"Processed a call inside a quoted token or comment: {inner_expr}") + + monkeypatch.setattr(state, "_eval_and_replace_message_text", unexpected_call) + temp_writes: list[tuple[str, Any]] = [] + + assert state._preprocess_custom_functions(formula, temp_writes) == formula + assert temp_writes == [] + + @pytest.mark.parametrize( + "inner_expr", + [ + "Local.'Messages) archive'", + "Local.'Messages'' ) archive'", + 'If(")""(" = ")""(", Local.Messages, Local.Messages)', + "Local.Messages /* ) ' \" MessageText(ignored) */", + "Local.Messages // ) MessageText(ignored)\n", + "Local.Messages // ) MessageText(ignored)\r", + "Local.Messages // ) MessageText(ignored)\r\n", + ], + ) + def test_call_boundaries_ignore_quoted_tokens_and_comments( + self, state: DeclarativeWorkflowState, monkeypatch: pytest.MonkeyPatch, inner_expr: str + ) -> None: + calls: list[str] = [] + + def replace_message_text(expression: str) -> str: + calls.append(expression) + return "hello" + + monkeypatch.setattr(state, "_eval_and_replace_message_text", replace_message_text) + + formula = state._preprocess_custom_functions(f"Upper(MessageText({inner_expr}))", []) + + assert formula == "Upper(Local._TempMessageText0)" + assert calls == [inner_expr] + + @pytest.mark.parametrize( + ("existing_name", "existing_value"), + [("_TempMessageText0", None), ("_tempmessagetext0", "user-value")], + ) + def test_temporary_binding_avoids_existing_names( + self, + state: DeclarativeWorkflowState, + monkeypatch: pytest.MonkeyPatch, + existing_name: str, + existing_value: str | None, + ) -> None: + state.set(f"Local.{existing_name}", existing_value) + monkeypatch.setattr(state, "_eval_and_replace_message_text", lambda inner_expr: "hello") + temp_writes: list[tuple[str, Any]] = [] + + formula = state._preprocess_custom_functions("Upper(MessageText(Local.Messages))", temp_writes) + + assert formula == "Upper(Local._TempMessageText1)" + assert state.get_state_data()["Local"] == {existing_name: existing_value, "_TempMessageText1": "hello"} + assert temp_writes == [("Local._TempMessageText1", state._MISSING)] + + @pytest.mark.parametrize( + "reference", ["Local._TempMessageText0", "Local.'_TempMessageText0'", "Local._tempmessagetext0"] + ) + def test_temporary_binding_avoids_formula_references( + self, + state: DeclarativeWorkflowState, + monkeypatch: pytest.MonkeyPatch, + reference: str, + ) -> None: + monkeypatch.setattr(state, "_eval_and_replace_message_text", lambda inner_expr: "hello") + + formula = state._preprocess_custom_functions(f"Upper(MessageText(Local.Messages) & {reference})", []) + + assert formula == f"Upper(Local._TempMessageText1 & {reference})" + assert "_TempMessageText0" not in state.get_state_data()["Local"] + + @_requires_powerfx class TestPowerFxStillWorks: def test_simple_powerfx_expression_evaluates(self, state: DeclarativeWorkflowState) -> None: @@ -308,6 +467,86 @@ def test_simple_powerfx_expression_evaluates(self, state: DeclarativeWorkflowSta state.set("Local.y", 7) assert state.eval("=Local.x * Local.y") == 42 + @pytest.mark.parametrize("name", ["Messages) archive", "Messages' ) archive"]) + def test_message_text_argument_with_quoted_identifier(self, state: DeclarativeWorkflowState, name: str) -> None: + state.set(f"Local.{name}", [{"text": "hello", "contents": [{"type": "text", "text": "hello"}]}]) + quoted_name = name.replace("'", "''") + original_local = state.get_state_data()["Local"].copy() + + assert state.eval(f"=Upper(MessageText(Local.'{quoted_name}'))") == "HELLO" + assert state.get_state_data()["Local"] == original_local + + @pytest.mark.parametrize( + ("formula", "expected"), + [ + ('$"Message: {MessageText(Local.Messages)}"', "Message: hello"), + ( + '$"Literal MessageText(ignored) ""{{{MessageText(Local.Messages)}}}"""', + 'Literal MessageText(ignored) "{hello}"', + ), + ( + '$"{With({nested: {text: $"Inner {MessageText(Local.Messages)}"}}, nested.text)}"', + "Inner hello", + ), + ('$"{MessageText(Local.Messages)}:{Upper(MessageText(Local.Other))}"', "hello:OTHER"), + ('$"{/* } MessageText(Local.Secret) */ MessageText(Local.Messages)}"', "hello"), + ( + ( + 'Upper(MessageText(If($"Literal ) {MessageText(Local.Messages)}" = "Literal ) hello", ' + "Local.Messages, Local.Other)))" + ), + "HELLO", + ), + ], + ids=[ + "basic", + "literal-text-and-escapes", + "nested-records-and-interpolation", + "multiple-expressions", + "commented-interpolation-delimiter", + "interpolation-in-function-argument", + ], + ) + def test_expression_sections_evaluate_with_powerfx( + self, state: DeclarativeWorkflowState, formula: str, expected: str + ) -> None: + """Only expression sections of interpolated strings may evaluate MessageText calls.""" + state.set("Local.Messages", [{"text": "hello", "contents": [{"type": "text", "text": "hello"}]}]) + state.set("Local.Other", [{"text": "other", "contents": [{"type": "text", "text": "other"}]}]) + original_local = state.get_state_data()["Local"].copy() + + assert state.eval(f"={formula}") == expected + assert state.get_state_data()["Local"] == original_local + + @pytest.mark.parametrize("name", ["MessageText(ignored)", "Archived' MessageText(ignored)"]) + def test_quoted_identifier_with_function_like_text(self, state: DeclarativeWorkflowState, name: str) -> None: + state.set(f"Local.{name}", "hello") + quoted_name = name.replace("'", "''") + original_local = state.get_state_data()["Local"].copy() + + assert state.eval(f"=Upper(Local.'{quoted_name}')") == "HELLO" + assert state.get_state_data()["Local"] == original_local + + @pytest.mark.parametrize( + ("formula", "expected"), + [ + ("1 // MessageText(1 / 0)", 1), + ("1 /* MessageText(1 / 0) */", 1), + ("/* MessageText(1 / 0) */ Upper(MessageText(Local.Messages))", "HELLO"), + ("// MessageText(1 / 0)\n Upper(MessageText(Local.Messages))", "HELLO"), + ("Upper(MessageText(Local.Messages /* ) MessageText(1 / 0) */))", "HELLO"), + ("Upper(MessageText(Local.Messages // ) MessageText(1 / 0)\n))", "HELLO"), + ], + ) + def test_message_text_in_comments_is_not_evaluated( + self, state: DeclarativeWorkflowState, formula: str, expected: str | int + ) -> None: + state.set("Local.Messages", [{"text": "hello", "contents": [{"type": "text", "text": "hello"}]}]) + original_local = state.get_state_data()["Local"].copy() + + assert state.eval(f"={formula}") == expected + assert state.get_state_data()["Local"] == original_local + def test_internal_temp_message_text_still_works(self, state: DeclarativeWorkflowState) -> None: """Long MessageText() results round-trip and the temp key is removed after eval.""" long_text = "A" * 600 @@ -323,25 +562,59 @@ def test_internal_temp_message_text_still_works(self, state: DeclarativeWorkflow remaining = sorted(k for k in local if k.startswith("_TempMessageText")) assert not remaining, f"Temporary keys remain in Local: {remaining}" - def test_message_text_eval_preserves_user_temp_value(self, state: DeclarativeWorkflowState) -> None: - """User state at the temp key path survives a long MessageText eval.""" - long_text = "A" * 600 + @pytest.mark.parametrize("text", ["hello", "A" * 600], ids=["short", "long"]) + def test_message_text_eval_preserves_user_temp_value(self, state: DeclarativeWorkflowState, text: str) -> None: + """Existing symbols retain their values during evaluation, not just after cleanup.""" state.set("Local._TempMessageText0", "user-important-value") state.set( "Local.Messages", - [{"text": long_text, "contents": [{"type": "text", "text": long_text}]}], + [{"text": text, "contents": [{"type": "text", "text": text}]}], ) + original_local = state.get_state_data()["Local"].copy() - result = state.eval("=Upper(MessageText(Local.Messages))") - assert result == "A" * 600 - assert state.get("Local._TempMessageText0") == "user-important-value" + result = state.eval("=Upper(Local._TempMessageText0 & MessageText(Local.Messages))") + + assert result == f"USER-IMPORTANT-VALUE{text.upper()}" + assert state.get_state_data()["Local"] == original_local + + def test_message_text_eval_does_not_define_missing_symbol(self, state: DeclarativeWorkflowState) -> None: + state.set("Local.Messages", [{"text": "hello", "contents": [{"type": "text", "text": "hello"}]}]) + original_local = state.get_state_data()["Local"].copy() + + result = state.eval("=Upper(MessageText(Local.Messages) & Local._TempMessageText0)") + + assert result is None + assert state.get_state_data()["Local"] == original_local + @pytest.mark.parametrize( + "second_argument", + ["Local.Second", 'If(Upper(MessageText(Local.First)) = "HELLO", Local.Second, Local.First)'], + ) + def test_multiple_message_text_calls_use_distinct_bindings( + self, state: DeclarativeWorkflowState, second_argument: str + ) -> None: + state.set("Local._TempMessageText0", "prefix") + state.set("Local._TempMessageText1", "suffix") + state.set("Local.First", [{"text": "hello", "contents": [{"type": "text", "text": "hello"}]}]) + state.set("Local.Second", [{"text": "world", "contents": [{"type": "text", "text": "world"}]}]) + original_local = state.get_state_data()["Local"].copy() + + result = state.eval( + "=Upper(Local._TempMessageText0 & MessageText(Local.First) & " + f"MessageText({second_argument}) & Local._TempMessageText1)" + ) + + assert result == "PREFIXHELLOWORLDSUFFIX" + assert state.get_state_data()["Local"] == original_local + + @pytest.mark.parametrize("text", ["hello", "A" * 600], ids=["short", "long"]) def test_message_text_eval_cleans_up_on_powerfx_failure( self, state: DeclarativeWorkflowState, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, + text: str, ) -> None: - """Temp key is removed even when PowerFx evaluation raises.""" + """New temp keys are removed and existing state survives evaluation errors.""" from agent_framework_declarative._workflows import _declarative_base as base class _FailingEngine: @@ -350,15 +623,16 @@ def eval(self, *args: Any, **kwargs: Any) -> Any: monkeypatch.setattr(base, "Engine", _FailingEngine) - long_text = "A" * 600 + state.set("Local._TempMessageText0", "user-important-value") + state.set("Local._TempMessageText1", None) state.set( "Local.Messages", - [{"text": long_text, "contents": [{"type": "text", "text": long_text}]}], + [{"text": text, "contents": [{"type": "text", "text": text}]}], ) + monkeypatch.setattr(state, "_eval_and_replace_message_text", lambda inner_expr: text) + original_local = state.get_state_data()["Local"].copy() with pytest.raises(RuntimeError, match="boom"): state.eval("=Upper(MessageText(Local.Messages))") - local = state.get_state_data().get("Local", {}) - remaining = sorted(k for k in local if k.startswith("_TempMessageText")) - assert not remaining, f"Temporary keys remain in Local after PowerFx failure: {remaining}" + assert state.get_state_data()["Local"] == original_local diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index 43662c82c2c..2d0b5cae2b1 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -2762,8 +2762,8 @@ async def test_multiplication_normal(self, mock_state): class TestLongMessageTextHandling: """Tests for handling long MessageText results that exceed PowerFx limits.""" - async def test_short_message_text_embedded_inline(self, mock_state): - """Test that short MessageText results are embedded inline.""" + async def test_short_message_text_round_trips_without_residual_temp_state(self, mock_state): + """Test that short MessageText results are removed from temporary state after evaluation.""" state = DeclarativeWorkflowState(mock_state) state.initialize() @@ -2771,11 +2771,11 @@ async def test_short_message_text_embedded_inline(self, mock_state): short_text = "Hello world" state.set("Local.Messages", [{"text": short_text, "contents": [{"type": "text", "text": short_text}]}]) - # Evaluate a formula with MessageText - should embed inline + # Evaluate a formula with MessageText. result = state.eval("=Upper(MessageText(Local.Messages))") assert result == "HELLO WORLD" - # No temp variable should be created for short strings + # Temporary state should be cleaned up after evaluation. temp_var = state.get("Local._TempMessageText0") assert temp_var is None From 52008e3c1ad26e1e79971f6b36ff6cdf07257db8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:52:41 +0000 Subject: [PATCH 084/108] .NET: Temporarily skip flaky steering harness session reuse test (#8377) * Initial plan * Temporarily skip flaky steering harness session reuse test Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- .../SteerableLongRunningIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/SteerableLongRunningIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/SteerableLongRunningIntegrationTests.cs index 4025f183f18..eed80b77473 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/SteerableLongRunningIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/SteerableLongRunningIntegrationTests.cs @@ -23,7 +23,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; [Collection(FoundryStateStoreLocalFallbackCollectionDefinition.CollectionName)] public sealed class SteerableLongRunningIntegrationTests { - [Fact] + [Fact(Skip = "Temporarily skipped: flaky in CI (observed RunCount 2 instead of 1); see https://github.com/microsoft/agent-framework/issues/8376")] public async Task ActiveMafTurn_QueuesSteeringThenRunsItOnTheSameSessionAsync() { // Arrange From 5d5fa52147655d2ab8051a212d62443403cc0b98 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Tue, 15 Sep 2026 09:59:35 +0000 Subject: [PATCH 085/108] [BREAKING] Python: Make HTTP cookie persistence explicit (#8371) * Python: Make HTTP response-cookie persistence explicit Configure internally created MCP, A2A, and AG-UI HTTP clients to reject response-cookie persistence while preserving caller-provided clients. Retain connection reuse and cleanup, and document the migration path for applications requiring persistent cookies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Align declarative HTTP cookie persistence defaults Apply the default response-cookie policy to internally managed declarative HTTP clients. Preserve supplied and provider-returned clients, client selection and lifecycle, explicit request headers, and response headers. Document the configuration path and cover reuse, failure responses, and overlapping requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Preserve caller-owned A2A HTTP clients Leave HTTP clients supplied alongside an A2A protocol client open on agent exit. Extend the existing ownership regression, retain internally owned cleanup coverage, and document caller cleanup responsibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Clarify AG-UI test types Type conditional exception contexts explicitly and use separate variables for chat and HTTP responses. Preserve the existing test cases and assertions while satisfying Mypy and Zuban. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/a2a/README.md | 13 + .../a2a/agent_framework_a2a/_agent.py | 15 +- python/packages/a2a/tests/test_a2a_agent.py | 110 +++++++-- python/packages/ag-ui/README.md | 14 ++ .../ag-ui/agent_framework_ag_ui/_client.py | 7 +- .../agent_framework_ag_ui/_http_service.py | 15 +- .../ag-ui/tests/ag_ui/test_ag_ui_client.py | 222 ++++++++++++++++++ .../ag-ui/tests/ag_ui/test_workflow_run.py | 4 +- python/packages/core/AGENTS.md | 1 + python/packages/core/README.md | 15 ++ python/packages/core/agent_framework/_mcp.py | 37 +-- python/packages/core/tests/core/test_mcp.py | 127 +++++----- .../core/tests/core/test_mcp_http_auth.py | 113 ++++++++- .../tests/workflow/test_workflow_agent.py | 4 +- python/packages/declarative/README.md | 19 ++ .../_workflows/_http_handler.py | 17 +- .../test_default_http_request_handler.py | 199 ++++++++++++---- .../test_http_request_yaml_integration.py | 46 ++++ 18 files changed, 811 insertions(+), 167 deletions(-) diff --git a/python/packages/a2a/README.md b/python/packages/a2a/README.md index 0f73d0fb423..b21836751c8 100644 --- a/python/packages/a2a/README.md +++ b/python/packages/a2a/README.md @@ -22,6 +22,19 @@ a2a_agent = A2AAgent(url="http://remote-agent/a2a") response = await a2a_agent.run("Hello!") ``` +**Breaking change:** HTTP clients created by `A2AAgent` no longer persist response cookies. This prevents +one run's cookies from being reused by another run through the same agent. +Connection pooling and cleanup of internally created clients are unchanged. + +If the remote service requires cookies for authentication, sessions, or load-balancer affinity, explicitly supply an +`httpx.AsyncClient` using `http_client=`. Its cookie behavior is preserved. +Scope that client to the intended authenticated user or security context and +manage its lifetime; do not share a user-specific cookie jar across users. + +Supplied HTTP clients remain caller-owned even when `client=` is also provided. +Applications that previously relied on the agent closing a supplied HTTP client +in that combination must now close it explicitly. + ### A2AExecutor (Hosting) The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients. diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 1a143aca33f..5dca644f184 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -7,6 +7,7 @@ import uuid import warnings from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence +from http.cookiejar import CookieJar, DefaultCookiePolicy from typing import Any, Final, Literal, TypeAlias, cast, overload import httpx @@ -244,7 +245,9 @@ def __init__( agent_card: The agent card for the agent. url: The URL for the A2A server. client: The A2A client for the agent. - http_client: Optional httpx.AsyncClient to use. + http_client: Optional httpx.AsyncClient to use. Agent-created clients do not persist + response cookies; supplied clients retain their configured cookie behavior and + remain caller-owned, including when ``client`` is also supplied. auth_interceptor: Optional authentication interceptor for secured endpoints. timeout: Request timeout configuration. Can be a float (applied to all timeout components), httpx.Timeout object (for full control), or None (uses 10.0s connect, 60.0s read, @@ -263,15 +266,13 @@ def __init__( super().__init__(id=id, name=name, description=description, **kwargs) self._http_client: httpx.AsyncClient | None = http_client - # By default, leave a caller-supplied HTTP client open; specific paths may override this below. - # every construction path must set this before __aexit__ can run + # Only HTTP clients created by this agent are closed on exit. self._close_http_client = False self._timeout_config = self._create_timeout_config(timeout) bindings = supported_protocol_bindings if supported_protocol_bindings is not None else ["JSONRPC"] if client is not None: self.client = client self._non_streaming_client: Client | None = None - self._close_http_client = True return if agent_card is None: if url is None: @@ -282,7 +283,11 @@ def __init__( # Create or use provided httpx client if http_client is None: headers = prepend_agent_framework_to_user_agent() - http_client = httpx.AsyncClient(timeout=self._timeout_config, headers=headers) + http_client = httpx.AsyncClient( + timeout=self._timeout_config, + headers=headers, + cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])), + ) self._http_client = http_client # Store for cleanup self._close_http_client = True diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 049d2a45b0f..b7bb617db90 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import json from collections.abc import AsyncIterator from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -30,6 +31,7 @@ ) from agent_framework.a2a import A2AAgent from agent_framework.exceptions import AgentInvalidRequestException +from google.protobuf.json_format import MessageToDict from pytest import fixture, mark, raises, warns from agent_framework_a2a import A2AAgentSession, A2AContinuationToken, A2AServiceSessionId @@ -130,6 +132,70 @@ def a2a_agent(mock_a2a_client: MockA2AClient) -> A2AAgent: return A2AAgent(name="Test Agent", id="test-agent", client=cast(Any, mock_a2a_client), http_client=None) +@mark.parametrize("set_cookie", [True, False], ids=["set-cookie", "no-set-cookie-control"]) +async def test_run_does_not_replay_upstream_cookies_between_sessions(set_cookie: bool) -> None: + """A later session must not receive private data through an earlier session's response cookie.""" + private_record = "First caller's private record" + upstream_requests: list[httpx.Request] = [] + upstream_responses: list[httpx.Response] = [] + + def handle_upstream_request(request: httpx.Request) -> httpx.Response: + upstream_requests.append(request) + payload = json.loads(request.content) + assert request.method == "POST" + assert payload["method"] == "SendMessage" + prompt = payload["params"]["message"]["parts"][-1]["text"] + headers = {} + if prompt == "sign-in": + text = "Signed in" + if set_cookie: + headers["set-cookie"] = "remote_session=first-session; Path=/; HttpOnly; Secure" + else: + assert prompt == "read-record" + text = ( + private_record + if "remote_session=first-session" in request.headers.get("cookie", "") + else "Access denied" + ) + response = httpx.Response( + 200, + headers=headers, + json={ + "jsonrpc": "2.0", + "id": payload["id"], + "result": { + "message": MessageToDict( + A2AMessage( + message_id=str(uuid4()), + role=A2ARole.ROLE_AGENT, + parts=[Part(text=text)], + ) + ) + }, + }, + ) + upstream_responses.append(response) + return response + + async_client_type = httpx.AsyncClient + + def create_http_client(**kwargs: Any) -> httpx.AsyncClient: + # Keep the agent-owned HTTPX client and its cookie handling; replace only network I/O. + return async_client_type(transport=httpx.MockTransport(handle_upstream_request), **kwargs) + + with patch("agent_framework_a2a._agent.httpx.AsyncClient", side_effect=create_http_client): + async with A2AAgent(url="https://a2a.example.test/") as agent: + first_response = await agent.run("sign-in", session=agent.create_session()) + second_response = await agent.run("read-record", session=agent.create_session()) + + assert len(upstream_requests) == 2 + assert "cookie" not in upstream_requests[0].headers + assert ("set-cookie" in upstream_responses[0].headers) is set_cookie + assert first_response.text == "Signed in" + assert second_response.text == "Access denied" + assert "cookie" not in upstream_requests[1].headers + + def test_a2a_agent_initialization_with_client(mock_a2a_client: MockA2AClient) -> None: """Test A2AAgent initialization with provided client.""" # Use model_construct to bypass Pydantic validation for mock objects @@ -435,24 +501,6 @@ async def test_run_streaming_with_message_response(a2a_agent: A2AAgent, mock_a2a assert mock_a2a_client.call_count == 1 -async def test_context_manager_cleanup() -> None: - """Test context manager cleanup of http client.""" - - # Create mock http client that tracks aclose calls - mock_http_client = AsyncMock() - mock_a2a_client = MagicMock() - - agent = A2AAgent(client=cast(Any, mock_a2a_client)) - agent._http_client = mock_http_client - - # Test context manager cleanup - async with agent: - pass - - # Verify aclose was called - mock_http_client.aclose.assert_called_once() - - async def test_context_manager_no_cleanup_when_no_http_client() -> None: """Test context manager when _http_client is None.""" @@ -465,14 +513,28 @@ async def test_context_manager_no_cleanup_when_no_http_client() -> None: pass -async def test_context_manager_does_not_close_caller_supplied_http_client() -> None: - """A caller-supplied http_client (without client=) must survive __aexit__. See issue #7950.""" - mock_http_client = AsyncMock() +@mark.parametrize("supply_a2a_client", [False, True]) +async def test_context_manager_does_not_close_caller_supplied_http_client(supply_a2a_client: bool) -> None: + """A caller-supplied HTTP client must stay open and retain its cookie behavior after __aexit__.""" - async with A2AAgent(url="http://localhost:9999/", http_client=cast(Any, mock_http_client)): - pass + def handle_request(request: httpx.Request) -> httpx.Response: + assert request.headers["cookie"] == "caller_cookie=kept" + return httpx.Response(200, headers={"set-cookie": "server_cookie=retained; Path=/"}) - mock_http_client.aclose.assert_not_called() + async with httpx.AsyncClient( + transport=httpx.MockTransport(handle_request), cookies={"caller_cookie": "kept"} + ) as http_client: + async with A2AAgent( + url="https://a2a.example.test/", + client=MagicMock() if supply_a2a_client else None, + http_client=http_client, + ): + pass + + assert not http_client.is_closed + await http_client.get("https://a2a.example.test/") + assert http_client.cookies["caller_cookie"] == "kept" + assert http_client.cookies["server_cookie"] == "retained" def test_prepare_message_for_a2a_with_multiple_contents() -> None: diff --git a/python/packages/ag-ui/README.md b/python/packages/ag-ui/README.md index 6130f0c7588..ee9c0af4edf 100644 --- a/python/packages/ag-ui/README.md +++ b/python/packages/ag-ui/README.md @@ -105,6 +105,20 @@ The `AGUIChatClient` supports: - Integration with `Agent` for client-side history management - Canonical interrupt/resume passthrough (`availableInterrupts` and `resume`) +#### HTTP client ownership and cookies + +**Breaking change:** `AGUIChatClient` and `AGUIHttpService` reuse an internally owned HTTP client +for connection pooling but no longer persist response cookies. This applies to all runs, +including repeated requests with the same thread ID. Closing the AG-UI client or leaving +its async context manager closes its internally owned HTTP client. + +Applications that need cookies for upstream authentication, sessions, or load-balancer affinity, including those migrating from +the previous default of retaining response cookies, must supply an `httpx.AsyncClient` +through `http_client`. Supplied clients retain their headers, cookies, timeout, transport, +and response-cookie handling, and must be closed by the caller. Scope a cookie-bearing +client to a single authenticated principal; do not share it across users. AG-UI thread IDs +are correlation identifiers, not authentication boundaries. + ## Tool Return Helpers Use `state_update` when a backend tool needs to send different payloads to the model, the UI, and shared state. The `text` value remains the LLM-bound tool result, `tool_result` becomes the AG-UI `ToolCallResultEvent.content` for frontend rendering, and `state` is merged into durable shared state. diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_client.py b/python/packages/ag-ui/agent_framework_ag_ui/_client.py index 7aa43062427..f15fc367a8d 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_client.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_client.py @@ -279,7 +279,10 @@ def __init__( Args: endpoint: The AG-UI server endpoint URL (e.g., "http://localhost:8888/") - http_client: Optional httpx.AsyncClient instance. If None, one will be created. + http_client: Optional httpx.AsyncClient instance. If None, creates a client that does not + persist response cookies. Supplied clients retain their cookie behavior and remain + caller-owned; scope cookie-based authentication to a single authenticated principal + rather than sharing it across users. timeout: Request timeout in seconds (default: 60.0) additional_properties: Additional properties to store middleware: Optional middleware to apply to the client. @@ -297,7 +300,7 @@ def __init__( ) async def close(self) -> None: - """Close the HTTP client.""" + """Close the internally created HTTP client, leaving supplied clients open.""" await self._http_service.close() async def __aenter__(self) -> Self: diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py b/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py index 4fb02ef8b19..43461e5cc5e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_http_service.py @@ -7,6 +7,7 @@ import json import logging from collections.abc import AsyncIterable, Mapping, Sequence +from http.cookiejar import CookieJar, DefaultCookiePolicy from typing import Any, cast import httpx @@ -151,12 +152,22 @@ def __init__( Args: endpoint: AG-UI server endpoint URL (e.g., "http://localhost:8888/") - http_client: Optional httpx AsyncClient. If None, creates a new one. + http_client: Optional httpx AsyncClient. If None, creates a client that does not + persist response cookies. Supplied clients retain their cookie behavior and + remain caller-owned; scope cookie-based authentication to a single authenticated + principal rather than sharing it across users. timeout: Request timeout in seconds (default: 60.0) """ self.endpoint = endpoint.rstrip("/") self._owns_client = http_client is None - self.http_client = http_client or httpx.AsyncClient(timeout=timeout) + if http_client is not None: + self.http_client = http_client + else: + # An empty domain allowlist prevents response cookies from persisting across runs. + self.http_client = httpx.AsyncClient( + timeout=timeout, + cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])), + ) async def post_run( self, diff --git a/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py b/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py index acb6af67e8a..679cfb11eec 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py +++ b/python/packages/ag-ui/tests/ag_ui/test_ag_ui_client.py @@ -2,8 +2,10 @@ """Tests for AGUIChatClient.""" +import asyncio import json from collections.abc import AsyncGenerator, Awaitable, Mapping, MutableSequence +from contextlib import AbstractContextManager, nullcontext from typing import Any, cast import httpx @@ -70,6 +72,226 @@ async def test_client_context_manager(self) -> None: async with StubAGUIChatClient(endpoint="http://localhost:8888/") as client: assert client is not None + @pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"]) + @pytest.mark.parametrize("set_cookie", [False, True], ids=["no_cookie_control", "response_cookie"]) + @pytest.mark.parametrize("first_status", [200, 503], ids=["successful_response", "failed_response"]) + @pytest.mark.parametrize("second_thread_id", ["thread_a", "thread_b"], ids=["same_thread", "different_thread"]) + async def test_default_client_does_not_replay_response_cookies_between_runs( + self, monkeypatch: MonkeyPatch, stream: bool, set_cookie: bool, first_status: int, second_thread_id: str + ) -> None: + """Response cookies must not persist between runs, regardless of thread ID.""" + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + payload = json.loads(await request.aread()) + headers = {"content-type": "text/event-stream"} + if set_cookie and len(requests) == 1: + headers["set-cookie"] = "session=thread-a-only; Path=/; HttpOnly; Secure" + events = [ + {"type": "RUN_STARTED", "threadId": payload["thread_id"], "runId": payload["run_id"]}, + {"type": "TEXT_MESSAGE_START", "messageId": "msg_1", "role": "assistant"}, + {"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg_1", "delta": "ok"}, + {"type": "TEXT_MESSAGE_END", "messageId": "msg_1"}, + {"type": "RUN_FINISHED", "threadId": payload["thread_id"], "runId": payload["run_id"]}, + ] + return httpx.Response( + first_status if len(requests) == 1 else 200, + headers=headers, + content="".join(f"data: {json.dumps(event)}\n\n" for event in events).encode(), + ) + + async with httpx.MockTransport(handler) as transport: + + async def handle_async_request( + _transport: httpx.AsyncHTTPTransport, request: httpx.Request + ) -> httpx.Response: + return await transport.handle_async_request(request) + + # Replace only network I/O, preserving the default client's real cookie handling. + monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", handle_async_request) + async with StubAGUIChatClient(endpoint="https://agui.example.test/") as client: + http_client = client.http_service.http_client + for index, thread_id in enumerate(("thread_a", second_thread_id)): + messages = [Message(role="user", contents=["Hello"])] + expected_error: AbstractContextManager[object] = ( + pytest.raises(httpx.HTTPStatusError, match="503") + if index == 0 and first_status == 503 + else nullcontext() + ) + with expected_error: + if stream: + updates = [ + update + async for update in client.get_response( + messages, stream=True, options={"metadata": {"thread_id": thread_id}} + ) + ] + assert "".join(update.text for update in updates) == "ok" + else: + response = await client.get_response( + messages, options={"metadata": {"thread_id": thread_id}} + ) + assert response.text == "ok" + assert client.http_service.http_client is http_client + + assert len(requests) == 2 + assert [json.loads(request.content)["thread_id"] for request in requests] == ["thread_a", second_thread_id] + assert all(request.method == "POST" for request in requests) + assert all(request.headers["accept"] == "text/event-stream" for request in requests) + assert requests[0].headers.get("cookie") is None + assert requests[1].headers.get("cookie") is None + assert not http_client.cookies + + @pytest.mark.parametrize("stream", [False, True], ids=["non_streaming", "streaming"]) + async def test_default_client_does_not_replay_cookies_while_another_response_is_open( + self, monkeypatch: MonkeyPatch, stream: bool + ) -> None: + """Response cookies must be rejected before an overlapping request is built.""" + requests: list[httpx.Request] = [] + first_headers_received = asyncio.Event() + second_request_received = asyncio.Event() + response_body = ( + b'data: {"type":"TEXT_MESSAGE_START","messageId":"msg_1","role":"assistant"}\n\n' + b'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_1","delta":"ok"}\n\n' + b'data: {"type":"TEXT_MESSAGE_END","messageId":"msg_1"}\n\n' + ) + + class PendingResponseStream(httpx.AsyncByteStream): + async def __aiter__(self) -> AsyncGenerator[bytes, None]: + # HTTPX has processed response headers before it starts reading this body. + first_headers_received.set() + await second_request_received.wait() + yield response_body + + async def handle_async_request(_transport: httpx.AsyncHTTPTransport, request: httpx.Request) -> httpx.Response: + requests.append(request) + await request.aread() + if len(requests) == 1: + return httpx.Response( + 200, + headers={ + "content-type": "text/event-stream", + "set-cookie": "session=thread-a-only; Path=/; HttpOnly; Secure", + }, + stream=PendingResponseStream(), + ) + second_request_received.set() + return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=response_body) + + monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", handle_async_request) + async with StubAGUIChatClient(endpoint="https://agui.example.test/") as client: + http_client = client.http_service.http_client + + async def run(thread_id: str) -> str: + if thread_id == "thread_b": + await first_headers_received.wait() + messages = [Message(role="user", contents=["Hello"])] + if stream: + updates = [ + update + async for update in client.get_response( + messages, stream=True, options={"metadata": {"thread_id": thread_id}} + ) + ] + return "".join(update.text for update in updates) + response = await client.get_response(messages, options={"metadata": {"thread_id": thread_id}}) + return response.text + + tasks = [asyncio.create_task(run(thread_id)) for thread_id in ("thread_a", "thread_b")] + try: + responses = await asyncio.wait_for(asyncio.gather(*tasks), timeout=10) + finally: + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + assert responses == ["ok", "ok"] + assert client.http_service.http_client is http_client + + assert len(requests) == 2 + assert [json.loads(request.content)["thread_id"] for request in requests] == ["thread_a", "thread_b"] + assert requests[0].headers.get("cookie") is None + assert requests[1].headers.get("cookie") is None + assert not http_client.cookies + + @pytest.mark.parametrize("raise_in_context", [False, True], ids=["normal_exit", "exception_exit"]) + async def test_context_manager_closes_owned_http_client(self, raise_in_context: bool) -> None: + """Owned HTTP clients close on both normal and exceptional context exits.""" + client = StubAGUIChatClient(endpoint="https://agui.example.test/", timeout=17.0) + http_client = client.http_service.http_client + expected_error: AbstractContextManager[object] = ( + pytest.raises(RuntimeError, match="context failed") if raise_in_context else nullcontext() + ) + try: + with expected_error: + async with client: + assert not http_client.is_closed + assert http_client.timeout == httpx.Timeout(17.0) + if raise_in_context: + raise RuntimeError("context failed") + + assert http_client.is_closed + await client.close() + assert http_client.is_closed + finally: + await http_client.aclose() + + @pytest.mark.parametrize("raise_in_context", [False, True], ids=["normal_exit", "exception_exit"]) + async def test_context_manager_preserves_supplied_http_client(self, raise_in_context: bool) -> None: + """Caller-owned clients retain their configuration and remain usable after cleanup.""" + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + headers={ + "content-type": "text/event-stream", + "set-cookie": "response=session-only; Path=/; HttpOnly; Secure", + }, + content=( + b'data: {"type":"RUN_STARTED","threadId":"thread_1","runId":"run_1"}\n\n' + b'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_1","delta":"ok"}\n\n' + b'data: {"type":"RUN_FINISHED","threadId":"thread_1","runId":"run_1"}\n\n' + ), + ) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(handler), + headers={"Authorization": "Bearer test-token"}, + cookies={"configured": "caller-owned"}, + timeout=17.0, + ) as http_client: + client = StubAGUIChatClient(endpoint="https://agui.example.test/", http_client=http_client) + expected_error: AbstractContextManager[object] = ( + pytest.raises(RuntimeError, match="context failed") if raise_in_context else nullcontext() + ) + with expected_error: + async with client: + assert client.http_service.http_client is http_client + chat_response = await client.get_response( + [Message(role="user", contents=["Hello"])], + options={"metadata": {"thread_id": "thread_1"}}, + ) + assert chat_response.text == "ok" + if raise_in_context: + raise RuntimeError("context failed") + + await client.close() + assert not http_client.is_closed + assert http_client.headers["authorization"] == "Bearer test-token" + assert http_client.cookies["configured"] == "caller-owned" + assert http_client.cookies["response"] == "session-only" + assert http_client.timeout == httpx.Timeout(17.0) + http_response = await http_client.get("https://agui.example.test/health") + assert http_response.status_code == 200 + + assert [request.method for request in requests] == ["POST", "GET"] + assert all(request.headers["authorization"] == "Bearer test-token" for request in requests) + assert requests[0].headers["cookie"] == "configured=caller-owned" + assert set(requests[1].headers["cookie"].split("; ")) == {"configured=caller-owned", "response=session-only"} + async def test_extract_state_from_messages_no_state(self) -> None: """Test state extraction when no state is present.""" client = StubAGUIChatClient(endpoint="http://localhost:8888/") diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py index be6213d2198..d9cb44fe01c 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py @@ -481,9 +481,7 @@ async def test_workflow_run_approval_pause_closes_open_real_tool_call() -> None: """ @executor(id="approval_with_streamed_tool") - async def approval_with_streamed_tool( - message: Any, ctx: WorkflowContext[Any, AgentResponseUpdate] - ) -> None: + async def approval_with_streamed_tool(message: Any, ctx: WorkflowContext[Any, AgentResponseUpdate]) -> None: del message function_call = Content.from_function_call( call_id="weather-call", diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index 1dc3eca9dfe..f9438a887a0 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -166,6 +166,7 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through — but note this constrains the *model*, not the *server*, which still widens the effective allowlist through its schema. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used. - **MCP HTTP header request scoping** - `static_headers` supplies fixed, origin-scoped headers without serializing concurrent calls; `header_provider` resolves dynamic per-call headers under the instance call lock. Fixed and dynamic headers form the session's complete effective identity (case-insensitive names, case-sensitive values), with dynamic values overriding fixed values of the same name. Agent runs reconcile a connected tool before copying its functions; generated tool and prompt calls perform the same check at invocation. Connected run preparation defers when a provider needs model-supplied values that are not available until invocation, where resolution remains strict. A different identity waits for in-flight discovery, reconnects framework-created sessions without loading, then refreshes session-derived discovery under the same lock. Because a caller-supplied session's established identity is unknown and the wrapper cannot reconnect it, dynamic header resolution on that wrapper is rejected even while disconnected. Ambient requests retain the bound set. With a shared `http_client`, processing stays scoped to the originating `MCPStreamableHTTPTool`, and every injected header is stripped from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. - **MCP lifecycle caller cancellation** - The lifecycle owner skips cancelled queued connect requests and waits for the caller to acknowledge successful setup. If the caller cancels before accepting a newly established connection, the owner tears it down before processing the next request. Cancelling a close waiter does not interrupt teardown, and cancelling a redundant connect does not discard a previously established session. +- **Streamable HTTP cookies** - Framework-created clients reject response-cookie persistence with or without a `header_provider`; explicit `Cookie` headers from `static_headers` or a provider remain supported. Caller-provided clients retain their cookie behavior and ownership. Applications requiring cookie persistence must scope clients and MCP sessions to one authenticated principal. Cookie rejection does not isolate MCP protocol sessions or other server-side state. - **`function_invocation_kwargs` and MCP servers** - That dict is shared across every tool in the run, including every attached `MCPTool`, and any name in it reaches a server that declares a matching `inputSchema` property. `header_provider` does not mitigate this — it reads the kwargs without consuming them. To keep a credential out of tool arguments, source it outside `function_invocation_kwargs`: read a `ContextVar` inside the provider (this still allows a different value per request), configure a custom `http_client`, or use `env` for `MCPStdioTool`. - **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`. - **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields: diff --git a/python/packages/core/README.md b/python/packages/core/README.md index cc145b0aa3b..6af0ecaf7d9 100644 --- a/python/packages/core/README.md +++ b/python/packages/core/README.md @@ -168,6 +168,21 @@ asyncio.run(main()) You can explore additional agent samples [here](https://github.com/microsoft/agent-framework/tree/main/python/samples/02-agents). +### MCP HTTP client ownership and cookies + +**Breaking change:** HTTP clients created by `MCPStreamableHTTPTool` no longer persist +response cookies. This applies with or without a `header_provider`, including repeated +calls within the same MCP session. Connection reuse, timeout and redirect defaults, +and cleanup on close or reset are preserved. Explicit `Cookie` headers supplied through +`static_headers` or a `header_provider` are still supported. + +Applications relying on cookie-based authentication, sessions, or load-balancer affinity +must supply an `httpx.AsyncClient` through `http_client=`. Supplied clients retain their +configuration and response-cookie handling and must be closed by the caller. Scope +cookie-bearing clients and MCP tool sessions to one authenticated principal; do not +share a user-specific cookie jar across users. Rejecting cookies does not partition +MCP protocol sessions or other server-side state between principals. + ## 5. Multi-Agent Orchestration Coordinate multiple agents to collaborate on complex tasks using orchestration patterns: diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 6eeec717b4d..aa08453b2e9 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -17,6 +17,7 @@ from copy import copy from dataclasses import dataclass from datetime import date, datetime, timedelta +from http.cookiejar import CookieJar, DefaultCookiePolicy from inspect import isawaitable from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast @@ -3582,7 +3583,7 @@ def __init__( The arguments are used to create a streamable HTTP client using the new ``mcp.client.streamable_http.streamable_http_client`` API. If an asyncClient is provided via ``http_client``, it will be used as the underlying transport client. - Otherwise, the ``streamable_http_client`` API will create and manage a default client. + Otherwise, the tool creates and manages a default client without response-cookie persistence. Args: name: The name of the tool. @@ -3639,8 +3640,13 @@ def __init__( (``min(requested, cap)``); ``None`` disables it. sampling_max_requests: Per-session cap on the number of sampling requests; further requests are rejected. Resets on reconnect. ``None`` disables it. - http_client: Optional asyncClient to use. If not provided, the - ``streamable_http_client`` API will create and manage a default client. + http_client: Optional asyncClient to use. If not provided, the tool creates and manages + a client that does not persist response cookies, with or without a ``header_provider``. + Explicit ``Cookie`` headers from ``static_headers`` or a ``header_provider`` are supported. + Supplied clients retain their cookie behavior and remain caller-owned. Applications + requiring cookie-based sessions must supply a client scoped to one authenticated + principal and manage its lifetime. Cookie rejection does not partition MCP protocol + sessions or other server-side state between principals. Use ``static_headers`` for fixed headers. To configure timeouts or other HTTP client settings, create and pass your own ``asyncClient`` instance. Security: when you attach sensitive headers (e.g. authentication tokens) @@ -3806,17 +3812,20 @@ def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]: self._promote_pending_session_headers() + target_origin = ( + _url_origin(URL(self.url)) if self._static_headers or self._header_provider is not None else None + ) http_client = self._httpx_client - if self._static_headers or self._header_provider is not None: - target_origin = _url_origin(URL(self.url)) - if http_client is None: - http_client = AsyncClient( - follow_redirects=True, - timeout=Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT), - ) - self._httpx_client = http_client - self._exit_stack.push_async_callback(self._close_owned_http_client, http_client) + if http_client is None: + http_client = AsyncClient( + follow_redirects=True, + timeout=Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT), + cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])), + ) + self._httpx_client = http_client + self._exit_stack.push_async_callback(self._close_owned_http_client, http_client) + if target_origin is not None: if not hasattr(self, "_inject_headers_hook"): async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async] @@ -3888,9 +3897,7 @@ async def _inject_headers(request: Request) -> None: # ruff:ignore[unused-async # while successful sessions keep the hook through transport shutdown. self._exit_stack.callback(self._remove_header_hook) - transport_http_client = ( - _MCPHeaderScopedClient(http_client, self._header_request_owner) if http_client is not None else None - ) + transport_http_client = _MCPHeaderScopedClient(http_client, self._header_request_owner) return streamable_http_client( url=self.url, diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index ce4ecaa99e9..832fead68e2 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -4672,24 +4672,32 @@ def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs(): ) -def test_mcp_streamable_http_tool_get_mcp_client_all_params(): +async def test_mcp_streamable_http_tool_get_mcp_client_all_params(): """Test MCPStreamableHTTPTool.get_mcp_client() with all parameters.""" + from agent_framework._mcp import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT + tool = MCPStreamableHTTPTool( name="test", url="http://example.com", terminate_on_close=True, ) - with patch("mcp.client.streamable_http.streamable_http_client") as mock_http_client: - tool.get_mcp_client() - - # Verify streamable_http_client was called with None for http_client - # (since we didn't provide one, the API will create its own) - mock_http_client.assert_called_once_with( - url="http://example.com", - http_client=None, - terminate_on_close=True, - ) + try: + with patch("mcp.client.streamable_http.streamable_http_client") as mock_http_client: + tool.get_mcp_client() + http_client = tool._httpx_client + assert http_client is not None + assert http_client.follow_redirects is True + assert http_client.timeout.connect == MCP_DEFAULT_TIMEOUT + assert http_client.timeout.read == MCP_DEFAULT_SSE_READ_TIMEOUT + assert not http_client.event_hooks["request"] + assert mock_http_client.call_args.kwargs["http_client"]._client is http_client + assert mock_http_client.call_args.kwargs["url"] == "http://example.com" + assert mock_http_client.call_args.kwargs["terminate_on_close"] is True + mock_http_client.assert_called_once() + finally: + await tool.close() + assert http_client.is_closed def test_mcp_websocket_tool_get_mcp_client_with_kwargs(): @@ -4853,7 +4861,7 @@ async def test_load_prompts_prevents_multiple_calls(): async def test_mcp_streamable_http_tool_httpx_client_cleanup(): - """Test that MCPStreamableHTTPTool delegates to caller-provided httpx clients.""" + """Owned clients are closed, while caller-provided clients remain caller-owned.""" from unittest.mock import AsyncMock, Mock, patch from agent_framework import MCPStreamableHTTPTool @@ -4876,7 +4884,6 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup(): mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session) mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None) - # Test 1: Tool without provided client (passes None to streamable_http_client) tool1 = MCPStreamableHTTPTool( name="test", url="http://localhost:8081/mcp", @@ -4884,11 +4891,13 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup(): load_prompts=False, terminate_on_close=False, ) - await tool1.connect() - # When no client is provided, _httpx_client should be None - assert tool1._httpx_client is None, "httpx client should be None when not provided" + async with tool1: + owned_client = tool1._httpx_client + assert owned_client is not None + assert not owned_client.is_closed + assert owned_client.is_closed + assert tool1._httpx_client is None - # Test 2: Tool with user-provided client user_client = Mock() tool2 = MCPStreamableHTTPTool( name="test", @@ -4898,15 +4907,10 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup(): terminate_on_close=False, http_client=user_client, ) - await tool2.connect() - - # Verify the user-provided client was stored - assert tool2._httpx_client is user_client, "User-provided client should be stored" - - # Verify the transport wrapper delegates to the user's client. - # Get the last call (should be from tool2.connect()) - call_args = mock_client.call_args - assert call_args.kwargs["http_client"]._client is user_client + async with tool2: + assert tool2._httpx_client is user_client + assert mock_client.call_args.kwargs["http_client"]._client is user_client + user_client.aclose.assert_not_called() async def test_load_tools_with_pagination(): @@ -7219,39 +7223,56 @@ async def test_mcp_streamable_http_tool_keeps_bound_headers_on_same_origin_redir await tool._httpx_client.aclose() # type: ignore[union-attr] -async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client(): - """Test that header_provider works when the user provides their own httpx client.""" +@pytest.mark.parametrize("use_header_provider", [False, True]) +async def test_mcp_streamable_http_tool_header_provider_with_user_httpx_client(use_header_provider: bool): + """Supplied clients preserve configuration, cookie persistence, and ownership.""" import httpx from agent_framework._mcp import _mcp_call_headers - user_client = httpx.AsyncClient(headers={"X-Base": "static"}) - - tool = MCPStreamableHTTPTool( - name="test", - url="http://example.com/mcp", - http_client=user_client, - header_provider=lambda kw: {"X-Dynamic": kw.get("dynamic", "")}, - ) - - with patch("agent_framework._mcp.streamable_http_client"): - tool.get_mcp_client() - - # The user's client should still be used - assert tool._httpx_client is user_client - hooks = user_client.event_hooks.get("request", []) - assert len(hooks) == 1 - - # Verify the hook injects headers - token = _mcp_call_headers.set({"X-Dynamic": "per-request"}) + def handle(request: httpx.Request) -> httpx.Response: + assert request.headers["X-Base"] == "static" + assert request.headers["Authorization"].startswith("Basic ") + assert request.headers["Cookie"] == "caller_session=kept" + return httpx.Response(200, headers={"Set-Cookie": "response_session=retained; Path=/"}) + + auth = httpx.BasicAuth("caller", "test-password") + async with httpx.AsyncClient( + transport=httpx.MockTransport(handle), + headers={"X-Base": "static"}, + cookies={"caller_session": "kept"}, + auth=auth, + timeout=17.0, + ) as user_client: + tool = MCPStreamableHTTPTool( + name="test", + url="http://example.com/mcp", + http_client=user_client, + header_provider=(lambda kw: {"X-Dynamic": kw.get("dynamic", "")}) if use_header_provider else None, + ) try: - request = _request_for_mcp_tool(tool) - await hooks[0](request) - assert request.headers.get("X-Dynamic") == "per-request" + with patch("agent_framework._mcp.streamable_http_client"): + tool.get_mcp_client() + assert tool._httpx_client is user_client + hooks = user_client.event_hooks["request"] + assert len(hooks) == int(use_header_provider) + if use_header_provider: + token = _mcp_call_headers.set({"X-Dynamic": "per-request"}) + try: + request = _request_for_mcp_tool(tool) + await hooks[0](request) + assert request.headers.get("X-Dynamic") == "per-request" + finally: + _mcp_call_headers.reset(token) finally: - _mcp_call_headers.reset(token) - - await user_client.aclose() + await tool.close() + assert not user_client.is_closed + assert user_client.auth is auth + assert user_client.timeout == httpx.Timeout(17.0) + assert not user_client.event_hooks["request"] + await user_client.get("http://example.com/unrelated") + assert user_client.cookies["caller_session"] == "kept" + assert user_client.cookies["response_session"] == "retained" async def test_mcp_streamable_http_tool_header_provider_isolated_on_shared_httpx_client(): diff --git a/python/packages/core/tests/core/test_mcp_http_auth.py b/python/packages/core/tests/core/test_mcp_http_auth.py index b84b9721aa2..1fd14a52844 100644 --- a/python/packages/core/tests/core/test_mcp_http_auth.py +++ b/python/packages/core/tests/core/test_mcp_http_auth.py @@ -6,7 +6,8 @@ import contextlib import json import sys -from collections.abc import AsyncGenerator, AsyncIterator +from collections.abc import AsyncGenerator, AsyncIterator, Callable +from contextvars import ContextVar from typing import Any, Literal, TypeAlias from unittest.mock import AsyncMock, Mock, patch @@ -17,10 +18,11 @@ from agent_framework.exceptions import ToolException, ToolExecutionException MCPHTTPServer: TypeAlias = tuple[httpx.AsyncClient, list[httpx.Request], dict[str, list[str]]] +MCPHTTPClientFactory: TypeAlias = tuple[Callable[..., httpx.AsyncClient], list[httpx.Request], dict[str, list[str]]] @pytest.fixture -async def mcp_http_server() -> AsyncIterator[MCPHTTPServer]: +async def mcp_http_client_factory() -> AsyncIterator[MCPHTTPClientFactory]: requests: list[httpx.Request] = [] writes: dict[str, list[str]] = {"token-a": [], "token-A": [], "token-b": [], "token-c": []} @@ -95,9 +97,34 @@ async def handle(request: httpx.Request) -> httpx.Response: return httpx.Response(202) return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) - async with httpx.AsyncClient( - transport=httpx.MockTransport(handle), event_hooks={"request": [record_request]} - ) as client: + async_client = httpx.AsyncClient + clients: list[httpx.AsyncClient] = [] + + def create_client(*, response_cookies: dict[str, str] | None = None, **kwargs: Any) -> httpx.AsyncClient: + async def handle_with_cookies(request: httpx.Request) -> httpx.Response: + response = await handle(request) + if response_cookies and request.method == "POST": + method = json.loads(request.content).get("method") + if method in response_cookies: + response.headers["Set-Cookie"] = response_cookies[method] + return response + + client = async_client(transport=httpx.MockTransport(handle_with_cookies), **kwargs) + client.event_hooks["request"].insert(0, record_request) + clients.append(client) + return client + + try: + yield create_client, requests, writes + finally: + for client in clients: + await client.aclose() + + +@pytest.fixture +async def mcp_http_server(mcp_http_client_factory: MCPHTTPClientFactory) -> AsyncIterator[MCPHTTPServer]: + create_client, requests, writes = mcp_http_client_factory + async with create_client() as client: yield client, requests, writes @@ -127,6 +154,38 @@ def _requests_for_method(requests: list[httpx.Request], method: str) -> list[htt ] +async def test_owned_client_does_not_replay_response_cookie_across_principals( + mcp_http_client_factory: MCPHTTPClientFactory, +) -> None: + create_client, requests, _ = mcp_http_client_factory + principal = ContextVar("principal", default="token-a") + response_cookies = {"tools/call": "regression_session=token-a; Path=/; Secure; HttpOnly"} + + def create_owned_client(**kwargs: Any) -> httpx.AsyncClient: + return create_client(response_cookies=response_cookies, **kwargs) + + tool = MCPStreamableHTTPTool( + name="owned", + url="https://mcp.example/mcp", + load_prompts=False, + header_provider=lambda _: {"Authorization": principal.get()}, + ) + with patch("httpx.AsyncClient", side_effect=create_owned_client): + async with tool: + await tool.call_tool("record") + response_cookies.clear() + token = principal.set("token-b") + try: + await tool.call_tool("record") + finally: + principal.reset(token) + + calls = _calls(requests) + assert [request.headers["Authorization"] for request in calls] == ["token-a", "token-b"] + assert "Cookie" not in calls[0].headers + assert "regression_session=token-a" not in calls[1].headers.get("Cookie", "") + + @pytest.mark.parametrize("principals", [("token-a", "token-b"), ("token-b", "token-a")]) async def test_shared_client_keeps_tools_and_caller_requests_isolated( mcp_http_server: MCPHTTPServer, principals: tuple[str, str] @@ -240,20 +299,50 @@ async def transport(**kwargs: Any) -> AsyncGenerator[tuple[()]]: await tool.close() -async def test_owned_client_is_closed_after_successful_session(mcp_http_server: MCPHTTPServer) -> None: - client, _, _ = mcp_http_server - original_hooks = list(client.event_hooks["request"]) +@pytest.mark.parametrize("header_source", ["none", "static", "provider"]) +async def test_owned_client_is_closed_after_successful_session( + mcp_http_client_factory: MCPHTTPClientFactory, header_source: str +) -> None: + create_client, requests, _ = mcp_http_client_factory + clients: list[httpx.AsyncClient] = [] + + def create_owned_client(**kwargs: Any) -> httpx.AsyncClient: + client = create_client( + response_cookies={"tools/call": "implicit_session=previous; Path=/; Secure; HttpOnly"}, + **kwargs, + ) + client.headers["Authorization"] = "token-a" + clients.append(client) + return client + tool = MCPStreamableHTTPTool( name="owned", url="https://mcp.example/mcp", load_prompts=False, - header_provider=lambda _: {"Authorization": "token-a"}, + static_headers={"Cookie": "explicit_session=caller"} if header_source == "static" else None, + header_provider=(lambda _: {"Authorization": "token-a", "Cookie": "explicit_session=caller"}) + if header_source == "provider" + else None, ) - with patch("httpx.AsyncClient", return_value=client): + with patch("httpx.AsyncClient", side_effect=create_owned_client): async with tool: await tool.call_tool("record") - assert client.is_closed - assert client.event_hooks["request"] == original_hooks + await tool.call_tool("record") + assert len(clients) == 1 + assert tool._httpx_client is clients[0] + await tool.connect(reset=True) + assert len(clients) == 2 + assert clients[0].is_closed + await tool.call_tool("record") + assert len(_calls(requests)) == 3 + assert all( + request.headers.get("Cookie") == ("explicit_session=caller" if header_source != "none" else None) + for request in _calls(requests) + ) + assert all(not client.cookies for client in clients) + assert all(client.is_closed for client in clients) + assert all(len(client.event_hooks["request"]) == 1 for client in clients) + assert tool._httpx_client is None async def test_connecting_another_tool_during_a_call_does_not_capture_its_headers( diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 1394ef70ef6..3374d15d395 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -939,9 +939,7 @@ async def test_workflow_as_agent_stream_preserves_empty_additional_properties(se """Test that an explicitly empty additional_properties dict is not converted to None.""" @executor - async def empty_props_executor( - messages: list[Message], ctx: WorkflowContext[Any, AgentResponseUpdate] - ) -> None: + async def empty_props_executor(messages: list[Message], ctx: WorkflowContext[Any, AgentResponseUpdate]) -> None: await ctx.yield_output( AgentResponseUpdate( contents=[Content.from_text(text="payload")], diff --git a/python/packages/declarative/README.md b/python/packages/declarative/README.md index 0ab3f8c9fc1..c536403aba4 100644 --- a/python/packages/declarative/README.md +++ b/python/packages/declarative/README.md @@ -20,3 +20,22 @@ This package ships at two different stability levels: ## Declarative features The declarative packages provides support for building agents based on a declarative yaml specification. + +## HTTP request client ownership and cookies + +**Breaking change:** The HTTP client created by `DefaultHttpRequestHandler` no longer +persists response cookies. The handler still creates its client lazily, reuses it across +requests and workflows, and closes it on `aclose()` or async context-manager exit. +Timeout and redirect defaults are unchanged. + +Applications requiring cookies for authentication, session continuity, or load-balancer +affinity must supply an `httpx.AsyncClient` through `client=` or `client_provider=`. +Supplied and provider-returned clients retain their configuration and cookie behavior +and must be closed by the caller. Scope cookie-bearing clients to one authenticated +principal; sharing a handler across workflows does not partition a client's cookies. +A provider returning `None` falls back to `client=`, if supplied, then to the internally +owned client with the default cookie policy. + +Explicit `Cookie` request headers remain supported. Response `Set-Cookie` headers +remain available in `HttpRequestResult.headers`; disabling persistence does not redact +the response. diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_http_handler.py b/python/packages/declarative/agent_framework_declarative/_workflows/_http_handler.py index f52cc73eff0..3dff14d17d6 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_http_handler.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_http_handler.py @@ -23,6 +23,7 @@ import asyncio from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, field +from http.cookiejar import CookieJar, DefaultCookiePolicy from typing import Any, Protocol, runtime_checkable from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit @@ -122,12 +123,18 @@ class DefaultHttpRequestHandler: Construction modes: 1. ``DefaultHttpRequestHandler()`` — owns an internal client created lazily - on first ``send()``. Closed by :meth:`aclose`. + on first ``send()`` without response-cookie persistence. Closed by :meth:`aclose`. 2. ``DefaultHttpRequestHandler(client=existing)`` — caller-owned client. - Not closed by :meth:`aclose`. + Retains its cookie behavior and is not closed by :meth:`aclose`. 3. ``DefaultHttpRequestHandler(client_provider=cb)`` — per-request client lookup (parity with .NET's ``httpClientProvider`` callback). The - provider may return ``None`` to fall back to the owned/default client. + provider's clients retain their cookie behavior and are not closed by :meth:`aclose`. + Returning ``None`` falls back to ``client``, if supplied, then to the owned client. + + Applications requiring persistent cookies must supply a client through ``client`` + or ``client_provider`` scoped to one authenticated principal and manage its lifetime. + Explicit outbound ``Cookie`` headers and response ``Set-Cookie`` headers are preserved; + the owned-client policy only prevents automatic cookie persistence. .. warning:: @@ -262,7 +269,9 @@ async def _resolve_client(self, info: HttpRequestInfo) -> httpx.AsyncClient: # one of them. async with self._owned_client_lock: if self._owned_client is None: - self._owned_client = httpx.AsyncClient() + self._owned_client = httpx.AsyncClient( + cookies=CookieJar(policy=DefaultCookiePolicy(allowed_domains=[])), + ) return self._owned_client async def __aenter__(self) -> DefaultHttpRequestHandler: diff --git a/python/packages/declarative/tests/test_default_http_request_handler.py b/python/packages/declarative/tests/test_default_http_request_handler.py index 1d4e393560d..3db80fd943e 100644 --- a/python/packages/declarative/tests/test_default_http_request_handler.py +++ b/python/packages/declarative/tests/test_default_http_request_handler.py @@ -11,7 +11,11 @@ from __future__ import annotations +import asyncio import sys +from collections.abc import AsyncIterator +from typing import Any +from unittest.mock import patch import httpx import pytest @@ -387,16 +391,87 @@ def respond(request: httpx.Request) -> httpx.Response: class TestClientOwnership: - @pytest.mark.asyncio async def test_owned_client_is_closed_on_aclose(self) -> None: + requests: list[httpx.Request] = [] + clients: list[httpx.AsyncClient] = [] + original_ctor = httpx.AsyncClient + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, headers={"Set-Cookie": "session=previous; Path=/; Secure"}) + + def create_client(**kwargs: Any) -> httpx.AsyncClient: + client = original_ctor(transport=httpx.MockTransport(respond), **kwargs) + clients.append(client) + return client + handler = DefaultHttpRequestHandler() - # Inject a MockTransport-backed client into the owned slot and verify - # aclose() releases it. Avoids real network access. - owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok"))) - handler._owned_client = owned - assert not owned.is_closed - await handler.aclose() - assert owned.is_closed + info = HttpRequestInfo(method="GET", url="https://api.example.test/x") + with patch("httpx.AsyncClient", side_effect=create_client): + for index in range(2): + async with handler: + assert handler._owned_client is None + await handler.send(info) + await handler.send(info) + assert len(clients) == index + 1 + assert handler._owned_client is clients[index] + assert not clients[index].is_closed + assert clients[index].timeout == httpx.Timeout(5.0) + assert not clients[index].follow_redirects + assert handler._owned_client is None + assert clients[index].is_closed + await handler.aclose() + assert clients[0].cookies.jar is not clients[1].cookies.jar + assert all(not client.cookies for client in clients) + assert all("Cookie" not in request.headers for request in requests) + + @pytest.mark.parametrize("provider_fallback", [False, True]) + @pytest.mark.parametrize("first_status", [200, 503]) + async def test_owned_client_does_not_replay_response_cookies( + self, provider_fallback: bool, first_status: int + ) -> None: + requests: list[httpx.Request] = [] + response_cookies = ["session=first; Path=/; HttpOnly; Secure", "affinity=first; Path=/; Secure"] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + first_status if len(requests) == 1 else 200, + text="ok", + headers=[("Set-Cookie", cookie) for cookie in response_cookies] if len(requests) == 1 else [], + ) + + async def provider(info: HttpRequestInfo) -> httpx.AsyncClient | None: + return None + + original_ctor = httpx.AsyncClient + + def create_client(**kwargs: Any) -> httpx.AsyncClient: + return original_ctor(transport=httpx.MockTransport(respond), **kwargs) + + with patch("httpx.AsyncClient", side_effect=create_client): + async with DefaultHttpRequestHandler(client_provider=provider if provider_fallback else None) as handler: + first = await handler.send( + HttpRequestInfo( + method="GET", url="https://api.example.test/x", headers={"Authorization": "caller-a"} + ) + ) + second = await handler.send( + HttpRequestInfo( + method="GET", url="https://api.example.test/x", headers={"Authorization": "caller-b"} + ) + ) + await handler.send( + HttpRequestInfo( + method="GET", url="https://api.example.test/x", headers={"Cookie": "explicit=caller"} + ) + ) + assert first.status_code == first_status + assert first.is_success_status_code is (first_status == 200) + assert first.headers["set-cookie"] == response_cookies + assert second.is_success_status_code + assert [request.headers.get("Authorization") for request in requests[:2]] == ["caller-a", "caller-b"] + assert [request.headers.get("Cookie") for request in requests] == [None, None, "explicit=caller"] @pytest.mark.asyncio async def test_caller_owned_client_is_not_closed(self) -> None: @@ -407,7 +482,6 @@ async def test_caller_owned_client_is_not_closed(self) -> None: assert not client.is_closed await client.aclose() # cleanup - @pytest.mark.asyncio async def test_concurrent_first_send_creates_single_owned_client(self) -> None: """Concurrent first-send calls must not race-leak duplicate clients. @@ -417,38 +491,50 @@ async def test_concurrent_first_send_creates_single_owned_client(self) -> None: is serialized: all concurrent sends end up using the same client and ``aclose()`` cleanly closes it. """ - import asyncio - - # Patch httpx.AsyncClient to count constructions, but only when called - # from inside _resolve_client (no transport=) so we don't break the - # MockTransport-backed clients used elsewhere. original_ctor = httpx.AsyncClient - construction_count = 0 + clients: list[httpx.AsyncClient] = [] + requests: list[httpx.Request] = [] + first_headers_received = asyncio.Event() + second_request_received = asyncio.Event() + + class PendingResponse(httpx.AsyncByteStream): + async def __aiter__(self) -> AsyncIterator[bytes]: + # HTTPX processes response cookies before reading the body. + first_headers_received.set() + await second_request_received.wait() + yield b"ok" - def counting_ctor(*args, **kwargs): # type: ignore[no-untyped-def] - nonlocal construction_count - if not args and not kwargs: - construction_count += 1 - return original_ctor(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok"))) - return original_ctor(*args, **kwargs) + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + if len(requests) == 1: + return httpx.Response( + 200, + headers={"Set-Cookie": "session=first; Path=/; HttpOnly; Secure"}, + stream=PendingResponse(), + ) + second_request_received.set() + return httpx.Response(200, text="ok") - import agent_framework_declarative._workflows._http_handler as hh + def create_client(**kwargs: Any) -> httpx.AsyncClient: + client = original_ctor(transport=httpx.MockTransport(respond), **kwargs) + clients.append(client) + return client - hh.httpx.AsyncClient = counting_ctor # type: ignore[assignment] # ty: ignore[invalid-assignment] - try: - handler = DefaultHttpRequestHandler() - try: - await asyncio.gather(*[ - handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x")) for _ in range(8) - ]) - finally: - await handler.aclose() - finally: - hh.httpx.AsyncClient = original_ctor # type: ignore[assignment] + with patch("httpx.AsyncClient", side_effect=create_client): + async with DefaultHttpRequestHandler() as handler: - assert construction_count == 1, ( - f"Expected exactly 1 owned client to be lazily created but got {construction_count}" - ) + async def send(index: int) -> None: + if index: + await first_headers_received.wait() + result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x")) + assert result.body == "ok" + + await asyncio.wait_for(asyncio.gather(*(send(index) for index in range(8))), timeout=5) + assert len(clients) == 1 + assert handler._owned_client is clients[0] + assert clients[0].is_closed + assert len(requests) == 8 + assert all("Cookie" not in request.headers for request in requests) class TestClientProvider: @@ -477,6 +563,8 @@ async def provider(info: HttpRequestInfo) -> httpx.AsyncClient: assert captured["transport"] == "provided" finally: await handler.aclose() + assert not primary_client.is_closed + assert not provided_client.is_closed await primary_client.aclose() await provided_client.aclose() @@ -634,23 +722,33 @@ def respond(request: httpx.Request) -> httpx.Response: assert captured["req"].url.raw_path == (b"/s?in-url=fromurl&in-explicit=fromexplicit&unique=kept") - @pytest.mark.asyncio - async def test_client_headers_cookies_auth_and_timeout_survive(self) -> None: + @pytest.mark.parametrize("client_source", ["caller", "provider", "provider_none"]) + async def test_client_headers_cookies_auth_and_timeout_survive(self, client_source: str) -> None: """Building through the client keeps its configuration, params merge bypassed.""" - captured: dict[str, httpx.Request] = {} + requests: list[httpx.Request] = [] def respond(request: httpx.Request) -> httpx.Response: - captured["req"] = request - return httpx.Response(200, text="ok") + requests.append(request) + return httpx.Response(200, text="ok", headers={"Set-Cookie": "response=retained; Path=/; Secure"}) + auth = httpx.BasicAuth("user", "pass") client = httpx.AsyncClient( params={"api-version": "2024-01-01"}, headers={"X-Client-Header": "client-value"}, cookies={"session": "cookie-value"}, - auth=("user", "pass"), + auth=auth, + timeout=17.0, + follow_redirects=True, transport=httpx.MockTransport(respond), ) - handler = DefaultHttpRequestHandler(client=client) + + async def provider(info: HttpRequestInfo) -> httpx.AsyncClient | None: + return client if client_source == "provider" else None + + handler = DefaultHttpRequestHandler( + client=client if client_source != "provider" else None, + client_provider=provider if client_source != "caller" else None, + ) try: await handler.send( HttpRequestInfo( @@ -659,11 +757,24 @@ def respond(request: httpx.Request) -> httpx.Response: timeout_ms=4500, ) ) + await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/s")) + await handler.aclose() + assert not client.is_closed + assert handler._owned_client is None + assert client.cookies["session"] == "cookie-value" + assert client.cookies["response"] == "retained" + assert client.auth is auth + assert client.timeout == httpx.Timeout(17.0) + assert client.follow_redirects + assert set(requests[1].headers["Cookie"].split("; ")) == { + "session=cookie-value", + "response=retained", + } finally: await handler.aclose() await client.aclose() - request = captured["req"] + request = requests[0] assert request.headers["X-Client-Header"] == "client-value" assert request.headers["Cookie"] == "session=cookie-value" assert request.headers["Authorization"].startswith("Basic ") diff --git a/python/packages/declarative/tests/test_http_request_yaml_integration.py b/python/packages/declarative/tests/test_http_request_yaml_integration.py index 68fb22cb050..1887b6787db 100644 --- a/python/packages/declarative/tests/test_http_request_yaml_integration.py +++ b/python/packages/declarative/tests/test_http_request_yaml_integration.py @@ -12,7 +12,9 @@ import sys from pathlib import Path from typing import Any +from unittest.mock import patch +import httpx import pytest try: @@ -36,6 +38,7 @@ from agent_framework_declarative import WorkflowFactory # noqa: E402 from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY # noqa: E402 from agent_framework_declarative._workflows._http_handler import ( # noqa: E402 + DefaultHttpRequestHandler, HttpRequestInfo, HttpRequestResult, ) @@ -98,6 +101,49 @@ async def test_http_request_yaml_roundtrip() -> None: assert sent.headers["User-Agent"] == "agent-framework-integration-test" +async def test_http_request_yaml_shared_handler_does_not_replay_cookies() -> None: + requests: list[httpx.Request] = [] + + def respond(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response( + 200, + text="ok", + headers={"Set-Cookie": "session=first; Path=/; HttpOnly; Secure"} if len(requests) == 1 else {}, + ) + + original_ctor = httpx.AsyncClient + + def create_client(**kwargs: Any) -> httpx.AsyncClient: + return original_ctor(transport=httpx.MockTransport(respond), **kwargs) + + definition = """ +kind: Workflow +trigger: + kind: OnConversationStart + id: http_cookie_test + actions: + - kind: HttpRequestAction + id: request + method: GET + url: https://api.example.test/x + headers: + Authorization: caller-a + response: Local.Response +""" + with patch("httpx.AsyncClient", side_effect=create_client): + async with DefaultHttpRequestHandler() as handler: + factory = WorkflowFactory(http_request_handler=handler) + first = factory.create_workflow_from_yaml(definition) + second = factory.create_workflow_from_yaml(definition.replace("caller-a", "caller-b")) + await first.run({}) + client = handler._owned_client + await second.run({}) + assert handler._owned_client is client + assert [request.headers["Authorization"] for request in requests] == ["caller-a", "caller-b"] + assert all("Cookie" not in request.headers for request in requests) + + @pytest.mark.asyncio async def test_http_request_yaml_missing_handler_fails_at_build_time() -> None: """Without an http_request_handler, building the workflow must raise.""" From d341515c075639aaaa0ce99be44ed2ab28637b5a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:45:08 +0000 Subject: [PATCH 086/108] .NET: [PREVIEW BREAKING] Promote `AgentSessionStore` into Agents.AI.Abstractions (#7991) * feat(dotnet): share agent session store abstraction * fix(dotnet): address session store review findings * fix(dotnet): remove legacy session key fallback * feat(dotnet): add partitioned session store keys * refactor(dotnet): keep session keys storage agnostic * refactor(dotnet): promote delegating session store * Restore session store service discovery Preserve GetService in the shared contract and delegate service queries through store decorators. Use discovery to avoid duplicate isolation wrappers in Hosting, A2A, and AGUI. Update public APIs, documentation, and regression coverage with scoped RS0026 pragmas. Copilot-Session: 22c59c1d-1805-41b5-b868-c01855e7ceb7 * Avoid dictionary allocations for unpartitioned session keys Make AgentSessionStoreKey.Partitions nullable and normalize absent or empty partitions to null. Update equality, hashing, storage encoders, API declarations, and regression tests. Copilot-Session: 22c59c1d-1805-41b5-b868-c01855e7ceb7 --------- Copilot-Session: 22c59c1d-1805-41b5-b868-c01855e7ceb7 --- ...sted-per-user-session-storage-isolation.md | 7 + .../0032-dotnet-hosting-protocol-helpers.md | 3 + .../0039-shared-agent-session-store.md | 94 ++++ .../003-dotnet-hosting-protocol-helpers.md | 72 ++- .../af-hosting/local_responses/README.md | 2 +- .../local_responses/Server/Program.cs | 14 +- .../local_responses/Server/README.md | 6 +- .../AgentSessionStore.cs | 114 +++++ .../AgentSessionStoreKey.cs | 159 +++++++ .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 15 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 15 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 15 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 15 + .../netstandard2.0/PublicAPI.Unshipped.txt | 15 + .../AgentFrameworkResponseHandler.cs | 31 +- .../AgentSessionStore.cs | 97 ---- .../FileSystemAgentSessionStore.cs | 190 ++------ .../FoundryAgentSessionKeyEncoder.cs | 64 +++ .../FoundryAgentSessionStore.cs | 95 +--- .../FoundryHostingAgent.cs | 32 ++ .../InMemoryAgentSessionStore.cs | 53 +-- .../A2AServerServiceCollectionExtensions.cs | 18 +- .../Microsoft.Agents.AI.Hosting.A2A.csproj | 1 + .../AGUIEndpointRouteBuilderExtensions.cs | 19 +- ...t.Agents.AI.Hosting.AGUI.AspNetCore.csproj | 1 + ...ClaimsIdentityAgentIsolationKeyProvider.cs | 9 +- .../AzureBlobHostedAgentBuilderExtensions.cs | 4 +- .../Blob/AzureBlobAgentSessionStore.cs | 97 ++-- ...soft.Agents.AI.Hosting.AzureStorage.csproj | 4 + .../AIHostAgent.cs | 39 +- .../AgentSessionStore.cs | 138 ------ .../HostedAgentBuilderExtensions.cs | 6 +- .../IsolationKeyScopedAgentSessionStore.cs | 80 ++-- ...lationKeyScopedAgentSessionStoreOptions.cs | 6 +- .../Local/InMemoryAgentSessionStore.cs | 60 +-- .../Microsoft.Agents.AI.Hosting.csproj | 3 + .../NoopAgentSessionStore.cs | 24 +- .../DelegatingAgentSessionStore.cs | 56 +-- .../PublicAPI/net10.0/PublicAPI.Unshipped.txt | 6 + .../PublicAPI/net472/PublicAPI.Unshipped.txt | 6 + .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 6 + .../PublicAPI/net9.0/PublicAPI.Unshipped.txt | 6 + .../netstandard2.0/PublicAPI.Unshipped.txt | 6 + .../AgentSessionStoreKeyTests.cs | 155 +++++++ .../AgentSessionStoreTests.cs | 145 ++++++ ...FrameworkResponseHandlerResilienceTests.cs | 23 +- .../AgentFrameworkResponseHandlerTests.cs | 47 +- .../FileSystemAgentSessionStoreTests.cs | 419 ++++++----------- .../FoundryAgentSessionStoreTests.cs | 129 ++++-- .../FoundryStateStoreLocalFallbackTests.cs | 5 +- .../HostedSessionIdentityContextTests.cs | 21 +- .../ResilientTwoLifetimeIntegrationTests.cs | 12 +- .../A2AAgentHandlerTests.cs | 116 +++-- ...AServerServiceCollectionExtensionsTests.cs | 11 +- ...AGUIEndpointRouteBuilderExtensionsTests.cs | 1 + .../AzureBlobAgentSessionStoreTests.cs | 121 ++++- ...reBlobHostedAgentBuilderExtensionsTests.cs | 1 - .../AnthropicResponsesHostingLiveTests.cs | 8 +- .../OpenAIResponsesHostingLiveTests.cs | 8 +- .../OpenAIResponsesHostingTests.cs | 17 +- ...sIdentityAgentIsolationKeyProviderTests.cs | 6 +- .../DelegatingAgentSessionStoreTests.cs | 403 ---------------- .../HostedAgentBuilderToolsExtensionsTests.cs | 63 +++ .../InMemoryAgentSessionStoreTests.cs | 102 ++--- ...solationKeyScopedAgentSessionStoreTests.cs | 432 ++++-------------- .../DelegatingAgentSessionStoreTests.cs | 319 +++++++++++++ 66 files changed, 2276 insertions(+), 1991 deletions(-) create mode 100644 docs/decisions/0039-shared-agent-session-store.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs rename dotnet/src/{Microsoft.Agents.AI.Hosting => Microsoft.Agents.AI}/DelegatingAgentSessionStore.cs (53%) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs diff --git a/docs/decisions/0031-hosted-per-user-session-storage-isolation.md b/docs/decisions/0031-hosted-per-user-session-storage-isolation.md index f7ca09a46e1..beb6942c489 100644 --- a/docs/decisions/0031-hosted-per-user-session-storage-isolation.md +++ b/docs/decisions/0031-hosted-per-user-session-storage-isolation.md @@ -101,6 +101,13 @@ Negative: - Encryption at rest and quota enforcement remain platform concerns. - Non-Foundry hosting layers can adopt an equivalent scheme independently. +## Update (2026-09-01): contract promoted to Abstractions + +[ADR-0039](0039-shared-agent-session-store.md) promotes this `AgentSessionStore` contract to +`Microsoft.Agents.AI.Abstractions` and makes it the common contract for Foundry Hosting and conventional +Hosting. It supersedes the required `userId` parameter with `AgentSessionStoreKey.Partitions`. Foundry +Hosting adds the resolved user identity as a named partition before loading or saving a session. + ## Update (2026-07-01): local runs no longer fail closed; sample dev provider removed Superseding the ADR-0026/0030 behavior where a `null` result from `HostedSessionIsolationKeyProvider` diff --git a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md index 907410e1cc3..bc3d36adf38 100644 --- a/docs/decisions/0032-dotnet-hosting-protocol-helpers.md +++ b/docs/decisions/0032-dotnet-hosting-protocol-helpers.md @@ -11,6 +11,9 @@ informed: [] Realizes the helper-first direction of [ADR-0027](0027-hosting-channels.md) for .NET. +> **Update (2026-09-01):** [ADR-0039](0039-shared-agent-session-store.md) supersedes the +> `AgentSessionStore` portion of this decision. The protocol helper and workflow decisions remain accepted. + ## Context and Problem Statement [ADR-0027](0027-hosting-channels.md) refocused the (Python) hosting design away from a channel diff --git a/docs/decisions/0039-shared-agent-session-store.md b/docs/decisions/0039-shared-agent-session-store.md new file mode 100644 index 00000000000..a6616e5e2b4 --- /dev/null +++ b/docs/decisions/0039-shared-agent-session-store.md @@ -0,0 +1,94 @@ +--- +status: proposed +contact: rogerbarreto +date: 2026-09-01 +deciders: rogerbarreto +consulted: [] +informed: [] +--- + +# Shared AgentSessionStore abstraction + +## Context and Problem Statement + +.NET has two public `AgentSessionStore` abstract classes. `Microsoft.Agents.AI.Hosting` defines a store +whose lookup creates a session when no value exists. `Microsoft.Agents.AI.Foundry.Hosting` defines a store +whose lookup returns `null`, accepts an explicit user partition, and provides a separate convenience method +that creates a session when needed. The types cannot be used interchangeably, so storage integrations depend +on a specific hosting protocol package instead of the core agent abstractions. + +## Decision Drivers + +- One storage contract must work across all hosting packages. +- Storage implementations must depend only on `Microsoft.Agents.AI.Abstractions`. +- A lookup must distinguish a missing value from a stored value without creating state as a side effect. +- The contract must support any number of isolation dimensions without privileging user identity. +- Existing Foundry storage behavior and per-user isolation must remain unchanged. + +## Considered Options + +1. Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Abstractions`. +2. Promote the conventional Hosting contract and adapt Foundry Hosting to it. +3. Add a third contract and keep adapters for both existing contracts. +4. Represent session identity as an immutable key with arbitrary named partitions. + +## Decision Outcome + +Chosen option: **Promote the Foundry Hosting contract to `Microsoft.Agents.AI.Abstractions`**. + +`AgentSessionStore` moves to the `Microsoft.Agents.AI` namespace and keeps the Foundry Hosting behavior: + +- The abstraction and every public implementation start as experimental under diagnostic `MAAI001`. +- `GetSessionAsync` returns `AgentSession?` and returns `null` when no session is stored. +- `GetOrCreateSessionAsync` performs the explicit lookup or creation operation. +- `SaveSessionAsync` and both lookup methods receive an `AgentSessionStoreKey`. +- `AgentSessionStoreKey.SessionId` identifies the logical session. +- `AgentSessionStoreKey.Partitions` holds zero or more named isolation dimensions. Every partition is + part of identity and implementations cannot ignore unknown partitions. +- Keys without partitions expose `Partitions = null`, avoiding dictionary allocations. Omitted, null, + and empty constructor inputs all represent the same unpartitioned identity. +- Partition order does not affect identity. Physical encoding remains the responsibility of each store. +- `GetService(Type, object?)` and `GetService(object?)` retain service discovery from conventional + Hosting. Stores can expose themselves, underlying implementations, or additional capabilities. +- `DeleteSessionAsync` is not part of the shared contract. + +The duplicate types in `Microsoft.Agents.AI.Hosting` and `Microsoft.Agents.AI.Foundry.Hosting` are removed. +Both packages reference the shared type directly. + +`DelegatingAgentSessionStore` lives in the `Microsoft.Agents.AI` package beside `ChatClientAgent`, providing +the common decorator base without requiring a hosting-protocol package. Its service queries check the +outer instance first, then forward to the inner store. Hosting registration uses this discovery to +recognize existing isolation even when other decorators surround it, avoiding a second isolation wrapper. + +The conventional Hosting implementations adopt the same behavior. `IsolationKeyScopedAgentSessionStore` +adds the value from `AgentIsolationKeyProvider` under the `isolation` partition while preserving existing +partitions. Protocol-specific hosting can add named partitions such as `user`, `tenant`, or `chat` before +loading the session. `AIHostAgent` uses `GetOrCreateSessionAsync` when it needs a ready session. + +Azure Blob Storage, filesystem storage, and Foundry State Store each encode the session id and every +partition into their own collision-safe physical key. Version 1 Azure +Blob keys are not read because the package is still preview and the previous format cannot distinguish +all partition combinations safely. + +Provider-specific metadata does not belong in `AgentSessionStoreKey`. For example, Foundry item tags can +be exposed by an overload or options type on `FoundryAgentSessionStore` without adding tags to Abstractions. + +## Consequences + +Positive: + +- Storage implementations can be shared by Foundry Hosting, conventional Hosting, and future protocols. +- Missing session handling is explicit and consistent. +- Isolation dimensions are explicit, composable, and independent from any hosting protocol. +- `Microsoft.Agents.AI.Abstractions` owns the contract alongside `AIAgent` and `AgentSession`. + +Negative: + +- This is a source-breaking change for implementations of the preview Hosting contract. +- Callers must construct an `AgentSessionStoreKey`; unpartitioned sessions use only `SessionId`. +- Consumers that need deletion must use a storage-specific API until a separate shared deletion capability is defined. + +## More Information + +- [ADR-0031](0031-hosted-per-user-session-storage-isolation.md) records the earlier Foundry-specific user partition. +- [ADR-0032](0032-dotnet-hosting-protocol-helpers.md) records the previous conventional Hosting contract. diff --git a/docs/specs/003-dotnet-hosting-protocol-helpers.md b/docs/specs/003-dotnet-hosting-protocol-helpers.md index 68a3a4353f3..746d3634bf9 100644 --- a/docs/specs/003-dotnet-hosting-protocol-helpers.md +++ b/docs/specs/003-dotnet-hosting-protocol-helpers.md @@ -87,23 +87,34 @@ does (by default no request setting is mapped onto the run; unsupported settings converters (an internal `ToResponse` overload with an optional originating request is added so the facade can render without one). The streaming renderer's existing workflow-event support is preserved. -### `Microsoft.Agents.AI.Hosting` (execution state, protocol-neutral) +### `Microsoft.Agents.AI.Abstractions` (agent session persistence) ```csharp -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI; public abstract class AgentSessionStore { - // ... existing members ... - - // New: the one missing store operation. Virtual (not abstract) with a default that throws - // NotSupportedException, so existing external stores (e.g. the Foundry hosting stores) keep - // compiling; the in-box Hosting stores override it. In-box overrides treat deleting a missing - // session as a no-op. - public virtual ValueTask DeleteSessionAsync( - AIAgent agent, string conversationId, CancellationToken cancellationToken = default); + public abstract ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default); + + public virtual ValueTask GetOrCreateSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default); + + public abstract ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default); } +``` + +### `Microsoft.Agents.AI.Hosting` (workflow execution state) +```csharp // Thin holder: pairs a workflow target with checkpointing + a per-session head cursor. public sealed class HostedWorkflowState { @@ -121,15 +132,19 @@ public sealed class HostedWorkflowState } ``` -For agents, the application uses `AgentSessionStore` directly: `GetSessionAsync(agent, id)` creates a -session on miss and returns an independent instance per call (so concurrent calls can fork the same -stored state — for example branching from a `previous_response_id` or managing several `conversation` -ids side by side — without one branch observing another's in-flight mutations). The store performs no -cross-call locking; an application that needs concurrent runs against the same id to be serialized owns -that coordination. `SaveSessionAsync(agent, id, session)` persists post-run, including under a newly -minted `resp_*` id when the protocol mints a new continuation id. `DeleteSessionAsync` uses the new -store method. No agent-side holder is needed: create-on-miss already lives in the store, so a -pass-through wrapper would only bind the `agent` argument. +For agents, the application uses `AgentSessionStore` directly. `GetSessionAsync(agent, key)` +returns `null` on a miss, while `GetOrCreateSessionAsync(agent, key)` returns a ready session. +Each successful lookup returns an independent instance, so concurrent calls can fork the same stored +state without observing another branch's changes. The store performs no cross-call locking. An +application that needs concurrent runs against the same id to be serialized owns that coordination. +`SaveSessionAsync(agent, key, session)` persists the post-run state, including under a newly +minted `resp_*` id when the protocol creates a continuation id. No agent-side holder is needed because +the convenience method already performs lookup or creation. + +`AgentSessionStoreKey` contains a session id plus arbitrary named partitions. Every partition contributes +to identity, independent of dictionary order. Stores must not ignore unknown partitions. Physical key +encoding belongs to each store implementation. Provider-specific +metadata such as Foundry tags is not part of the key or the Abstractions contract. `HostedWorkflowState` defaults to `CheckpointManager.CreateInMemory()` and an in-memory `sessionId -> CheckpointInfo` cursor. Because the checkpoint store is already `sessionId`-keyed but @@ -171,8 +186,8 @@ parsing a structured payload into a typed record), without coupling the holder t - Authenticate the caller before using any `GetSessionId(...)` result. - Authorize and bind the candidate id to the authenticated principal/tenant before using it as an `AgentSessionStore` key or a workflow checkpoint session id. -- For multi-user hosts, wrap the store with `IsolationKeyScopedAgentSessionStore` (for example via - `UseClaimsBasedAgentIsolation(...)`), so the session namespace is scoped per principal. +- Multi-user hosts must add a trusted identity partition, or wrap the store with + `IsolationKeyScopedAgentSessionStore` so `AgentIsolationKeyProvider` supplies one. - Persist session/checkpoint state only after the run or stream has completed. ## E2E Code Samples @@ -193,7 +208,8 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => string sessionId = Authorize(http.User, candidate) ?? OpenAIResponses.CreateResponseId(); var run = OpenAIResponses.ToAgentRunRequest(body); - var session = await sessionStore.GetSessionAsync(agent, sessionId, ct); + var key = new AgentSessionStoreKey(sessionId); + var session = await sessionStore.GetOrCreateSessionAsync(agent, key, ct); string responseId = OpenAIResponses.CreateResponseId(); @@ -206,12 +222,20 @@ app.MapPost("/responses", async (HttpContext http, CancellationToken ct) => await http.Response.WriteAsync(frame, ct); await http.Response.Body.FlushAsync(ct); } - await sessionStore.SaveSessionAsync(agent, responseId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + new AgentSessionStoreKey(responseId), + session, + ct); return Results.Empty; } var result = await agent.RunAsync(run.Messages, session, run.Options, ct); - await sessionStore.SaveSessionAsync(agent, responseId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + new AgentSessionStoreKey(responseId), + session, + ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, sessionId)); }); ``` diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md index 5f6fb1bd6d4..4873bd3b202 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/README.md @@ -54,4 +54,4 @@ The client defaults to `http://localhost:5000`; override with `RESPONSES_SERVER_ `OpenAIResponses.GetSessionStoreId(...)` returns an untrusted candidate key. The server's `Authorize(...)` is a placeholder; a real application must authenticate the caller and authorize/bind the id to the authenticated principal before using it as a session key. For multi-user hosts, scope the store with -`IsolationKeyScopedAgentSessionStore`. +`IsolationKeyScopedAgentSessionStore`, or add trusted partition values to `AgentSessionStoreKey`. diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs index b9a62c09dbc..71be67b9cc2 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/Program.cs @@ -46,8 +46,8 @@ static string LookupWeather([Description("The city to look up weather for.")] st name: "WeatherAgent", tools: [AIFunctionFactory.Create(LookupWeather, name: "lookup_weather")]); -// The application owns session storage directly. The in-memory store's GetSessionAsync creates a session -// on first use and returns an independent instance per call; no shared holder is needed. A real app that +// The application owns session storage directly. GetOrCreateSessionAsync loads a saved session or creates +// one on first use and returns an independent instance per call. A real app that // runs concurrent turns against the same session id owns any coordination it needs. AgentSessionStore sessionStore = new InMemoryAgentSessionStore(); @@ -65,8 +65,12 @@ static string LookupWeather([Description("The city to look up weather for.")] st // this key to the principal before using it. This sample simply falls back to a fresh id. string? candidateSessionStoreId = OpenAIResponses.GetSessionStoreId(run); string sessionStoreId = Authorize(http, candidateSessionStoreId) ?? OpenAIResponses.CreateResponseId(); + var sessionKey = new AgentSessionStoreKey(sessionStoreId); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSession session = await sessionStore.GetOrCreateSessionAsync( + agent, + sessionKey, + cancellationToken).ConfigureAwait(false); string responseId = OpenAIResponses.CreateResponseId(); // Choose where to persist the post-run session, which depends on how the caller continued the thread: @@ -92,7 +96,7 @@ static string LookupWeather([Description("The city to look up weather for.")] st } // Persist the post-run session under the selected continuation id (see saveId above). - await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(saveId), session, cancellationToken).ConfigureAwait(false); // The SSE body was already written straight to http.Response above, so return an empty result: // this returns from the handler (the non-streaming code below does not run) without writing a body. @@ -100,7 +104,7 @@ static string LookupWeather([Description("The city to look up weather for.")] st } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, cancellationToken).ConfigureAwait(false); - await sessionStore.SaveSessionAsync(agent, saveId, session, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(saveId), session, cancellationToken).ConfigureAwait(false); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md index 273e22b4e5c..e1faafb7278 100644 --- a/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md +++ b/dotnet/samples/04-hosting/af-hosting/local_responses/Server/README.md @@ -11,9 +11,9 @@ Exposes an `AIAgent` over the OpenAI Responses protocol on a `POST /responses` r - `OpenAIResponses.WriteResponse(...)` / `WriteResponseStreamAsync(...)` render the agent output back to the Responses wire shape (non-streaming JSON and SSE). -Session continuity uses an in-memory `AgentSessionStore` directly. `GetSessionAsync(agent, id)` creates a -session on first use and returns an independent instance per call; the store does no internal locking, so a -route that runs concurrent turns against the same id owns any coordination it needs. +Session continuity uses an in-memory `AgentSessionStore` directly. `GetOrCreateSessionAsync` loads a stored +session or creates one on first use and returns an independent instance per call. The store does no internal +locking, so a route that runs concurrent turns against the same id owns any coordination it needs. The route persists each turn under a continuation id chosen by how the caller continued the thread: diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs new file mode 100644 index 00000000000..3a72dde499b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStore.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Defines the contract for storing and retrieving agent conversation sessions. +/// +/// +/// Implementations enable persistent storage of conversation sessions, allowing conversations to be +/// resumed across HTTP requests, application restarts, or different service instances in hosted scenarios. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public abstract class AgentSessionStore +{ + /// + /// Saves an agent session to persistent storage. + /// + /// The agent that owns this session. + /// The key that identifies and partitions the session. + /// The session to save. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public abstract ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default); + + /// + /// Retrieves an agent session from persistent storage, or when no session is stored + /// for the given identifiers. + /// + /// The agent that owns this session. + /// The key that identifies and partitions the session. + /// The to monitor for cancellation requests. + /// + /// A task whose result contains the restored session, or when nothing is stored for + /// the given identifiers. This method never creates a session. + /// + /// + /// Each successful lookup must return an independent instance. Callers may + /// mutate the returned session and may run concurrent branches from the same identifiers without those + /// branches observing one another's changes or modifying the stored state. Implementations that cache a + /// live session must return an independent copy rather than the shared instance. + /// + public abstract ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default); + + /// + /// Retrieves the stored session for the given identifiers, or creates a new one when none is stored. + /// + /// The agent that owns this session. + /// The key that identifies and partitions the session. + /// The to monitor for cancellation requests. + /// A task whose result is always a usable session. + /// + /// The default implementation calls and creates a session through + /// only when the lookup returns . + /// Implementations that override receive this behavior automatically. + /// + public virtual async ValueTask GetOrCreateSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(agent); + + return await this.GetSessionAsync(agent, key, cancellationToken).ConfigureAwait(false) + ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + } + + /// Asks the store for an object of the specified type. + /// The type of object being requested. + /// An optional key that identifies the requested service. + /// The requested object, or if it is not available. + /// is . + /// + /// Stores can expose themselves, underlying stores, or additional services through this method. + /// The default implementation returns this instance when no key is supplied and it is assignable to + /// . Otherwise, it returns . + /// +#pragma warning disable RS0026 // Preserves the existing Hosting service discovery contract and matches AIAgent. + public virtual object? GetService(Type serviceType, object? serviceKey = null) + { + _ = Throw.IfNull(serviceType); + + return serviceKey is null && serviceType.IsInstanceOfType(this) + ? this + : null; + } +#pragma warning restore RS0026 + + /// Asks the store for an object of type . + /// The type of object being requested. + /// An optional key that identifies the requested service. + /// The requested object, or the default value of if it is not available. + /// + /// This method calls so that services exposed by derived stores + /// are available through both overloads. + /// +#pragma warning disable RS0026 // Preserves the existing Hosting service discovery contract and matches AIAgent. + public TService? GetService(object? serviceKey = null) + => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; +#pragma warning restore RS0026 +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs new file mode 100644 index 00000000000..04544918b3a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/AgentSessionStoreKey.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI; + +/// +/// Identifies an agent session in persistent storage. +/// +/// +/// +/// identifies the session while contains additional +/// named dimensions that isolate sessions sharing that identifier. Every partition is part of the +/// identity and must be honored by implementations. +/// +/// +/// Partition names are compared using ordinal, case-sensitive comparison. Partition ordering does not +/// affect identity. Names and values cannot be empty or whitespace. +/// +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSessionStoreKey : IEquatable +{ + private readonly int _hashCode; + + /// + /// Initializes a new instance of the class. + /// + /// The logical session identifier. + /// + /// Optional named partition values. Every partition contributes to identity. Nonempty collections are copied. + /// A null or empty collection leaves null. + /// + public AgentSessionStoreKey( + string sessionId, + IReadOnlyDictionary? partitions = null) + { + this.SessionId = Throw.IfNullOrWhitespace(sessionId); + + if (partitions is { Count: > 0 }) + { + var partitionCopy = new SortedDictionary(StringComparer.Ordinal); + foreach (KeyValuePair partition in partitions) + { + partitionCopy.Add( + Throw.IfNullOrWhitespace(partition.Key, nameof(partitions)), + Throw.IfNullOrWhitespace(partition.Value, nameof(partitions))); + } + + this.Partitions = new ReadOnlyDictionary(partitionCopy); + } + + this._hashCode = this.ComputeHashCode(); + } + + /// + /// Gets the logical session identifier. + /// + public string SessionId { get; } + + /// + /// Gets the named partition values that form part of the session identity, or + /// when the key has no partitions. + /// + public IReadOnlyDictionary? Partitions { get; } + + /// + /// Returns a new key containing the specified partition. + /// + /// The partition name. + /// The partition value. + /// + /// A new key with the partition added or replaced, or this instance when the partition already has + /// the specified value. + /// + public AgentSessionStoreKey WithPartition(string name, string value) + { + name = Throw.IfNullOrWhitespace(name); + value = Throw.IfNullOrWhitespace(value); + + if (this.Partitions is not null + && this.Partitions.TryGetValue(name, out string? existingValue) + && string.Equals(existingValue, value, StringComparison.Ordinal)) + { + return this; + } + + var partitions = new Dictionary(StringComparer.Ordinal); + if (this.Partitions is not null) + { + foreach (KeyValuePair partition in this.Partitions) + { + partitions.Add(partition.Key, partition.Value); + } + } + partitions[name] = value; + + return new AgentSessionStoreKey(this.SessionId, partitions); + } + + /// + public bool Equals(AgentSessionStoreKey? other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other is null + || !string.Equals(this.SessionId, other.SessionId, StringComparison.Ordinal) + || this.Partitions?.Count != other.Partitions?.Count) + { + return false; + } + + if (this.Partitions is not null && other.Partitions is not null) + { + foreach (KeyValuePair partition in this.Partitions) + { + if (!other.Partitions.TryGetValue(partition.Key, out string? value) + || !string.Equals(partition.Value, value, StringComparison.Ordinal)) + { + return false; + } + } + } + + return true; + } + + /// + public override bool Equals(object? obj) => this.Equals(obj as AgentSessionStoreKey); + + /// + public override int GetHashCode() => this._hashCode; + + private int ComputeHashCode() + { + unchecked + { + int hashCode = StringComparer.Ordinal.GetHashCode(this.SessionId); + if (this.Partitions is not null) + { + foreach (KeyValuePair partition in this.Partitions) + { + hashCode = (hashCode * 31) + StringComparer.Ordinal.GetHashCode(partition.Key); + hashCode = (hashCode * 31) + StringComparer.Ordinal.GetHashCode(partition.Value); + } + } + + return hashCode; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt index ab058de62d4..4f67fa96c03 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1 +1,16 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary? +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt index ab058de62d4..4f67fa96c03 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1 +1,16 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary? +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt index ab058de62d4..4f67fa96c03 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1 +1,16 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary? +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt index ab058de62d4..4f67fa96c03 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1 +1,16 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary? +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index ab058de62d4..4f67fa96c03 100644 --- a/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI.Abstractions/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1 +1,16 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.AgentSessionStore +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.AgentSessionStore() -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStore.GetService(object? serviceKey = null) -> TService? +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]abstract Microsoft.Agents.AI.AgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]virtual Microsoft.Agents.AI.AgentSessionStore.GetOrCreateSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.AgentSessionStoreKey(string! sessionId, System.Collections.Generic.IReadOnlyDictionary? partitions = null) -> void +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Equals(Microsoft.Agents.AI.AgentSessionStoreKey? other) -> bool +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.Partitions.get -> System.Collections.Generic.IReadOnlyDictionary? +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.SessionId.get -> string! +[MAAI001]Microsoft.Agents.AI.AgentSessionStoreKey.WithPartition(string! name, string! value) -> Microsoft.Agents.AI.AgentSessionStoreKey! +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.Equals(object? obj) -> bool +[MAAI001]override Microsoft.Agents.AI.AgentSessionStoreKey.GetHashCode() -> int diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index b9983ed6f05..efe820a3691 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -36,6 +36,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; public class AgentFrameworkResponseHandler : ResponseHandler { private const string LatestWorkflowCheckpointIdMetadataKey = "_last_checkpoint_id"; + private const string UserPartitionName = "user"; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -130,8 +131,8 @@ public override async IAsyncEnumerable CreateAsync( // When resolvedHostedContext is null here the container is NOT hosted by Foundry (local // development: docker run / dotnet run outside the platform, so no x-agent-user-id header). - // Per-user isolation simply does not apply in that case: the request proceeds with a null user - // id (the session store treats null as "no user partition") and no hosted context is stamped or + // Per-user isolation simply does not apply in that case: no user partition is added to the + // session key and no hosted context is stamped or // validated. This lets contributors run the image locally without registering a fallback // provider, while production stays strict because FoundryEnvironment.IsHosted is true there. var resolvedUserId = resolvedHostedContext?.UserId; @@ -140,13 +141,20 @@ public override async IAsyncEnumerable CreateAsync( // Map the request to a stable MAF AgentSession key: conversation_id when present, else the // partition embedded in previous_response_id (chains converge), else the minted response id // (cold start). Container session id is intentionally not used — it spans many conversations. - // The session store partitions persisted state per user via resolvedUserId so one user can + // The session key partitions persisted state per user via resolvedUserId so one user can // never observe another user's session, even with a forged conversation id. Locally // (resolvedUserId is null) there is no user to partition on, so the session is unscoped/shared // by design — per-user isolation applies only when a user identity was resolved (hosted). var conversationId = request.GetConversationId(); var agentSessionId = HostedConversationKey.Resolve( conversationId, request.PreviousResponseId, context.ResponseId); + AgentSessionStoreKey? agentSessionKey = string.IsNullOrWhiteSpace(agentSessionId) + ? null + : new AgentSessionStoreKey(agentSessionId); + if (agentSessionKey is not null && resolvedUserId is not null) + { + agentSessionKey = agentSessionKey.WithPartition(UserPartitionName, resolvedUserId); + } var agentOptions = agent.GetService(); var hostingOptions = this._serviceProvider.GetService>()?.Value; @@ -157,7 +165,7 @@ public override async IAsyncEnumerable CreateAsync( // a session to run against. AgentSession? session; bool sessionRestoredFromStore = false; - if (string.IsNullOrWhiteSpace(agentSessionId)) + if (agentSessionKey is null) { session = await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } @@ -165,8 +173,7 @@ public override async IAsyncEnumerable CreateAsync( { session = await sessionStore.GetSessionAsync( agent, - agentSessionId, - resolvedUserId, + agentSessionKey, cancellationToken).ConfigureAwait(false); sessionRestoredFromStore = session is not null; @@ -648,12 +655,12 @@ await PersistWorkflowCheckpointAsync( && evt is ResponseOutputItemDoneEvent && session is not null && session.GetService() is null - && !string.IsNullOrWhiteSpace(agentSessionId) + && agentSessionKey is not null && !turnFailed) { try { - await sessionStore.SaveSessionAsync(agent, agentSessionId!, session, resolvedUserId, cancellationToken).ConfigureAwait(false); + await sessionStore.SaveSessionAsync(agent, agentSessionKey, session, cancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -690,9 +697,8 @@ await PersistWorkflowCheckpointAsync( { await sessionStore.SaveSessionAsync( agent, - agentSessionId!, + agentSessionKey!, session, - resolvedUserId, steeringDetected ? CancellationToken.None : cancellationToken).ConfigureAwait(false); } } @@ -721,7 +727,7 @@ await sessionStore.SaveSessionAsync( if (!isResilientTurn || session is null || session.GetService() is null - || string.IsNullOrWhiteSpace(agentSessionId) + || agentSessionKey is null || (stream.InternalMetadata.TryGetValue(LatestWorkflowCheckpointIdMetadataKey, out string? lastCheckpointId) && string.Equals(lastCheckpointId, checkpoint.CheckpointId, StringComparison.Ordinal))) { @@ -732,9 +738,8 @@ await sessionStore.SaveSessionAsync( { await sessionStore.SaveSessionAsync( agent, - agentSessionId, + agentSessionKey, session, - resolvedUserId, checkpointCancellationToken).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs deleted file mode 100644 index d507db4d966..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI.Foundry.Hosting; - -/// -/// Defines the contract for storing and retrieving agent conversation sessions. -/// -/// -/// Implementations of this interface enable persistent storage of conversation sessions, -/// allowing conversations to be resumed across HTTP requests, application restarts, -/// or different service instances in hosted scenarios. -/// -[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public abstract class AgentSessionStore -{ - /// - /// Saves a serialized agent session to persistent storage. - /// - /// The agent that owns this session. - /// The unique identifier for the conversation/session. - /// The session to save. - /// - /// The platform-injected per-user partition key (x-agent-user-id) that scopes this session to the - /// end user who initiated the request. Pass only when there is genuinely no user - /// context (for example local development without the platform header, or a non-hosted direct caller). - /// The parameter is required (no default) so every caller consciously decides the scope: implementations - /// that persist to a shared medium partition by this value so one user can never observe another user's - /// sessions, and an accidental unscoped save cannot happen silently. - /// - /// The to monitor for cancellation requests. - /// A task that represents the asynchronous save operation. - public abstract ValueTask SaveSessionAsync( - AIAgent agent, - string conversationId, - AgentSession session, - string? userId, - CancellationToken cancellationToken = default); - - /// - /// Retrieves a serialized agent session from persistent storage, or when - /// no session is stored for the given identifiers. - /// - /// The agent that owns this session. - /// The unique identifier for the conversation/session to retrieve. - /// - /// The platform-injected per-user partition key (x-agent-user-id) that scopes this session to the - /// end user who initiated the request. Pass only when there is genuinely no user - /// context (for example local development without the platform header, or a non-hosted direct caller). - /// The parameter is required (no default); it must match the value used when the session was saved, - /// otherwise a different (or new) session is returned. - /// - /// The to monitor for cancellation requests. - /// - /// A task that represents the asynchronous retrieval operation. The task result contains the restored - /// session, or when nothing is stored for the given identifiers. This is a plain - /// lookup: it never creates a session. Use to get a ready-to-use - /// session (loading an existing one or creating a new one), and use this method when the caller needs to - /// distinguish a resumed session from a fresh one (a non-null result means a prior turn established it). - /// - public abstract ValueTask GetSessionAsync( - AIAgent agent, - string conversationId, - string? userId, - CancellationToken cancellationToken = default); - - /// - /// Retrieves the stored session for the given identifiers, or creates a new one via - /// when none is stored. - /// - /// The agent that owns this session. - /// The unique identifier for the conversation/session to retrieve. - /// The per-user partition key; see for its meaning. - /// The to monitor for cancellation requests. - /// A task whose result is always a usable session, never . - /// - /// This is the convenience path for callers that only need a session to work with and do not care whether - /// it was loaded or freshly created. It is implemented in terms of , so a - /// store overriding that method gets this behavior for free. - /// - public virtual async ValueTask GetOrCreateSessionAsync( - AIAgent agent, - string conversationId, - string? userId, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(agent); - - return await this.GetSessionAsync(agent, conversationId, userId, cancellationToken).ConfigureAwait(false) - ?? await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs index c7c3b7292d4..8262225a68d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FileSystemAgentSessionStore.cs @@ -1,10 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Buffers; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -14,7 +12,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// Provides a file-system backed implementation of that persists -/// the agent-framework's serialized state for each (agent, conversation) +/// the agent-framework's serialized state for each agent and session key /// pair to disk. This complements Foundry storage (which owns conversation messages, agent /// definitions, and threads) — it is not a replacement for it. /// @@ -33,7 +31,7 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// read-only and paths outside $HOME may be cleared between requests. Locally, /// sessions fall under {cwd}/.checkpoints. The session JSON produced when the agent /// serializes the session already contains the workflow's in-memory checkpoint manager -/// state, so a single file per (agent, conversation) pair is sufficient to resume +/// state, so a single file per agent and session key is sufficient to resume /// long-running workflows across process restarts. /// /// @@ -147,15 +145,19 @@ private static bool IsUsableHostedHomeDirectory(string? homeDirectory) } /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); - ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(session); JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); - string path = this.GetSessionPath(agent, conversationId, userId); + string path = this.GetSessionPath(agent, key); // Each save writes to its own temp file before atomically renaming over the // destination. Last writer wins for the final file, but no reader can observe @@ -208,12 +210,15 @@ private string BuildNotWritableMessage(string sessionFilePath) => $"(for example {nameof(InMemoryAgentSessionStore)}) via AddFoundryResponses(agent, agentSessionStore)."; /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(agent); - ArgumentException.ThrowIfNullOrWhiteSpace(conversationId); + ArgumentNullException.ThrowIfNull(key); - string path = this.GetSessionPath(agent, conversationId, userId); + string path = this.GetSessionPath(agent, key); if (!File.Exists(path)) { return null; @@ -231,40 +236,22 @@ private string BuildNotWritableMessage(string sessionFilePath) => return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false); } - private string GetSessionPath(AIAgent agent, string conversationId, string? userId) + private string GetSessionPath(AIAgent agent, AgentSessionStoreKey key) { - // Path layout uses self-describing, prefixed segments so every layer is unambiguous and a - // collapsed layout can never be confused with a different layer (e.g. a user id can never - // masquerade as an agent name): + // The stable key incorporates the session identifier and every partition without exposing + // those values in the filesystem: // - // {root}/a-{agent}/u-{userId}/c-{conversationId}.json + // {root}/a-{agent}/k-{stable-key}.json // - // - a-{agent} buckets per hosted agent, because a single container hosts multiple keyed - // agents that must not collide on the same conversationId. (agent.Id is NOT - // used: it is regenerated on every startup for in-memory-defined agents.) - // - u-{userId} partitions per end user (x-agent-user-id) for multi-tenant isolation. Present - // only when a user id was resolved; absent for local runs with no platform header. - // - c-{conv} the conversation/context key. {conversationId} is HostedConversationKey.Resolve's - // output (conversation_id, else the partition of previous_response_id / response id). - // - // The prefixes are constant literals applied AFTER sanitizing/validating each untrusted value, - // so they can never themselves introduce path traversal. - string dir = this.RootDirectory; - - if (!string.IsNullOrEmpty(agent.Name)) - { - dir = Path.Combine(dir, "a-" + Sanitize(agent.Name!)); - } - - if (!string.IsNullOrWhiteSpace(userId)) - { - // The user id is the platform-injected, untrusted partition key. Reject (do not sanitize) - // anything that is not a single safe path component so a forged value cannot escape the root. - ValidatePathSegment(userId!, "user id"); - dir = Path.Combine(dir, "u-" + Sanitize(userId!)); - } + // Persistent storage requires the stable name or keyed registration carried by the hosted + // wrapper. Hashing it avoids case-insensitive and platform-specific directory collisions. + string agentIdentity = FoundryHostingAgent.GetSessionStorageIdentity(agent); + string agentKey = FoundryAgentSessionKeyEncoder.BuildAgentStorageKey(agentIdentity); + string sessionKey = FoundryAgentSessionKeyEncoder.BuildStorageKey( + FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, key)); + string dir = Path.Combine(this.RootDirectory, "a-" + agentKey); - string path = Path.Combine(dir, "c-" + Sanitize(conversationId) + ".json"); + string path = Path.Combine(dir, "k-" + sessionKey + ".json"); // Defense in depth: regardless of per-segment handling, the fully-resolved path must remain // under the storage root. Reject anything that escapes (CWE-22). @@ -281,127 +268,4 @@ private string GetSessionPath(AIAgent agent, string conversationId, string? user return path; } - - /// - /// Validates that is a single safe path component (CWE-22). - /// - /// - /// The value originates from caller-controlled or platform-injected fields (such as the - /// x-agent-user-id partition key). It must be treated as an untrusted single path segment: - /// path separators, drive letters, parent references and similar would otherwise let the resulting - /// directory escape the configured storage root. We deliberately do not URL-decode the value (the - /// hosting layer never decodes these ids before joining them, so forms such as %2e%2e are - /// accepted as literal directory names), and we do not "sanitize" by stripping characters because - /// that can introduce collisions between distinct ids — a non-conforming value is rejected outright. - /// - private static void ValidatePathSegment(string segment, string kind) - { - // Reject any value that is not a single safe path component. This covers POSIX/Windows - // separators, NUL bytes, drive letters, rooted paths, and all-dot segments (".", "..", "..."). - if (segment.IndexOf('/') >= 0 - || segment.IndexOf('\\') >= 0 - || segment.IndexOf('\0') >= 0 - || segment.Trim('.').Length == 0 - || Path.IsPathRooted(segment) - || !string.IsNullOrEmpty(Path.GetPathRoot(segment))) - { - throw new InvalidOperationException($"Invalid {kind}: '{segment}'."); - } - } - - private static string Sanitize(string value) - { - // Percent-encode every character that is invalid in a filename, plus '%' itself - // so the encoding is unambiguous. This is reversible and avoids the collision - // hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing - // a sanitized name). - char[] invalid = Path.GetInvalidFileNameChars(); - - int encodedLength = ComputeEncodedLength(value, invalid); - - // stackalloc is bounded so an externally-controlled length cannot crash the - // hosting process with StackOverflowException. - const int StackLimit = 512; - string sanitized; - if (encodedLength <= StackLimit) - { - Span buffer = stackalloc char[encodedLength]; - SanitizeCore(value, invalid, buffer); - sanitized = new string(buffer); - } - else - { - char[] rented = ArrayPool.Shared.Rent(encodedLength); - try - { - Span buffer = rented.AsSpan(0, encodedLength); - SanitizeCore(value, invalid, buffer); - sanitized = new string(buffer); - } - finally - { - ArrayPool.Shared.Return(rented); - } - } - - // '.' and '..' are valid filename characters but resolve to current/parent - // directory when used as a bare path component. Windows additionally strips - // trailing dots from filenames, so a segment like "..." would survive on disk - // as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every - // dot in any all-dot segment so the result has no special meaning to the OS. - if (sanitized.Length > 0 && IsAllDots(sanitized)) - { - return string.Concat(Enumerable.Repeat("%2E", sanitized.Length)); - } - - return sanitized; - } - - private static int ComputeEncodedLength(string value, char[] invalid) - { - int extra = 0; - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (c == '%' || Array.IndexOf(invalid, c) >= 0) - { - extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX") - } - } - return value.Length + extra; - } - - private static bool IsAllDots(string value) - { - for (int i = 0; i < value.Length; i++) - { - if (value[i] != '.') - { - return false; - } - } - - return true; - } - - private static void SanitizeCore(string value, char[] invalid, Span buffer) - { - int j = 0; - for (int i = 0; i < value.Length; i++) - { - char c = value[i]; - if (c == '%' || Array.IndexOf(invalid, c) >= 0) - { - buffer[j++] = '%'; - buffer[j++] = HexChar((c >> 4) & 0xF); - buffer[j++] = HexChar(c & 0xF); - } - else - { - buffer[j++] = c; - } - } - } - - private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs new file mode 100644 index 00000000000..c199b34de05 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionKeyEncoder.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Encodes agent session identities for Foundry-backed and filesystem storage implementations. +/// +internal static class FoundryAgentSessionKeyEncoder +{ + private static readonly UTF8Encoding s_strictUtf8 = + new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + internal static string BuildLogicalKey(string agentIdentity, AgentSessionStoreKey key) + { + _ = Throw.IfNull(key); + + StringBuilder builder = new(); + AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity)); + AppendComponent(builder, 's', key.SessionId); + if (key.Partitions is not null) + { + foreach (KeyValuePair partition in key.Partitions) + { + AppendComponent(builder, 'n', partition.Key); + AppendComponent(builder, 'v', partition.Value); + } + } + builder.Length--; + return builder.ToString(); + } + + internal static string BuildStorageKey(string logicalKey) + { + byte[] hash; + try + { + hash = SHA256.HashData(s_strictUtf8.GetBytes(logicalKey)); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException( + "Session keys and agent identities must contain valid UTF-16 text.", + nameof(logicalKey), + exception); + } + + return $"s-{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; + } + + internal static string BuildAgentStorageKey(string agentIdentity) + { + _ = Throw.IfNullOrWhitespace(agentIdentity); + return BuildStorageKey($"a{agentIdentity.Length}:{agentIdentity}"); + } + + private static void AppendComponent(StringBuilder builder, char prefix, string value) + => builder.Append(prefix).Append(value.Length).Append(':').Append(value).Append('|'); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs index 195a7971673..ad18c128f76 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAgentSessionStore.cs @@ -3,8 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Security.Cryptography; -using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -28,17 +26,16 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; /// /// /// Layout. All sessions live in one state store, named unless -/// overridden, and each (agent, user, conversation) triple is one item in it. The item key is a -/// hash of an unambiguous, length-prefixed encoding of the hosted registration identity, user id, -/// and conversation id. Hashing is required because the platform limits an item key to 128 +/// overridden, and each agent and pair is one item in it. The item +/// key is a hash of an unambiguous encoding of the hosted registration identity and session key. +/// Hashing is required because the platform limits an item key to 128 /// characters. The readable encoding is stored alongside the session so an item can still be traced /// back to its partition. /// /// -/// Per-user isolation is expressed through the item key rather than through the state store's own -/// userIsolation option. That option is fixed when the store is created and resolves the -/// user from the calling identity, whereas the user id handled here arrives per request and the -/// container always calls the storage API with its own identity. +/// Logical isolation is expressed through rather than +/// through the state store's own userIsolation option. That option is fixed when the store is +/// created, while session partitions may vary per request. /// /// /// The bound state store is resolved once, on first use, and reused for the lifetime of this @@ -124,24 +121,23 @@ internal FoundryAgentSessionStore(Func public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); _ = Throw.IfNull(session); - string agentIdentity = ResolveAgentIdentity(agent); + string agentIdentity = FoundryHostingAgent.GetSessionStorageIdentity(agent); JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); BinaryData sessionData = ToBinaryData(serialized); - string logicalKey = BuildLogicalKey(agentIdentity, conversationId, userId); + string logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, key); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); await store.SetItemAsync( - BuildItemKey(logicalKey), + FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey), new Dictionary { [SessionField] = sessionData, @@ -153,19 +149,22 @@ await store.SetItemAsync( /// public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { _ = Throw.IfNull(agent); - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); - string logicalKey = BuildLogicalKey(ResolveAgentIdentity(agent), conversationId, userId); + string logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey( + FoundryHostingAgent.GetSessionStorageIdentity(agent), + key); FoundryStateStore store = await this.GetStoreAsync(cancellationToken).ConfigureAwait(false); // GetItemAsync already answers null for an item that is not there, which is exactly the // "nothing stored" result this method contracts to return. - StateStoreItem? item = await store.GetItemAsync(BuildItemKey(logicalKey), cancellationToken).ConfigureAwait(false); + StateStoreItem? item = await store.GetItemAsync( + FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey), + cancellationToken).ConfigureAwait(false); if (!FoundryStateStoreJson.TryGetField(item, SessionField, out BinaryData? sessionData)) { return null; @@ -185,62 +184,6 @@ await store.SetItemAsync( private ValueTask GetStoreAsync(CancellationToken cancellationToken) => this._binding.GetAsync(cancellationToken); - /// - /// Builds an unambiguous readable partition key from the hosted agent identity, end user, and - /// conversation. Each component carries its length so delimiters inside values cannot collide. - /// - internal static string BuildLogicalKey(string agentIdentity, string conversationId, string? userId) - { - StringBuilder builder = new(); - AppendComponent(builder, 'a', Throw.IfNullOrWhitespace(agentIdentity)); - AppendComponent(builder, 'u', string.IsNullOrWhiteSpace(userId) ? null : userId); - AppendComponent(builder, 'c', Throw.IfNullOrWhitespace(conversationId)); - builder.Length--; - return builder.ToString(); - } - - private static string ResolveAgentIdentity(AIAgent agent) - { - _ = Throw.IfNull(agent); - - if (agent.GetService() is { } hostingAgent) - { - return hostingAgent.SessionStorageIdentity; - } - - if (string.IsNullOrWhiteSpace(agent.Name)) - { - throw new InvalidOperationException( - $"Direct use of {nameof(FoundryAgentSessionStore)} requires an agent with a stable {nameof(AIAgent.Name)}. " + - "Foundry hosting supplies the keyed or default registration identity separately."); - } - - return $"name:{agent.Name}"; - } - - private static void AppendComponent(StringBuilder builder, char prefix, string? value) - { - builder.Append(prefix).Append(value?.Length ?? -1).Append(':'); - if (value is not null) - { - builder.Append(value); - } - - builder.Append('|'); - } - - /// - /// Reduces a logical key to a fixed-length item key. The platform limits an item key to 128 - /// characters, which an agent name plus a user id plus a conversation id can exceed, so the - /// logical key is hashed rather than truncated: truncation would let two different conversations - /// share a key and therefore overwrite each other's session. - /// - internal static string BuildItemKey(string logicalKey) - { - byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(logicalKey)); - return $"s-{Convert.ToBase64String(hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')}"; - } - private static BinaryData ToBinaryData(JsonElement element) => FoundryStateStoreJson.ToBinaryData(element); private static BinaryData ToJsonString(string value) => FoundryStateStoreJson.ToJsonString(value); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs index cc17a707dab..6b4f5d04f1c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryHostingAgent.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Foundry.Hosting; @@ -21,6 +22,37 @@ internal FoundryHostingAgent(AIAgent innerAgent, string sessionStorageIdentity) internal string SessionStorageIdentity { get; } + /// + /// Gets the session storage identity carried by a hosting wrapper, or derives one for direct use. + /// + /// The agent whose storage identity is required. + /// + /// Whether an unnamed direct agent may use its process-local . + /// + /// The resolved session storage identity. + internal static string GetSessionStorageIdentity(AIAgent agent, bool allowInstanceId = false) + { + _ = Throw.IfNull(agent); + + if (agent.GetService() is { } hostingAgent) + { + return hostingAgent.SessionStorageIdentity; + } + + if (!string.IsNullOrWhiteSpace(agent.Name)) + { + return $"name:{agent.Name}"; + } + + if (allowInstanceId) + { + return $"id:{agent.Id}"; + } + + throw new InvalidOperationException( + $"Persistent session storage requires a stable {nameof(AIAgent.Name)} or Foundry hosting registration."); + } + /// /// Resolves the stable identity used to partition session storage for the resolved agent. /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs index 579240432b6..5b4df9e3da6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Text.Json; @@ -30,20 +31,33 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class InMemoryAgentSessionStore : AgentSessionStore { - private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary<(string AgentIdentity, AgentSessionStoreKey Key), JsonElement> _sessions = new(); /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - var key = GetKey(agent, conversationId, userId); - this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(session); + + var storageKey = GetKey(agent, key); + this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - var key = GetKey(agent, conversationId, userId); - if (!this._sessions.TryGetValue(key, out var existingSession)) + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(key); + + if (!this._sessions.TryGetValue(GetKey(agent, key), out var existingSession)) { return null; } @@ -51,25 +65,8 @@ public override async ValueTask SaveSessionAsync(AIAgent agent, string conversat return await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false); } - // Keyed with the same a-/u-/c- prefix scheme as FileSystemAgentSessionStore so the in-memory store - // partitions per agent and per user identically. Like FileSystemAgentSessionStore, the agent segment - // uses agent.Name (a stable identity) and is omitted when no name is set; agent.Id is intentionally - // NOT used because it is regenerated on every startup for in-memory-defined agents, which would break - // session continuity for a transient or recreated agent. The user segment is omitted when no user id - // is supplied. - private static string GetKey(AIAgent agent, string conversationId, string? userId) - { - string key = string.Empty; - if (!string.IsNullOrEmpty(agent.Name)) - { - key += $"a-{agent.Name}:"; - } - - if (!string.IsNullOrWhiteSpace(userId)) - { - key += $"u-{userId}:"; - } - - return key + $"c-{conversationId}"; - } + private static (string AgentIdentity, AgentSessionStoreKey Key) GetKey( + AIAgent agent, + AgentSessionStoreKey key) + => (FoundryHostingAgent.GetSessionStorageIdentity(agent, allowInstanceId: true), key); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs index 8e8240493c2..7d7fda54d07 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -32,12 +32,10 @@ public static class A2AServerServiceCollectionExtensions /// /// Trust model. The A2A contextId and taskId arrive /// from the wire and are treated as chain-resume identifiers — not as - /// authorization tokens. Both the and - /// contracts carry no principal/owner dimension by default, - /// so when a persistent store is registered any caller who knows or guesses another - /// caller's contextId or taskId can access that other caller's data. - /// Hosts that serve more than one user must compose a principal dimension into the - /// lookup key — typically by calling UseClaimsBasedAgentIsolation(...) from + /// authorization tokens. accepts an explicit user partition, + /// while has no principal or owner dimension. + /// Hosts that serve more than one user must supply both dimensions from a trusted identity, + /// typically by calling UseClaimsBasedAgentIsolation(...) from /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom /// ). When an /// is registered, both the session store and the task store are automatically wrapped @@ -68,7 +66,7 @@ public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBui /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must - /// compose a principal dimension via UseClaimsBasedAgentIsolation(...) or + /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). /// public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action? configureOptions = null) @@ -94,7 +92,7 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must - /// compose a principal dimension via UseClaimsBasedAgentIsolation(...) or + /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). /// public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action? configureOptions = null) @@ -119,7 +117,7 @@ public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must - /// compose a principal dimension via UseClaimsBasedAgentIsolation(...) or + /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). /// public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null) @@ -157,7 +155,7 @@ public static IServiceCollection AddA2AServer(this IServiceCollection services, /// See the trust-model remarks on /// for guidance on multi-user hosts (the wire contextId and taskId /// are chain-resume identifiers, not authorization tokens; multi-user hosts must - /// compose a principal dimension via UseClaimsBasedAgentIsolation(...) or + /// supply a trusted user partition via UseClaimsBasedAgentIsolation(...) or /// a custom ). /// public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action? configureOptions = null) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj index 3c805ee7a4d..2db2d6b6f6a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj @@ -4,6 +4,7 @@ $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.A2A preview + $(NoWarn);MAAI001 Microsoft Agent Framework Hosting A2A Provides Microsoft Agent Framework support for hosting A2A agents. diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index 64568c1a72e..ca900935daf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -83,23 +83,20 @@ public static IEndpointConventionBuilder MapAGUIServer( /// /// /// Trust model. The AG-UI RunAgentInput.ThreadId arrives - /// from the wire and is treated as a chain-resume identifier — not as an - /// authorization token. The contract carries no - /// principal/owner dimension, so when a persistent store is registered any caller - /// who knows or guesses another caller's ThreadId can resume that other - /// caller's persisted thread. Hosts that serve more than one user must compose a - /// principal dimension into the lookup key. The recommended way is to wrap the + /// from the wire and is treated as a chain-resume identifier, not as an authorization + /// token. The contract accepts a userId partition, + /// which must come from a trusted identity rather than from the wire ThreadId. + /// The recommended way to supply it is to wrap the /// keyed in /// , typically by calling /// UseClaimsBasedAgentIsolation(...) from /// Microsoft.Agents.AI.Hosting.AspNetCore (or by registering a custom /// ) and registering the store via the /// WithSessionStore(...) / WithInMemorySessionStore(...) helpers on - /// so that the wrapper is applied. When no - /// isolation provider is registered, behavior is unchanged — the bare - /// ThreadId is used as the conversation identifier, which is appropriate - /// for first-run / single-user / prototyping scenarios but unsafe for - /// multi-user hosts. + /// so that the wrapper is applied. When no isolation + /// provider is registered, userId is and all callers share + /// one partition. This is appropriate for single-user applications and prototyping, + /// but unsafe for multi-user hosts. /// /// public static IEndpointConventionBuilder MapAGUIServer( diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index 3a46871daad..ac9b03f75a4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -4,6 +4,7 @@ $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.AGUI.AspNetCore preview + $(NoWarn);MAAI001 $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentityAgentIsolationKeyProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentityAgentIsolationKeyProvider.cs index 39f54a7c046..a8ffd77bd50 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentityAgentIsolationKeyProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AspNetCore/ClaimsIdentityAgentIsolationKeyProvider.cs @@ -34,7 +34,7 @@ namespace Microsoft.Agents.AI.Hosting; /// /// /// If the is unavailable, the user is not authenticated, or the specified claim -/// is missing, the provider returns . Consuming stores then enforce strict or +/// is missing or blank, the provider returns . Consuming stores then enforce strict or /// pass-through behavior based on their configuration. /// /// @@ -73,7 +73,7 @@ public ClaimsIdentityAgentIsolationKeyProvider( /// /// A task that represents the asynchronous operation. The task result contains the value of the /// configured claim type from the current user's identity, or if the HTTP - /// context is unavailable, the user is not authenticated, or the claim is not present. + /// context is unavailable, the user is not authenticated, or the claim is missing or blank. /// /// /// This method only reads claims from an authenticated principal: if the current request has no @@ -89,8 +89,7 @@ public ClaimsIdentityAgentIsolationKeyProvider( return new ValueTask((string?)null); } - Claim? claim = user?.Claims.FirstOrDefault(c => c.Type == this._claimType); - - return new ValueTask(claim?.Value); + string? value = user.Claims.FirstOrDefault(c => c.Type == this._claimType)?.Value; + return new ValueTask(string.IsNullOrWhiteSpace(value) ? null : value); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs index 2833ed991a4..d06116a6717 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/AzureBlobHostedAgentBuilderExtensions.cs @@ -20,7 +20,7 @@ public static class AzureBlobHostedAgentBuilderExtensions /// The Blob container client used to store sessions. /// Optional session store configuration. /// - /// Whether to scope session IDs with the configured . + /// Whether to add an isolation partition from the configured . /// /// The supplied . public static IHostedAgentBuilder WithAzureBlobSessionStore( @@ -49,7 +49,7 @@ public static IHostedAgentBuilder WithAzureBlobSessionStore( /// Optional session store configuration. /// The dependency injection lifetime of the registered session store. /// - /// Whether to scope session IDs with the configured . + /// Whether to add an isolation partition from the configured . /// /// The supplied . public static IHostedAgentBuilder WithAzureBlobSessionStore( diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs index a3192963a35..00bdad661e3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Blob/AzureBlobAgentSessionStore.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -9,6 +11,7 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting.AzureStorage; @@ -28,6 +31,7 @@ namespace Microsoft.Agents.AI.Hosting.AzureStorage; /// default. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class AzureBlobAgentSessionStore : AgentSessionStore { private const int MaxBlobNameLength = 1024; @@ -37,6 +41,7 @@ public sealed class AzureBlobAgentSessionStore : AgentSessionStore { HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" }, }; + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); private readonly BlobContainerClient _containerClient; private readonly object _containerInitializationLock = new(); @@ -60,7 +65,7 @@ public AzureBlobAgentSessionStore( AzureBlobAgentSessionStoreOptions? options = null) { this._containerClient = Throw.IfNull(containerClient); - this._agentKey = ComputeKey(Throw.IfNullOrWhitespace(agentNamespace)); + this._agentKey = ComputeAgentKey(Throw.IfNullOrWhitespace(agentNamespace)); options ??= new AzureBlobAgentSessionStoreOptions(); this._createContainerIfNotExists = options.CreateContainerIfNotExists; @@ -77,18 +82,18 @@ public AzureBlobAgentSessionStore( /// public override async ValueTask SaveSessionAsync( AIAgent agent, - string sessionStoreId, + AgentSessionStoreKey key, AgentSession session, CancellationToken cancellationToken = default) { Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); + Throw.IfNull(key); Throw.IfNull(session); await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false); JsonElement serializedSession = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); - BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId)); + BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(key)); await blobClient.UploadAsync( BinaryData.FromString(serializedSession.GetRawText()), s_uploadOptions, @@ -96,18 +101,28 @@ await blobClient.UploadAsync( } /// - public override async ValueTask GetSessionAsync( + public override async ValueTask GetSessionAsync( AIAgent agent, - string sessionStoreId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) { Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); + Throw.IfNull(key); await this.EnsureContainerExistsAsync(cancellationToken).ConfigureAwait(false); - BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId)); + return await this.TryGetSessionAsync( + agent, + this.GetBlobName(key), + cancellationToken).ConfigureAwait(false); + } + private async ValueTask TryGetSessionAsync( + AIAgent agent, + string blobName, + CancellationToken cancellationToken) + { + BlobClient blobClient = this._containerClient.GetBlobClient(blobName); try { Response response = await blobClient.DownloadContentAsync(cancellationToken).ConfigureAwait(false); @@ -116,30 +131,7 @@ public override async ValueTask GetSessionAsync( } catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.BlobNotFound.ToString()) { - return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - } - } - - /// - public override async ValueTask DeleteSessionAsync( - AIAgent agent, - string sessionStoreId, - CancellationToken cancellationToken = default) - { - Throw.IfNull(agent); - Throw.IfNull(sessionStoreId); - - BlobClient blobClient = this._containerClient.GetBlobClient(this.GetBlobName(sessionStoreId)); - - try - { - await blobClient.DeleteIfExistsAsync( - DeleteSnapshotsOption.IncludeSnapshots, - cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (RequestFailedException ex) when (ex.ErrorCode == BlobErrorCode.ContainerNotFound.ToString()) - { - // A missing container cannot contain the requested session, so deletion remains idempotent. + return null; } } @@ -177,16 +169,34 @@ private async Task EnsureContainerExistsAsync(CancellationToken cancellationToke private async Task CreateContainerIfNotExistsAsync() => await this._containerClient.CreateIfNotExistsAsync(cancellationToken: CancellationToken.None).ConfigureAwait(false); - private string GetBlobName(string sessionStoreId) + private string GetBlobName(AgentSessionStoreKey key) { - string sessionKey = ComputeKey(sessionStoreId); - string baseName = $"v1/{this._agentKey}/{sessionKey}.json"; + string baseName = $"v2/{this._agentKey}/{ComputeSessionKey(key)}.json"; return this._blobNamePrefix is null ? baseName : $"{this._blobNamePrefix}/{baseName}"; } + private static string ComputeSessionKey(AgentSessionStoreKey key) + { + StringBuilder builder = new(); + AppendComponent(builder, 's', key.SessionId); + if (key.Partitions is not null) + { + foreach (KeyValuePair partition in key.Partitions) + { + AppendComponent(builder, 'n', partition.Key); + AppendComponent(builder, 'v', partition.Value); + } + } + + return ComputeKey(builder.ToString()); + } + + private static void AppendComponent(StringBuilder builder, char prefix, string value) + => builder.Append(prefix).Append(value.Length).Append(':').Append(value).Append('|'); + private static async Task WaitWithCancellationAsync(Task task, CancellationToken cancellationToken) { if (task.IsCompleted || !cancellationToken.CanBeCanceled) @@ -205,7 +215,7 @@ private static async Task WaitWithCancellationAsync(Task task, CancellationToken private static string ComputeKey(string value) { - byte[] input = Encoding.UTF8.GetBytes(value); + byte[] input = s_strictUtf8.GetBytes(value); #if NET8_0_OR_GREATER return Convert.ToHexString(SHA256.HashData(input)); #else @@ -224,6 +234,21 @@ private static string ComputeKey(string value) #endif } + private static string ComputeAgentKey(string agentNamespace) + { + try + { + return ComputeKey(agentNamespace); + } + catch (EncoderFallbackException exception) + { + throw new ArgumentException( + "The agent namespace must contain valid UTF-16 text.", + nameof(agentNamespace), + exception); + } + } + #if !NET8_0_OR_GREATER private static char ToHexChar(int value) => (char)(value < 10 ? '0' + value : 'A' + value - 10); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj index d2652c12a7e..bd442cd24c4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AzureStorage/Microsoft.Agents.AI.Hosting.AzureStorage.csproj @@ -3,6 +3,9 @@ preview true + true + true + $(NoWarn);MAAI001 Microsoft Agent Framework Azure Blob Storage integration @@ -18,4 +21,5 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs index ac54968cdce..a0396feeccf 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/AIHostAgent.cs @@ -51,11 +51,25 @@ public AIHostAgent(AIAgent innerAgent, AgentSessionStore sessionStore) /// A task that represents the asynchronous operation. The task result contains the agent session associated with the /// specified conversation. If no session exists, a new session is created and returned. public ValueTask GetOrCreateSessionAsync(string conversationId, CancellationToken cancellationToken = default) + => this.GetOrCreateSessionAsync(new AgentSessionStoreKey(conversationId), cancellationToken); + + /// + /// Gets an existing agent session for the specified storage key, or creates a new one if none exists. + /// + /// The key that identifies and partitions the session. + /// A cancellation token that can be used to cancel the asynchronous operation. + /// A task whose result contains the stored or newly created agent session. + public ValueTask GetOrCreateSessionAsync( + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); MarkFeatureUsed(); - return this._sessionStore.GetSessionAsync(this.InnerAgent, conversationId, cancellationToken); + return this._sessionStore.GetOrCreateSessionAsync( + this.InnerAgent, + key, + cancellationToken); } /// @@ -68,12 +82,29 @@ public ValueTask GetOrCreateSessionAsync(string conversationId, Ca /// is null or whitespace. /// is . public ValueTask SaveSessionAsync(string conversationId, AgentSession session, CancellationToken cancellationToken = default) + => this.SaveSessionAsync(new AgentSessionStoreKey(conversationId), session, cancellationToken); + + /// + /// Persists a session under the specified storage key. + /// + /// The key that identifies and partitions the session. + /// The session to persist. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public ValueTask SaveSessionAsync( + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - _ = Throw.IfNullOrWhitespace(conversationId); + _ = Throw.IfNull(key); _ = Throw.IfNull(session); MarkFeatureUsed(); - return this._sessionStore.SaveSessionAsync(this.InnerAgent, conversationId, session, cancellationToken); + return this._sessionStore.SaveSessionAsync( + this.InnerAgent, + key, + session, + cancellationToken); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs deleted file mode 100644 index 85e3985ab8b..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/AgentSessionStore.cs +++ /dev/null @@ -1,138 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting; - -/// -/// Defines the contract for storing and retrieving agent conversation threads. -/// -/// -/// -/// Implementations of this interface enable persistent storage of conversation threads, -/// allowing conversations to be resumed across HTTP requests, application restarts, -/// or different service instances in hosted scenarios. -/// -/// -/// Trust model. The sessionStoreId passed to -/// and is the id under which the session is -/// stored. It typically originates from the wire (for example, an AG-UI RunAgentInput.ThreadId or an -/// A2A contextId). It is a chain-resume identifier, not an authorization -/// token, and the (agent, sessionStoreId) tuple carries no principal/owner -/// dimension. Hosts that serve more than one user from the same registered store must -/// therefore compose a principal dimension into the lookup key, otherwise any caller -/// who knows or guesses another caller's sessionStoreId can resume -/// that other caller's persisted thread. The framework provides -/// as a decorator that rewrites -/// sessionStoreId to include an isolation key resolved from an -/// (for example, the ASP.NET Core -/// ClaimsIdentityAgentIsolationKeyProvider wired up via -/// UseClaimsBasedAgentIsolation(...)). When no provider is registered, the -/// store behaves as a single-namespace persistence layer — appropriate for -/// single-user / first-run / prototyping scenarios but unsafe for multi-user hosts. -/// -/// -/// Implementer guidance. Implementations should treat -/// sessionStoreId as opaque: do not parse it, do not impose length -/// or character-set constraints on it, and do not assume it round-trips to the value -/// the caller originally supplied (decorators such as -/// may rewrite it before forwarding). -/// Be aware that any logging, telemetry, or audit sink that surfaces -/// sessionStoreId will also surface the isolation prefix when a -/// scoping decorator is in the chain. -/// -/// -public abstract class AgentSessionStore -{ - /// - /// Saves a serialized agent session to persistent storage. - /// - /// The agent that owns this session. - /// The id under which the session is stored. - /// The session to save. - /// The to monitor for cancellation requests. - /// A task that represents the asynchronous save operation. - public abstract ValueTask SaveSessionAsync( - AIAgent agent, - string sessionStoreId, - AgentSession session, - CancellationToken cancellationToken = default); - - /// - /// Retrieves a serialized agent session from persistent storage. - /// - /// The agent that owns this session. - /// The id under which the session is stored. - /// The to monitor for cancellation requests. - /// - /// A task that represents the asynchronous retrieval operation. The task result contains the - /// restored , or a newly created session when nothing is stored for the id. - /// - /// - /// Isolation. Each call must return an independent - /// instance. Callers may mutate the returned session, and may run several concurrent branches from the - /// same (for example forking from an OpenAI Responses - /// previous_response_id), without those branches observing one another's mutations or altering the - /// stored state. The in-box stores satisfy this by returning a fresh instance rehydrated from a serialized - /// snapshot on every call; implementations that cache a live must return an - /// independent copy (for example by round-tripping through - /// - /// and ) - /// rather than handing back the shared instance. - /// - public abstract ValueTask GetSessionAsync( - AIAgent agent, - string sessionStoreId, - CancellationToken cancellationToken = default); - - /// - /// Deletes a stored agent session, if present. - /// - /// The agent that owns this session. - /// The id under which the session is stored. - /// The to monitor for cancellation requests. - /// A task that represents the asynchronous delete operation. - /// - /// Implementations that support removal delete the session and treat a missing session as a no-op. - /// Implementations that genuinely cannot support deletion should throw . - /// - /// The store does not support deletion. - public abstract ValueTask DeleteSessionAsync( - AIAgent agent, - string sessionStoreId, - CancellationToken cancellationToken = default); - - /// Asks the for an object of the specified type . - /// The type of object being requested. - /// An optional key that can be used to help identify the target service. - /// The found object, otherwise . - /// is . - /// - /// The purpose of this method is to allow for the retrieval of strongly-typed services that might be provided by the , - /// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains - /// to verify that specific store implementations are present. - /// - public virtual object? GetService(Type serviceType, object? serviceKey = null) - { - _ = Throw.IfNull(serviceType); - - return serviceKey is null && serviceType.IsInstanceOfType(this) - ? this - : null; - } - - /// Asks the for an object of type . - /// The type of the object to be retrieved. - /// An optional key that can be used to help identify the target service. - /// The found object, otherwise . - /// - /// The purpose of this method is to allow for the retrieval of strongly typed services that may be provided by the , - /// including itself or any services it might be wrapping. This is particularly useful for inspecting delegation chains - /// to verify that specific store implementations are present. - /// - public TService? GetService(object? serviceKey = null) - => this.GetService(typeof(TService), serviceKey) is TService service ? service : default; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs index a13eab90384..dba3e2bfe7f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/HostedAgentBuilderExtensions.cs @@ -18,7 +18,7 @@ public static class HostedAgentBuilderExtensions /// /// The host agent builder to configure with the in-memory session store. /// When , wraps the session store with an - /// to provide isolation-key-based scoping for sessions. Defaults to . + /// that adds a partition from . Defaults to . /// The same instance, configured to use an in-memory session store. public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuilder builder, bool withIsolation = true) => builder.WithSessionStore(new InMemoryAgentSessionStore(), withIsolation); @@ -30,7 +30,7 @@ public static IHostedAgentBuilder WithInMemorySessionStore(this IHostedAgentBuil /// The host agent builder to configure with the session store. Cannot be null. /// The agent session store instance to register. Cannot be null. /// When , wraps the session store with an - /// to provide isolation-key-based scoping for sessions. Defaults to . + /// that adds a partition from . Defaults to . /// The same host agent builder instance, allowing for method chaining. public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, AgentSessionStore store, bool withIsolation = true) => builder.WithSessionStore((sp, key) => store, ServiceLifetime.Singleton, withIsolation); @@ -44,7 +44,7 @@ public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder buil /// The DI service lifetime for the session store registration. Defaults to /// because session stores persist conversation state across requests and are consumed independently of the agent's lifetime. /// When , wraps the session store with an - /// to provide isolation-key-based scoping for sessions. Defaults to . + /// that adds a partition from . Defaults to . /// The same host agent builder instance, enabling further configuration. public static IHostedAgentBuilder WithSessionStore(this IHostedAgentBuilder builder, Func createAgentSessionStore, ServiceLifetime lifetime = ServiceLifetime.Singleton, bool withIsolation = true) { diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs index 55935530f50..c6772d5bba3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStore.cs @@ -1,18 +1,23 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; /// -/// A delegating that scopes session keys by an isolation key -/// provided by an , ensuring that sessions are isolated -/// per logical partition (e.g., user, tenant, or composite key). +/// A delegating that adds an isolation partition from an +/// . /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public class IsolationKeyScopedAgentSessionStore : DelegatingAgentSessionStore { + private const string IsolationPartitionName = "isolation"; + private readonly AgentIsolationKeyProvider? _keyProvider; private readonly bool _strict; @@ -54,63 +59,60 @@ public IsolationKeyScopedAgentSessionStore( ? await this._keyProvider.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false) : null; - if (this._strict && key == null) + if (string.IsNullOrWhiteSpace(key)) { - throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider."); + if (this._strict) + { + throw new InvalidOperationException("Agent isolation key is required but was not provided by the configured AgentIsolationKeyProvider."); + } + + return null; } return key; } /// - /// Escapes special characters in the isolation key to ensure unambiguous scoped session store IDs. - /// - /// The raw isolation key. - /// The escaped isolation key. - /// - /// Backslashes are escaped first (\ becomes \\), then colons (: becomes \:). - /// This ensures the scoped session store ID format {key}::{sessionStoreId} can be parsed correctly. - /// - private static string EscapeIsolationKey(string key) => key.Replace("\\", "\\\\").Replace(":", "\\:"); - - /// - /// Constructs a scoped session store ID by prefixing the bare session store ID with the escaped isolation key. + /// Adds the isolation value from the current hosting context to the session key. /// - /// The original session store ID. - /// The cancellation token. - /// - /// The scoped session store ID in the format {escapedKey}::{sessionStoreId}, or the bare session store ID - /// if no isolation key is available and non-strict mode is enabled. - /// - private async ValueTask GetScopedSessionStoreIdAsync(string bareSessionStoreId, CancellationToken cancellationToken) + private async ValueTask GetScopedKeyAsync( + AgentSessionStoreKey key, + CancellationToken cancellationToken) { - string? key = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); - if (key == null) - { - return bareSessionStoreId; - } + _ = Throw.IfNull(key); - return $"{EscapeIsolationKey(key)}::{bareSessionStoreId}"; + string? isolationKey = await this.GetIsolationKeyAsync(cancellationToken).ConfigureAwait(false); + return isolationKey is null ? key : key.WithPartition(IsolationPartitionName, isolationKey); } /// - public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - return await this.InnerStore.GetSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetSessionAsync(agent, scopedKey, cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask GetOrCreateSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.SaveSessionAsync(agent, scopedSessionStoreId, session, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + return await this.InnerStore.GetOrCreateSessionAsync(agent, scopedKey, cancellationToken).ConfigureAwait(false); } /// - public override async ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - string scopedSessionStoreId = await this.GetScopedSessionStoreIdAsync(sessionStoreId, cancellationToken).ConfigureAwait(false); - await this.InnerStore.DeleteSessionAsync(agent, scopedSessionStoreId, cancellationToken).ConfigureAwait(false); + AgentSessionStoreKey scopedKey = await this.GetScopedKeyAsync(key, cancellationToken).ConfigureAwait(false); + await this.InnerStore.SaveSessionAsync(agent, scopedKey, session, cancellationToken).ConfigureAwait(false); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs index 773ee96206e..662225ee5ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/IsolationKeyScopedAgentSessionStoreOptions.cs @@ -16,9 +16,9 @@ public class IsolationKeyScopedAgentSessionStoreOptions /// when returns . /// /// - /// If , the conversation ID is passed through unmodified when the isolation key is absent, - /// allowing unscoped access to the underlying session store. This mode is suitable for development scenarios - /// or mixed environments where not all requests have isolation keys. + /// If , the original is passed through without + /// an isolation partition when the provider returns . This mode is suitable + /// for development scenarios or environments where not all requests have isolation keys. /// /// public bool Strict { get; set; } = true; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs index 448d20f473f..084c9eaabce 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Local/InMemoryAgentSessionStore.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting; @@ -25,50 +28,47 @@ namespace Microsoft.Agents.AI.Hosting; /// such as Redis, SQL Server, or Azure Cosmos DB. /// /// -/// Multi-user warning. This store keys threads by -/// (agent.Id, sessionStoreId) only — it has no principal/owner dimension. When -/// the session store id originates from the wire (for example, an AG-UI -/// RunAgentInput.ThreadId or an A2A contextId), any caller who knows -/// or guesses another caller's identifier can resume that other caller's persisted -/// thread. Multi-user hosts must wrap this store in +/// Multi-user warning. This store partitions sessions by the userId supplied +/// to and . +/// Multi-user hosts must supply a trusted user identifier, either directly or by wrapping this store in /// (typically by calling /// UseClaimsBasedAgentIsolation(...) from /// Microsoft.Agents.AI.Hosting.AspNetCore or by registering a custom -/// ) so that the conversation namespace is -/// scoped per principal. See the trust-model remarks on -/// for the full background. +/// ). Passing uses a shared, unscoped +/// partition that is only appropriate for single-user applications and local development. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class InMemoryAgentSessionStore : AgentSessionStore { - private readonly ConcurrentDictionary _threads = new(); + private readonly ConcurrentDictionary<(string AgentId, AgentSessionStoreKey Key), JsonElement> _sessions = new(); /// - public override async ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + public override async ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { - var key = GetKey(sessionStoreId, agent.Id); - this._threads[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - /// - public override async ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - { - var key = GetKey(sessionStoreId, agent.Id); - JsonElement? sessionContent = this._threads.TryGetValue(key, out var existingSession) ? existingSession : null; + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); + _ = Throw.IfNull(session); - return sessionContent switch - { - null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false), - _ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false), - }; + var storageKey = (agent.Id, key); + this._sessions[storageKey] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); } /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - this._threads.TryRemove(GetKey(sessionStoreId, agent.Id), out _); - return default; - } + _ = Throw.IfNull(agent); + _ = Throw.IfNull(key); - private static string GetKey(string sessionStoreId, string agentId) => $"{agentId}:{sessionStoreId}"; + return this._sessions.TryGetValue((agent.Id, key), out JsonElement existingSession) + ? await agent.DeserializeSessionAsync(existingSession, cancellationToken: cancellationToken).ConfigureAwait(false) + : null; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj index 70c690bfdf5..abbba8eb92e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj @@ -2,11 +2,14 @@ preview + $(NoWarn);MAAI001 true + true true + true true diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs index a156285a856..8d847b54c70 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting/NoopAgentSessionStore.cs @@ -1,31 +1,35 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Agents.AI.Hosting; /// /// This store implementation does not have any store under the hood and therefore does not store sessions. -/// always returns a new session. +/// always returns . /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public sealed class NoopAgentSessionStore : AgentSessionStore { /// - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { return default; } /// - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) { - return agent.CreateSessionAsync(cancellationToken); - } - - /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - { - return default; + return new((AgentSession?)null); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs similarity index 53% rename from dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs rename to dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs index f340cec8af5..6af4bb84773 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting/DelegatingAgentSessionStore.cs +++ b/dotnet/src/Microsoft.Agents.AI/DelegatingAgentSessionStore.cs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; -namespace Microsoft.Agents.AI.Hosting; +namespace Microsoft.Agents.AI; /// /// Provides an abstract base class for agent session stores that delegate operations to an inner store @@ -18,11 +20,12 @@ namespace Microsoft.Agents.AI.Hosting; /// underlying store. /// /// -/// The default implementation provides transparent pass-through behavior, forwarding all operations to the inner store. -/// Derived classes can override specific methods to add custom behavior while maintaining compatibility with the store -/// interface. +/// The default implementation forwards lookup and save operations to the inner store. The inherited +/// lookup-or-create method calls the outer store's lookup override before creating a session when needed. +/// Service queries check this instance before querying the inner store. /// /// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public abstract class DelegatingAgentSessionStore : AgentSessionStore { /// @@ -32,8 +35,7 @@ public abstract class DelegatingAgentSessionStore : AgentSessionStore /// The underlying session store instance that will handle the core operations. /// is . /// - /// The inner session store serves as the foundation of the delegation chain. All operations not overridden by - /// derived classes will be forwarded to this store. + /// Lookup and save operations are forwarded to this store unless overridden by a derived class. /// protected DelegatingAgentSessionStore(AgentSessionStore innerStore) { @@ -52,34 +54,26 @@ protected DelegatingAgentSessionStore(AgentSessionStore innerStore) /// protected AgentSessionStore InnerStore { get; } - /// - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => this.InnerStore.GetSessionAsync(agent, sessionStoreId, cancellationToken); - - /// - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => this.InnerStore.SaveSessionAsync(agent, sessionStoreId, session, cancellationToken); - - /// - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => this.InnerStore.DeleteSessionAsync(agent, sessionStoreId, cancellationToken); - /// /// - /// This implementation first checks if this instance satisfies the service request. - /// If not, it chains the request to the inner store, allowing services to be retrieved - /// from any store in the delegation chain. + /// Returns this instance for a compatible unkeyed request. Otherwise, forwards the request to + /// , allowing services to be discovered through multiple decorators. /// public override object? GetService(Type serviceType, object? serviceKey = null) - { - // First, check if this instance satisfies the request - object? service = base.GetService(serviceType, serviceKey); - if (service is not null) - { - return service; - } + => base.GetService(serviceType, serviceKey) ?? this.InnerStore.GetService(serviceType, serviceKey); - // Chain to the inner store - return this.InnerStore.GetService(serviceType, serviceKey); - } + /// + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + => this.InnerStore.GetSessionAsync(agent, key, cancellationToken); + + /// + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + => this.InnerStore.SaveSessionAsync(agent, key, session, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt index b473d4bd163..9f7d556732f 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net10.0/PublicAPI.Unshipped.txt @@ -1,4 +1,10 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask *REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt index b473d4bd163..9f7d556732f 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net472/PublicAPI.Unshipped.txt @@ -1,4 +1,10 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask *REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt index b473d4bd163..9f7d556732f 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -1,4 +1,10 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask *REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt index b473d4bd163..9f7d556732f 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/net9.0/PublicAPI.Unshipped.txt @@ -1,4 +1,10 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask *REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index b473d4bd163..9f7d556732f 100644 --- a/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/dotnet/src/Microsoft.Agents.AI/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -1,4 +1,10 @@ #nullable enable +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.DelegatingAgentSessionStore(Microsoft.Agents.AI.AgentSessionStore! innerStore) -> void +[MAAI001]Microsoft.Agents.AI.DelegatingAgentSessionStore.InnerStore.get -> Microsoft.Agents.AI.AgentSessionStore! +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetService(System.Type! serviceType, object? serviceKey = null) -> object? +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.GetSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +[MAAI001]override Microsoft.Agents.AI.DelegatingAgentSessionStore.SaveSessionAsync(Microsoft.Agents.AI.AIAgent! agent, Microsoft.Agents.AI.AgentSessionStoreKey! key, Microsoft.Agents.AI.AgentSession! session, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask *REMOVED*[MAAI001]abstract Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.get -> System.TimeSpan [MAAI001]Microsoft.Agents.AI.BackgroundAgentsProviderOptions.WaitTimeout.set -> void diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs new file mode 100644 index 00000000000..593c4563157 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreKeyTests.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class AgentSessionStoreKeyTests +{ + [Fact] + public void Constructor_WithoutPartitions_LeavesPartitionsNull() + { + // Act + var omitted = new AgentSessionStoreKey("session-1"); + var explicitNull = new AgentSessionStoreKey("session-1", partitions: null); + var empty = new AgentSessionStoreKey("session-1", new Dictionary()); + + // Assert + Assert.Null(omitted.Partitions); + Assert.Null(explicitNull.Partitions); + Assert.Null(empty.Partitions); + Assert.Equal(omitted, explicitNull); + Assert.Equal(explicitNull, empty); + Assert.Equal(empty, omitted); + Assert.Equal(omitted.GetHashCode(), explicitNull.GetHashCode()); + Assert.Equal(omitted.GetHashCode(), empty.GetHashCode()); + } + + [Fact] + public void Equality_NullPartitions_DistinguishesSessionAndPartitionedKeys() + { + // Arrange + var key = new AgentSessionStoreKey("session-1", partitions: null); + var otherSession = new AgentSessionStoreKey("session-2", partitions: null); + var partitioned = key.WithPartition("user", "alice"); + + // Act and assert + Assert.False(key.Equals(otherSession)); + Assert.False(key.Equals(partitioned)); + Assert.False(partitioned.Equals(key)); + Assert.False(key.Equals(null)); + } + + [Fact] + public void Constructor_CopiesAndSortsPartitions() + { + // Arrange + var partitions = new Dictionary + { + ["user"] = "user-1", + ["tenant"] = "tenant-1", + }; + + // Act + var key = new AgentSessionStoreKey("session-1", partitions); + partitions["user"] = "changed"; + + // Assert + Assert.Equal("session-1", key.SessionId); + Assert.NotNull(key.Partitions); + Assert.Equal(["tenant", "user"], key.Partitions.Keys); + Assert.Equal("user-1", key.Partitions["user"]); + } + + [Fact] + public void Equality_IgnoresPartitionInsertionOrder() + { + // Arrange + var first = new AgentSessionStoreKey( + "session-1", + new Dictionary + { + ["tenant"] = "tenant-1", + ["user"] = "user-1", + }); + var second = new AgentSessionStoreKey( + "session-1", + new Dictionary + { + ["user"] = "user-1", + ["tenant"] = "tenant-1", + }); + + // Act and assert + Assert.Equal(first, second); + Assert.Equal(first.GetHashCode(), second.GetHashCode()); + } + + [Fact] + public void Equality_DistinguishesPartitionNamesValuesAndMissingPartitions() + { + // Arrange + var unpartitioned = new AgentSessionStoreKey("tenant::session"); + var tenantPartition = new AgentSessionStoreKey("session").WithPartition("tenant", "tenant"); + var userPartition = new AgentSessionStoreKey("session").WithPartition("user", "tenant"); + + // Act and assert + Assert.NotEqual(unpartitioned, tenantPartition); + Assert.NotEqual(tenantPartition, userPartition); + } + + [Fact] + public void WithPartition_ReturnsNewKeyAndPreservesOriginal() + { + // Arrange + var original = new AgentSessionStoreKey("session-1"); + + // Act + AgentSessionStoreKey partitioned = original.WithPartition("tenant", "tenant-1"); + + // Assert + Assert.Null(original.Partitions); + Assert.NotNull(partitioned.Partitions); + Assert.Equal("tenant-1", partitioned.Partitions["tenant"]); + } + + [Fact] + public void WithPartition_SameValue_ReturnsSameInstance() + { + // Arrange + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); + + // Act + AgentSessionStoreKey result = key.WithPartition("tenant", "tenant-1"); + + // Assert + Assert.Same(key, result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_BlankSessionId_Throws(string sessionId) + { + // Act and assert + Assert.Throws(() => new AgentSessionStoreKey(sessionId)); + } + + [Theory] + [InlineData("", "value")] + [InlineData(" ", "value")] + [InlineData("name", "")] + [InlineData("name", " ")] + public void Constructor_BlankPartition_Throws(string name, string value) + { + // Act and assert + Assert.Throws( + () => new AgentSessionStoreKey( + "session-1", + new Dictionary { [name] = value })); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs new file mode 100644 index 00000000000..492e6e2fb54 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Abstractions.UnitTests/AgentSessionStoreTests.cs @@ -0,0 +1,145 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.Abstractions.UnitTests; + +/// +/// Unit tests for . +/// +public sealed class AgentSessionStoreTests +{ + [Fact] + public void GetService_CompatibleUnkeyedType_ReturnsStore() + { + // Arrange + var store = new TestAgentSessionStore(session: null); + + // Act and assert + Assert.Same(store, store.GetService(typeof(TestAgentSessionStore))); + Assert.Same(store, store.GetService()); + Assert.Same(store, store.GetService()); + } + + [Fact] + public void GetService_UnsupportedRequest_ReturnsDefault() + { + // Arrange + var store = new TestAgentSessionStore(session: null); + + // Act and assert + Assert.Null(store.GetService(typeof(IDisposable))); + Assert.Null(store.GetService()); + Assert.Null(store.GetService("key")); + Assert.Equal(0, store.GetService()); + } + + [Fact] + public void GetService_NullType_Throws() + { + // Arrange + var store = new TestAgentSessionStore(session: null); + + // Act and assert + Assert.Throws("serviceType", () => store.GetService(null!)); + } + + [Fact] + public void GetService_GenericOverload_UsesVirtualMethod() + { + // Arrange + var service = new object(); + var key = new object(); + var store = new Mock(); + store.Setup(s => s.GetService(typeof(object), key)).Returns(service); + + // Act + var result = store.Object.GetService(key); + + // Assert + Assert.Same(service, result); + store.Verify(s => s.GetService(typeof(object), key), Times.Once); + } + + [Fact] + public async Task GetOrCreateSessionAsync_StoredSession_ReturnsStoredSessionAsync() + { + // Arrange + var storedSession = new TestAgentSession(); + var store = new TestAgentSessionStore(storedSession); + var agent = new Mock(); + var key = new AgentSessionStoreKey("conversation-1").WithPartition("user", "user-1"); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync(agent.Object, key); + + // Assert + Assert.Same(storedSession, session); + Assert.Same(key, store.LastKey); + agent.Protected().Verify( + "CreateSessionCoreAsync", + Times.Never(), + ItExpr.IsAny()); + } + + [Fact] + public async Task GetOrCreateSessionAsync_MissingSession_CreatesSessionAsync() + { + // Arrange + var createdSession = new TestAgentSession(); + var store = new TestAgentSessionStore(session: null); + var agent = new Mock(); + var key = new AgentSessionStoreKey("conversation-1"); + agent.Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(createdSession); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync(agent.Object, key); + + // Assert + Assert.Same(createdSession, session); + agent.Protected().Verify( + "CreateSessionCoreAsync", + Times.Once(), + ItExpr.IsAny()); + } + + [Fact] + public async Task GetOrCreateSessionAsync_NullAgent_ThrowsAsync() + { + // Arrange + var store = new TestAgentSessionStore(session: null); + + // Act and assert + await Assert.ThrowsAsync( + () => store.GetOrCreateSessionAsync(null!, new AgentSessionStoreKey("conversation-1")).AsTask()); + } + + private sealed class TestAgentSessionStore(AgentSession? session) : AgentSessionStore + { + public AgentSessionStoreKey? LastKey { get; private set; } + + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + { + this.LastKey = key; + return new(session); + } + + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) + => default; + } + + private sealed class TestAgentSession : AgentSession; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs index 6b2567244ef..7f46da4a85f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerResilienceTests.cs @@ -426,7 +426,11 @@ private sealed class ThrowOnceSessionStore : AgentSessionStore public int SaveAttempts => this._saveAttempts; - public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, string? userId, CancellationToken cancellationToken = default) + public override ValueTask SaveSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + AgentSession session, + CancellationToken cancellationToken = default) { var attempt = Interlocked.Increment(ref this._saveAttempts); if (attempt == 1) @@ -437,7 +441,10 @@ public override ValueTask SaveSessionAsync(AIAgent agent, string conversationId, return default; } - public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, string? userId, CancellationToken cancellationToken = default) => + public override async ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } @@ -449,9 +456,8 @@ private sealed class CountingSessionStore : AgentSessionStore public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { Interlocked.Increment(ref this._saveAttempts); @@ -460,8 +466,7 @@ public override ValueTask SaveSessionAsync( public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => new((AgentSession?)null); } @@ -470,16 +475,14 @@ private sealed class AlwaysLoadedSessionStore : AgentSessionStore { public override ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) => default; public override async ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs index 509a24a6ab9..09d896c543c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/AgentFrameworkResponseHandlerTests.cs @@ -1435,7 +1435,12 @@ private static ResponseContext NewContextServing(string responseId, IReadOnlyLis private static async Task SerializedSessionOfAsync(AIAgent agent, InMemoryAgentSessionStore store, string responseId) { var sessionKey = HostedConversationKey.Resolve(conversationId: null, previousResponseId: null, responseId); - var session = await store.GetSessionAsync(agent, sessionKey!, FakeHostedSessionIsolationKeyProvider.DefaultUserId, CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent( + agent, + FoundryHostingAgent.ResolveSessionStorageIdentity(agent, registrationKey: null, defaultAgent: agent)); + var storageKey = new AgentSessionStoreKey(sessionKey!) + .WithPartition("user", FakeHostedSessionIsolationKeyProvider.DefaultUserId); + var session = await store.GetSessionAsync(storageAgent, storageKey, CancellationToken.None); // The handler persists the session at the end of every turn, so a missing one means the turn did // not get that far and the assertions below would otherwise pass without proving anything. @@ -1836,11 +1841,10 @@ public async Task CreateAsync_AfterStreamCompletes_DoesNotLeakCallIdToCallerCont // These drive the hosted-agent handler (the in-process "hosted instance") against a REAL // FileSystemAgentSessionStore and the REAL PlatformHostedSessionIsolationKeyProvider (no fake), so the // user id is genuinely captured from the request's x-agent-user-id (ResponseContext.PlatformContext). - // They assert the on-disk layout {root}/a-{agent}/u-{userId}/c-{conv}.json for combinations of agent - // name and user. + // They assert distinct stable-key files for combinations of agent name and user. [Fact] - public async Task CreateAsync_MultipleUsersSameAgent_WritePerUserDirectoriesAsync() + public async Task CreateAsync_MultipleUsersSameAgent_WriteDistinctPartitionedFilesAsync() { var root = NewIsolationTempRoot(); try @@ -1855,10 +1859,11 @@ public async Task CreateAsync_MultipleUsersSameAgent_WritePerUserDirectoriesAsyn var (bobReq, bobCtx) = BuildUserRequest("concierge", "trip", userId: "bob"); await DrainEventsAsync(handler.CreateAsync(bobReq, bobCtx.Object, CancellationToken.None)); - // Assert: each user's session is persisted under its own u-{userId} directory beneath the - // shared a-{agent} directory; neither can reach the other's path. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-bob", "c-trip.json"))); + // Assert + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1884,8 +1889,11 @@ public async Task CreateAsync_MultipleAgentsSameUser_WritePerAgentDirectoriesAsy // Assert: each agent buckets the user's session under its own a-{agent} directory, so two // agents in the same container cannot collide on a shared conversation id. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-scheduler", "u-alice", "c-trip.json"))); + Assert.Equal(2, Directory.GetDirectories(store.RootDirectory).Length); + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1917,9 +1925,11 @@ public async Task CreateAsync_SecondUserSameConversation_GetsFreshSessionNoLeakA // (Alice turn 1, Bob turn 1) and one restore (Alice turn 2). Assert.Equal(2, agent.CreateCount); Assert.Equal(1, agent.DeserializeCount); - // And the files live in distinct per-user directories. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-alice", "c-trip.json"))); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "u-bob", "c-trip.json"))); + // And the users produce distinct partitioned keys. + Assert.Equal(2, Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories).Length); } finally { @@ -1936,7 +1946,7 @@ public async Task CreateAsync_NoUserIdCaptured_NotHosted_SucceedsUnscopedAsync() // Arrange: a non-hosted (local) request whose x-agent-user-id was not captured (PlatformContext // is null). Under unit tests FoundryEnvironment.IsHosted is false, so the container is treated as // local: per-user isolation is simply not triggered and the request succeeds instead of 500ing. - // The session is persisted without a u-{userId} segment (unscoped). The hosted-but-missing-user + // The session key contains no user partition. The hosted-but-missing-user // branch (which still rejects) cannot be unit-tested because FoundryEnvironment.IsHosted is a // process-cached static; it is exercised by the investigation repro app's "hosted" scenario. var store = new FileSystemAgentSessionStore(root); @@ -1946,10 +1956,11 @@ public async Task CreateAsync_NoUserIdCaptured_NotHosted_SucceedsUnscopedAsync() // Act: the request drains without throwing. await DrainEventsAsync(handler.CreateAsync(req, ctx.Object, CancellationToken.None)); - // Assert: the session is written under the agent bucket with NO per-user (u-*) segment. - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "a-concierge", "c-trip.json"))); - var agentDir = Path.Combine(store.RootDirectory, "a-concierge"); - Assert.Empty(Directory.GetDirectories(agentDir, "u-*")); + // Assert: the session is written under the agent bucket using an unpartitioned key. + Assert.Single(Directory.GetFiles( + store.RootDirectory, + "*.json", + SearchOption.AllDirectories)); } finally { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs index 01552edcd15..589f6b0fe86 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FileSystemAgentSessionStoreTests.cs @@ -12,12 +12,8 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public sealed class FileSystemAgentSessionStoreTests : IDisposable { - private readonly string _root; - - public FileSystemAgentSessionStoreTests() - { - this._root = Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N")); - } + private readonly string _root = + Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N")); public void Dispose() { @@ -30,7 +26,7 @@ public void Dispose() } catch { - // best-effort cleanup + // Best-effort cleanup. } } @@ -55,11 +51,12 @@ public async Task GetSessionAsync_NoFileOnDisk_ReturnsNullAsync() var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + AgentSession? session = await store.GetSessionAsync(agent, new AgentSessionStoreKey("session-1")); Assert.Null(session); Assert.Equal(0, agent.CreateCalls); Assert.Equal(0, agent.DeserializeCalls); + Assert.False(Directory.Exists(this._root)); } [Fact] @@ -68,7 +65,7 @@ public async Task GetOrCreateSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAg var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); - var session = await store.GetOrCreateSessionAsync(agent, "conv-1", userId: null); + AgentSession session = await store.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey("session-1")); Assert.NotNull(session); Assert.Equal(1, agent.CreateCalls); @@ -79,29 +76,28 @@ public async Task GetOrCreateSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAg public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsNullAsync() { var store = new FileSystemAgentSessionStore(this._root); - Directory.CreateDirectory(store.RootDirectory); - File.WriteAllText(Path.Combine(store.RootDirectory, "conv-empty.json"), string.Empty); - + var key = new AgentSessionStoreKey("empty"); var agent = new TestAgent(); - var session = await store.GetSessionAsync(agent, "conv-empty", userId: null); + string agentDirectory = AgentDirectory(store, "name:test-agent"); + Directory.CreateDirectory(agentDirectory); + File.WriteAllText(SessionPath(store, "name:test-agent", key), string.Empty); + + AgentSession? session = await store.GetSessionAsync(agent, key); Assert.Null(session); - Assert.Equal(0, agent.CreateCalls); Assert.Equal(0, agent.DeserializeCalls); } [Fact] - public async Task SaveSessionAsync_CreatesRootDirectoryIfMissingAsync() + public async Task SaveSessionAsync_CreatesRootDirectoryAndStableKeyFileAsync() { var nested = Path.Combine(this._root, "nested", "deeper"); var store = new FileSystemAgentSessionStore(nested); - Assert.False(Directory.Exists(nested)); + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); - var agent = new TestAgent("{\"workflow\":\"x\"}"); - await store.SaveSessionAsync(agent, "conv-2", NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent("{\"workflow\":\"x\"}"), key, NewSession()); - Assert.True(Directory.Exists(nested)); - Assert.True(File.Exists(Path.Combine(nested, "c-conv-2.json"))); + Assert.True(File.Exists(SessionPath(store, "name:test-agent", key))); } [Fact] @@ -109,100 +105,92 @@ public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsViaAgentSeriali { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent("{\"foo\":42}"); + var key = new AgentSessionStoreKey("round-trip").WithPartition("tenant", "tenant-1"); - await store.SaveSessionAsync(agent, "round-trip", NewSession(), userId: null); - await store.GetSessionAsync(agent, "round-trip", userId: null); + await store.SaveSessionAsync(agent, key, NewSession()); + await store.GetSessionAsync(agent, key); Assert.Equal(1, agent.SerializeCalls); Assert.Equal(1, agent.DeserializeCalls); - Assert.NotNull(agent.LastDeserialized); - Assert.Equal(JsonValueKind.Object, agent.LastDeserialized!.Value.ValueKind); Assert.Equal(42, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32()); } [Fact] - public async Task SaveSessionAsync_TwoAgentsSameConversationId_DoNotCollideAsync() + public async Task SaveSessionAsync_TwoAgentsSameKey_DoNotCollideAsync() { var store = new FileSystemAgentSessionStore(this._root); + var key = new AgentSessionStoreKey("shared"); var agentA = new TestAgent("{\"who\":\"a\"}", name: "AgentA"); var agentB = new TestAgent("{\"who\":\"b\"}", name: "AgentB"); - await store.SaveSessionAsync(agentA, "shared-conv", NewSession(), userId: null); - await store.SaveSessionAsync(agentB, "shared-conv", NewSession(), userId: null); + await store.SaveSessionAsync(agentA, key, NewSession()); + await store.SaveSessionAsync(agentB, key, NewSession()); - // Agents with distinct Names get distinct subdirectories so neither overwrites the other. - var pathA = Path.Combine(store.RootDirectory, "a-AgentA", "c-shared-conv.json"); - var pathB = Path.Combine(store.RootDirectory, "a-AgentB", "c-shared-conv.json"); - Assert.True(File.Exists(pathA)); - Assert.True(File.Exists(pathB)); + string pathA = SessionPath(store, "name:AgentA", key); + string pathB = SessionPath(store, "name:AgentB", key); Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal); Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal); } [Fact] - public async Task SaveSessionAsync_LongConversationId_DoesNotStackOverflowAsync() + public async Task SaveSessionAsync_ArbitraryIdentifiersDoNotBecomePathSegmentsAsync() { - // Keep the value < typical OS file-name limits (~255 chars) so the file write - // succeeds, but long enough to force Sanitize past its small-input fast path. var store = new FileSystemAgentSessionStore(this._root); - var conversationId = new string('a', 200); - var agent = new TestAgent(); + var key = new AgentSessionStoreKey("../../session\0") + .WithPartition("../tenant", "/rooted/value"); - await store.SaveSessionAsync(agent, conversationId, NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent(), key, NewSession()); - var files = Directory.GetFiles(store.RootDirectory, "*.json"); - Assert.Single(files); + string file = Assert.Single(Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories)); + Assert.Equal(Path.GetFileName(SessionPath(store, "name:test-agent", key)), Path.GetFileName(file)); + Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, Path.GetFullPath(file), StringComparison.Ordinal); } [Fact] - public async Task SaveSessionAsync_SanitizesInvalidPathCharactersAsync() + public async Task SaveSessionAsync_DifferentPartitionsProduceDistinctFilesAsync() { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent(); + var aliceKey = new AgentSessionStoreKey("shared").WithPartition("user", "alice"); + var bobKey = new AgentSessionStoreKey("shared").WithPartition("user", "bob"); - // Pick an invalid filename char for the current OS. The set differs by platform - // (e.g. '?' is invalid on Windows but not on Linux), so we must select dynamically. - var invalidChars = Path.GetInvalidFileNameChars(); - Assert.NotEmpty(invalidChars); - char invalid = invalidChars[0]; - // Avoid NUL specifically because some shells/loggers handle it oddly; prefer - // the next character if available. - if (invalid == '\0' && invalidChars.Length > 1) - { - invalid = invalidChars[1]; - } + await store.SaveSessionAsync(agent, aliceKey, NewSession()); + await store.SaveSessionAsync(agent, bobKey, NewSession()); - var conversationId = $"id-with{invalid}invalid-chars"; + Assert.Equal(2, Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories).Length); + } - await store.SaveSessionAsync(agent, conversationId, NewSession(), userId: null); + [Fact] + public async Task GetSessionAsync_DifferentPartition_DoesNotReadStoredSessionAsync() + { + var store = new FileSystemAgentSessionStore(this._root); + var agent = new TestAgent("{\"secret\":\"alice-only\"}"); + var aliceKey = new AgentSessionStoreKey("shared").WithPartition("user", "alice"); + var bobKey = new AgentSessionStoreKey("shared").WithPartition("user", "bob"); + await store.SaveSessionAsync(agent, aliceKey, NewSession()); + + AgentSession? bobSession = await store.GetSessionAsync(agent, bobKey); - var files = Directory.GetFiles(store.RootDirectory, "*.json"); - Assert.Single(files); - var fileName = Path.GetFileName(files[0]); - Assert.DoesNotContain(invalid.ToString(), fileName, StringComparison.Ordinal); - Assert.Contains("id-with", fileName, StringComparison.Ordinal); - Assert.Contains("invalid-chars", fileName, StringComparison.Ordinal); + Assert.Null(bobSession); + Assert.Equal(0, agent.DeserializeCalls); } [Fact] - public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollideOnTempFileAsync() + public async Task SaveSessionAsync_ConcurrentSavesOnSameKey_DoNotCollideOnTempFileAsync() { var store = new FileSystemAgentSessionStore(this._root); var agent = new TestAgent("{\"x\":1}"); - - // Fan out N concurrent saves; with a fixed temp filename ("path.tmp") this would - // race on FileMode.Create / Move. Verify they all complete successfully. + var key = new AgentSessionStoreKey("concurrent"); var tasks = new List(); - for (int i = 0; i < 16; i++) + for (int index = 0; index < 16; index++) { - tasks.Add(store.SaveSessionAsync(agent, "concurrent", NewSession(), userId: null).AsTask()); + tasks.Add(store.SaveSessionAsync(agent, key, NewSession()).AsTask()); } await Task.WhenAll(tasks); - Assert.True(File.Exists(Path.Combine(store.RootDirectory, "c-concurrent.json"))); - var leftoverTempFiles = Directory.GetFiles(store.RootDirectory, "*.tmp"); - Assert.Empty(leftoverTempFiles); + Assert.True(File.Exists(SessionPath(store, "name:test-agent", key))); + Assert.Empty(Directory.GetFiles(store.RootDirectory, "*.tmp", SearchOption.AllDirectories)); } [Theory] @@ -212,272 +200,128 @@ public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollid public async Task SaveSessionAsync_AgentNameIsDotSegment_DoesNotEscapeRootAsync(string agentName) { var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: agentName); - await store.SaveSessionAsync(agent, "conv-dots", NewSession(), userId: null); + await store.SaveSessionAsync( + new TestAgent(name: agentName), + new AgentSessionStoreKey("session-1"), + NewSession()); - // The session file must land inside RootDirectory, not in (or above) it as a sibling. - var allFiles = Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories); - Assert.Single(allFiles); - var fullPath = Path.GetFullPath(allFiles[0]); + string fullPath = Path.GetFullPath(Assert.Single( + Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories))); Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, fullPath, StringComparison.Ordinal); - - // The bucket directory name must not be a navigable dot-segment. After - // percent-encoding every dot in an all-dot segment, names like ".", "..", and - // "..." become "%2E", "%2E%2E", "%2E%2E%2E" — distinct, OS-neutral filenames. - var bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!); - Assert.NotEmpty(bucketName); - Assert.NotEqual(".", bucketName); - Assert.NotEqual("..", bucketName); - Assert.DoesNotContain(bucketName, c => c == '.'); + string bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!); + Assert.DoesNotContain(bucketName, value => value == '.'); } [Fact] - public async Task SaveSessionAsync_DistinctNamesWithInvalidChars_ProduceDistinctFilesAsync() + public async Task SaveSessionAsync_DistinctAgentNamesWithInvalidCharacters_DoNotCollideAsync() { - // Percent-encoding must keep otherwise-colliding inputs distinct: under the - // earlier underscore-substitution scheme, "foo/bar" and "foo_bar" both sanitized - // to "foo_bar" and would have shared a session bucket on disk. var store = new FileSystemAgentSessionStore(this._root); - var agentSlash = new TestAgent(name: "foo/bar"); - var agentUnderscore = new TestAgent(name: "foo_bar"); + var key = new AgentSessionStoreKey("session-1"); - await store.SaveSessionAsync(agentSlash, "conv-1", NewSession(), userId: null); - await store.SaveSessionAsync(agentUnderscore, "conv-1", NewSession(), userId: null); + await store.SaveSessionAsync(new TestAgent(name: "foo/bar"), key, NewSession()); + await store.SaveSessionAsync(new TestAgent(name: "foo_bar"), key, NewSession()); - var bucketDirs = Directory.GetDirectories(store.RootDirectory); - Assert.Equal(2, bucketDirs.Length); + Assert.Equal(2, Directory.GetDirectories(store.RootDirectory).Length); } [Fact] - public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync() + public async Task SaveSessionAsync_UnnamedDirectAgent_ThrowsAsync() { - // Read operations must not have side effects on the file system. var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "agent-with-bucket"); - var session = await store.GetSessionAsync(agent, "missing-id", userId: null); + await Assert.ThrowsAsync( + () => store.SaveSessionAsync( + new TestAgent(name: null), + new AgentSessionStoreKey("session-1"), + NewSession()).AsTask()); + } - Assert.Null(session); - Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory."); + [Fact] + public async Task SaveSessionAsync_NonWritableDirectory_ThrowsClearActionableIOExceptionAsync() + { + Directory.CreateDirectory(this._root); + string blockingFile = Path.Combine(this._root, "blocking-file"); + File.WriteAllText(blockingFile, "x"); + var store = new FileSystemAgentSessionStore(Path.Combine(blockingFile, ".checkpoints")); + + IOException exception = await Assert.ThrowsAsync( + () => store.SaveSessionAsync( + new TestAgent(), + new AgentSessionStoreKey("session-1"), + NewSession()).AsTask()); + + Assert.Contains("could not be created or written to", exception.Message, StringComparison.Ordinal); + Assert.Contains(FileSystemAgentSessionStore.SessionDataDirectoryEnvironmentVariable, exception.Message, StringComparison.Ordinal); + Assert.Contains(FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, exception.Message, StringComparison.Ordinal); + Assert.NotNull(exception.InnerException); } [Fact] public void ResolveDefaultRootDirectory_Hosted_RootsUnderHome() { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: true, homeDirectory: "/home/session", currentDirectory: "/some/cwd"); - // Assert - Assert.Equal( - Path.Combine("/home/session", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); + Assert.Equal(Path.Combine("/home/session", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void ResolveDefaultRootDirectory_HostedWithoutHome_UsesDefaultSessionDataDirectory(string? home) + [InlineData("/")] + public void ResolveDefaultRootDirectory_HostedWithUnusableHome_UsesDefault(string? home) { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: true, homeDirectory: home, currentDirectory: "/some/cwd"); - // Assert: falls back to the spec default ("/home/session"), never the filesystem root. Assert.Equal( Path.Combine( FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); - Assert.NotEqual("/.checkpoints", root); } [Fact] public void ResolveDefaultRootDirectory_NotHosted_UsesCurrentDirectory() { - // Arrange / Act - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( + string root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( isHosted: false, homeDirectory: "/home/session", currentDirectory: "/some/cwd"); - // Assert - Assert.Equal( - Path.Combine("/some/cwd", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - } - - [Theory] - [InlineData("/")] - public void ResolveDefaultRootDirectory_HostedWithFilesystemRootHome_FallsBackToDefault(string home) - { - // Arrange / Act: a filesystem-root HOME (e.g. "/") must NOT root the store at - // "/.checkpoints", which is read-only in the container and caused issue #6231. - var root = FileSystemAgentSessionStore.ResolveDefaultRootDirectory( - isHosted: true, - homeDirectory: home, - currentDirectory: "/some/cwd"); - - // Assert: falls back to the default session-data directory, never the filesystem root. - Assert.Equal( - Path.Combine( - FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, - FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - Assert.NotEqual( - Path.Combine(Path.GetPathRoot(Path.GetFullPath(home))!, FileSystemAgentSessionStore.LocalCheckpointDirectoryName), - root); - } - - [Fact] - public async Task SaveSessionAsync_NonWritableDirectory_ThrowsClearActionableIOExceptionAsync() - { - // Arrange: place a file where the store's root directory needs to be created. Creating - // a directory under an existing file fails with IOException on every OS, standing in for - // the read-only root filesystem of a Foundry hosted container (issue #6231). - Directory.CreateDirectory(this._root); - var blockingFile = Path.Combine(this._root, "blocking-file"); - File.WriteAllText(blockingFile, "x"); - - var store = new FileSystemAgentSessionStore(Path.Combine(blockingFile, ".checkpoints")); - var agent = new TestAgent(); - - // Act - var ex = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-fatal", NewSession(), userId: null)); - - // Assert: failure stays fatal but the message is clear and actionable, and the original - // IO error is preserved as the inner exception. - Assert.Contains("could not be created or written to", ex.Message, StringComparison.Ordinal); - Assert.Contains(FileSystemAgentSessionStore.SessionDataDirectoryEnvironmentVariable, ex.Message, StringComparison.Ordinal); - Assert.Contains(FileSystemAgentSessionStore.DefaultHostedSessionDataDirectory, ex.Message, StringComparison.Ordinal); - Assert.Contains(store.RootDirectory, ex.Message, StringComparison.Ordinal); - Assert.NotNull(ex.InnerException); + Assert.Equal(Path.Combine("/some/cwd", FileSystemAgentSessionStore.LocalCheckpointDirectoryName), root); } - [Fact] - public async Task SaveSessionAsync_WithUserId_NestsUnderPrefixedAgentAndUserAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"who\":\"alice\"}", name: "Concierge"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: "alice"); - - // Layout: {root}/a-{agent}/u-{userId}/c-{conv}.json - var expected = Path.Combine(store.RootDirectory, "a-Concierge", "u-alice", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - } - - [Fact] - public async Task SaveSessionAsync_NoUserId_OmitsUserSegmentAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: null); - - // No user id -> the u- layer collapses: {root}/a-{agent}/c-{conv}.json - var expected = Path.Combine(store.RootDirectory, "a-Concierge", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - Assert.Empty(Directory.GetDirectories(Path.Combine(store.RootDirectory, "a-Concierge"))); - } - - [Fact] - public async Task GetSessionAsync_DifferentUser_DoesNotReadAnotherUsersSessionAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"secret\":\"alice-only\"}", name: "Concierge"); - - // Alice saves under the same conversationId Bob will guess/forge. - await store.SaveSessionAsync(agent, "shared-conv", NewSession(), userId: "alice"); - - // Bob requests the same conversationId. The per-user partition means Bob's path is distinct, - // so the store returns null (no leak), not Alice's persisted state. - var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob"); - - Assert.Null(bobSession); // no session for Bob under his partition - Assert.Equal(0, agent.CreateCalls); // a plain lookup never creates - Assert.Equal(0, agent.DeserializeCalls); // Alice's file never deserialized for Bob - } - - [Fact] - public async Task SaveSessionAsync_UserIdEqualToAgentName_StaysDistinctViaPrefixesAsync() - { - // Without prefixes, agent "x" + no user could collide with no-agent + user "x". The a-/u- - // prefixes keep the layers unambiguous. - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "x"); - - await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: "x"); - - var expected = Path.Combine(store.RootDirectory, "a-x", "u-x", "c-conv-1.json"); - Assert.True(File.Exists(expected), $"expected session at {expected}"); - } - - [Theory] - [InlineData("../../escape")] - [InlineData("..")] - [InlineData("user/../../escape")] - [InlineData("a/b")] - [InlineData("a\\b")] - [InlineData(".")] - public async Task SaveSessionAsync_TraversalUserId_IsRejectedAsync(string userId) - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - // A forged user id that is not a single safe path segment is rejected outright (CWE-22), - // not sanitized — so it can never escape the storage root. - await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: userId)); - - // Nothing was written outside (or inside) the root. - Assert.False(Directory.Exists(this._root) && Directory.GetFiles(this._root, "*.json", SearchOption.AllDirectories).Length > 0); - } - - [Fact] - public async Task SaveSessionAsync_AbsoluteOrRootedUserId_IsRejectedAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent(name: "Concierge"); - - var rooted = Path.IsPathRooted("/etc") ? "/etc" : Path.GetFullPath("/etc"); - await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", NewSession(), userId: rooted)); - } - - [Fact] - public async Task SaveSessionAsync_ThenGetSessionAsync_WithUserId_RoundTripsAsync() - { - var store = new FileSystemAgentSessionStore(this._root); - var agent = new TestAgent("{\"foo\":7}", name: "Concierge"); - - await store.SaveSessionAsync(agent, "round-trip", NewSession(), userId: "alice"); - await store.GetSessionAsync(agent, "round-trip", userId: "alice"); + private static TestSession NewSession() => new(); - Assert.Equal(1, agent.SerializeCalls); - Assert.Equal(1, agent.DeserializeCalls); - Assert.Equal(7, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32()); - } + private static string AgentDirectory(FileSystemAgentSessionStore store, string identity) + => Path.Combine( + store.RootDirectory, + "a-" + FoundryAgentSessionKeyEncoder.BuildAgentStorageKey(identity)); - private static TestSession NewSession() => new(); + private static string SessionPath( + FileSystemAgentSessionStore store, + string identity, + AgentSessionStoreKey key) + => Path.Combine( + AgentDirectory(store, identity), + "k-" + FoundryAgentSessionKeyEncoder.BuildStorageKey( + FoundryAgentSessionKeyEncoder.BuildLogicalKey(identity, key)) + ".json"); - private sealed class TestSession : AgentSession - { - } + private sealed class TestSession : AgentSession; private sealed class TestAgent : AIAgent { private readonly string _serializedJson; private readonly string? _name; - public TestAgent(string serializedJson = "{}", string? name = null) + public TestAgent(string serializedJson = "{}", string? name = "test-agent") { this._serializedJson = serializedJson; this._name = name; @@ -486,8 +330,11 @@ public TestAgent(string serializedJson = "{}", string? name = null) public override string? Name => this._name; public int CreateCalls { get; private set; } + public int SerializeCalls { get; private set; } + public int DeserializeCalls { get; private set; } + public JsonElement? LastDeserialized { get; private set; } protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) @@ -496,24 +343,38 @@ protected override ValueTask CreateSessionCoreAsync(CancellationTo return new ValueTask(NewSession()); } - protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) { this.SerializeCalls++; - using var doc = JsonDocument.Parse(this._serializedJson); - return new ValueTask(doc.RootElement.Clone()); + using var document = JsonDocument.Parse(this._serializedJson); + return new ValueTask(document.RootElement.Clone()); } - protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) { this.DeserializeCalls++; this.LastDeserialized = serializedState.Clone(); return new ValueTask(NewSession()); } - protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); - protected override IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs index 3fb3da21ddb..640b6733735 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryAgentSessionStoreTests.cs @@ -13,6 +13,29 @@ namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; public sealed class FoundryAgentSessionStoreTests { + [Fact] + public async Task SaveAndGetSessionAsync_NullAndEmptyPartitions_UseSameStoredSessionAsync() + { + // Arrange + var backing = new FakeStateStore(); + var store = NewStore(backing); + var agent = new TestAgent("{\"foo\":7}", name: "Concierge"); + var nullPartitions = new AgentSessionStoreKey("conv-1", partitions: null); + var emptyPartitions = new AgentSessionStoreKey("conv-1", new Dictionary()); + + // Act + await store.SaveSessionAsync(agent, nullPartitions, new TestSession()); + var session = await store.GetSessionAsync(agent, emptyPartitions); + var partitionedSession = await store.GetSessionAsync(agent, nullPartitions.WithPartition("user", "alice")); + + // Assert + Assert.NotNull(session); + Assert.Null(partitionedSession); + Assert.Equal(7, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32()); + var item = Assert.Single(backing.Items); + Assert.Equal("\"a14:name:Concierge|s6:conv-1\"", item["key"].ToString()); + } + [Fact] public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsAsync() { @@ -20,10 +43,11 @@ public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsAsync() var backing = new FakeStateStore(); var store = NewStore(backing); var agent = new TestAgent("{\"foo\":7}", name: "Concierge"); + var key = Key("round-trip", "user", "alice"); // Act - await store.SaveSessionAsync(agent, "round-trip", new TestSession(), userId: "alice"); - var session = await store.GetSessionAsync(agent, "round-trip", userId: "alice"); + await store.SaveSessionAsync(agent, key, new TestSession()); + var session = await store.GetSessionAsync(agent, key); // Assert Assert.NotNull(session); @@ -39,13 +63,14 @@ public async Task SaveSessionAsync_StoresReadableLogicalKeyAlongsideTheSessionAs var backing = new FakeStateStore(); var store = NewStore(backing); var agent = new TestAgent(name: "Concierge"); + var key = Key("conv-1", "user", "alice"); // Act - await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: "alice"); + await store.SaveSessionAsync(agent, key, new TestSession()); // Assert: the item body keeps the readable key so a stored item can be traced back. var item = Assert.Single(backing.Items); - Assert.Equal("\"a14:name:Concierge|u5:alice|c6:conv-1\"", item["key"].ToString()); + Assert.Equal("\"a14:name:Concierge|s6:conv-1|n4:user|v5:alice\"", item["key"].ToString()); } [Fact] @@ -56,7 +81,7 @@ public async Task GetSessionAsync_NothingStored_ReturnsNullAsync() var agent = new TestAgent(name: "Concierge"); // Act - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -72,7 +97,7 @@ public async Task GetOrCreateSessionAsync_NothingStored_ReturnsFreshSessionFromA var agent = new TestAgent(name: "Concierge"); // Act - var session = await store.GetOrCreateSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetOrCreateSessionAsync(agent, Key("conv-1")); // Assert Assert.NotNull(session); @@ -86,10 +111,10 @@ public async Task GetSessionAsync_DifferentUser_DoesNotReadAnotherUsersSessionAs // Arrange: Alice saves under the conversation id Bob will forge. var store = NewStore(new FakeStateStore()); var agent = new TestAgent("{\"secret\":\"alice-only\"}", name: "Concierge"); - await store.SaveSessionAsync(agent, "shared-conv", new TestSession(), userId: "alice"); + await store.SaveSessionAsync(agent, Key("shared-conv", "user", "alice"), new TestSession()); // Act - var bobSession = await store.GetSessionAsync(agent, "shared-conv", userId: "bob"); + var bobSession = await store.GetSessionAsync(agent, Key("shared-conv", "user", "bob")); // Assert Assert.Null(bobSession); @@ -104,10 +129,11 @@ public async Task GetSessionAsync_DifferentAgent_DoesNotReadAnotherAgentsSession var store = NewStore(backing); var concierge = new TestAgent("{\"owner\":\"concierge\"}", name: "Concierge"); var researcher = new TestAgent(name: "Researcher"); - await store.SaveSessionAsync(concierge, "shared-conv", new TestSession(), userId: "alice"); + var key = Key("shared-conv", "user", "alice"); + await store.SaveSessionAsync(concierge, key, new TestSession()); // Act - var otherSession = await store.GetSessionAsync(researcher, "shared-conv", userId: "alice"); + var otherSession = await store.GetSessionAsync(researcher, key); // Assert Assert.Null(otherSession); @@ -125,10 +151,11 @@ public async Task GetSessionAsync_DifferentKeyedRegistration_DoesNotReadAnotherA var support = new TestAgent(); AIAgent billing = new FoundryHostingAgent(billingLeaf, "key:billing"); AIAgent hostedSupport = new FoundryHostingAgent(support, "key:support"); - await store.SaveSessionAsync(billing, "shared-conv", new TestSession(), userId: "alice"); + var key = Key("shared-conv", "user", "alice"); + await store.SaveSessionAsync(billing, key, new TestSession()); // Act - var supportSession = await store.GetSessionAsync(hostedSupport, "shared-conv", userId: "alice"); + var supportSession = await store.GetSessionAsync(hostedSupport, key); // Assert Assert.Null(supportSession); @@ -144,7 +171,7 @@ public async Task SaveSessionAsync_UnnamedAgent_ThrowsAsync() // Act var exception = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: "alice")); + async () => await store.SaveSessionAsync(agent, Key("conv-1", "user", "alice"), new TestSession())); // Assert Assert.Contains(nameof(AIAgent.Name), exception.Message, StringComparison.Ordinal); @@ -159,7 +186,7 @@ public async Task GetSessionAsync_UnnamedAgent_ThrowsAsync() // Act var exception = await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: "alice")); + async () => await store.GetSessionAsync(agent, Key("conv-1", "user", "alice"))); // Assert Assert.Contains(nameof(AIAgent.Name), exception.Message, StringComparison.Ordinal); @@ -179,9 +206,9 @@ public async Task GetStoreAsync_ResolvesTheStoreOnceAcrossManyCallsAsync() var agent = new TestAgent(name: "Concierge"); // Act - await store.SaveSessionAsync(agent, "conv-1", new TestSession(), userId: null); - await store.GetSessionAsync(agent, "conv-1", userId: null); - await store.GetSessionAsync(agent, "conv-2", userId: null); + await store.SaveSessionAsync(agent, Key("conv-1"), new TestSession()); + await store.GetSessionAsync(agent, Key("conv-1")); + await store.GetSessionAsync(agent, Key("conv-2")); // Assert Assert.Equal(1, bindCount); @@ -204,8 +231,8 @@ public async Task GetStoreAsync_FailedBinding_IsRetriedOnTheNextCallAsync() // Act await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -230,8 +257,8 @@ public async Task GetStoreAsync_CanceledBinding_IsRetriedOnTheNextCallAsync() // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -256,8 +283,8 @@ public async Task GetStoreAsync_BindingFaultedWithCancellation_IsRetriedOnTheNex // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null)); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + async () => await store.GetSessionAsync(agent, Key("conv-1"))); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -282,9 +309,9 @@ public async Task GetStoreAsync_CallerCancellation_DoesNotDiscardTheSharedBindin // Act await Assert.ThrowsAnyAsync( - async () => await store.GetSessionAsync(agent, "conv-1", userId: null, cancellation.Token)); + async () => await store.GetSessionAsync(agent, Key("conv-1"), cancellation.Token)); binding.SetResult(backing); - var session = await store.GetSessionAsync(agent, "conv-1", userId: null); + var session = await store.GetSessionAsync(agent, Key("conv-1")); // Assert Assert.Null(session); @@ -292,15 +319,15 @@ await Assert.ThrowsAnyAsync( } [Theory] - [InlineData("name:Concierge", "alice", "conv-1", "a14:name:Concierge|u5:alice|c6:conv-1")] - [InlineData("name:Concierge", null, "conv-1", "a14:name:Concierge|u-1:|c6:conv-1")] - [InlineData("default", "alice", "conv-1", "a7:default|u5:alice|c6:conv-1")] - [InlineData("default", null, "conv-1", "a7:default|u-1:|c6:conv-1")] - [InlineData("name:x", "x", "conv-1", "a6:name:x|u1:x|c6:conv-1")] - public void BuildLogicalKey_UsesLengthPrefixedComponents(string agentIdentity, string? userId, string conversationId, string expected) + [InlineData("name:Concierge", "conv-1", "a14:name:Concierge|s6:conv-1")] + [InlineData("default", "conv-1", "a7:default|s6:conv-1")] + public void BuildLogicalKey_UsesLengthPrefixedComponents( + string agentIdentity, + string sessionId, + string expected) { // Act - var key = FoundryAgentSessionStore.BuildLogicalKey(agentIdentity, conversationId, userId); + string key = FoundryAgentSessionKeyEncoder.BuildLogicalKey(agentIdentity, Key(sessionId)); // Assert Assert.Equal(expected, key); @@ -309,29 +336,31 @@ public void BuildLogicalKey_UsesLengthPrefixedComponents(string agentIdentity, s [Fact] public void BuildLogicalKey_DelimitersInsideComponents_DoNotCollide() { - // Act: these tuples produced the same delimiter-joined string before components carried - // their lengths. - string first = FoundryAgentSessionStore.BuildLogicalKey("name:Concierge", "x:c-y", "alice"); - string second = FoundryAgentSessionStore.BuildLogicalKey("name:Concierge", "y", "alice:c-x"); + // Act + string first = FoundryAgentSessionKeyEncoder.BuildLogicalKey( + "name:Concierge", + Key("x:c-y", "user", "alice")); + string second = FoundryAgentSessionKeyEncoder.BuildLogicalKey( + "name:Concierge", + Key("y", "user", "alice:c-x")); // Assert Assert.NotEqual(first, second); Assert.NotEqual( - FoundryAgentSessionStore.BuildItemKey(first), - FoundryAgentSessionStore.BuildItemKey(second)); + FoundryAgentSessionKeyEncoder.BuildStorageKey(first), + FoundryAgentSessionKeyEncoder.BuildStorageKey(second)); } [Fact] public void BuildItemKey_StaysWithinThePlatformKeyLimitForAnyInput() { - // Arrange: an agent name plus a user id plus a conversation id can easily pass 128 chars. - var logicalKey = FoundryAgentSessionStore.BuildLogicalKey( + // Arrange + var logicalKey = FoundryAgentSessionKeyEncoder.BuildLogicalKey( $"name:{new string('a', 200)}", - new string('c', 200), - new string('u', 200)); + Key(new string('s', 200), "user", new string('u', 200))); // Act - var itemKey = FoundryAgentSessionStore.BuildItemKey(logicalKey); + var itemKey = FoundryAgentSessionKeyEncoder.BuildStorageKey(logicalKey); // Assert Assert.InRange(itemKey.Length, 1, 128); @@ -341,9 +370,9 @@ public void BuildItemKey_StaysWithinThePlatformKeyLimitForAnyInput() public void BuildItemKey_IsStableAndDistinctPerLogicalKey() { // Arrange / Act - var first = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u5:alice|c6:conv-1"); - var same = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u5:alice|c6:conv-1"); - var other = FoundryAgentSessionStore.BuildItemKey("a14:name:Concierge|u3:bob|c6:conv-1"); + var first = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); + var same = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v5:alice"); + var other = FoundryAgentSessionKeyEncoder.BuildStorageKey("a14:name:Concierge|s6:conv-1|n4:user|v3:bob"); // Assert Assert.Equal(first, same); @@ -371,6 +400,14 @@ public void Constructor_WithoutCredential_IsAllowedForTheSdkLocalFallback() private static FoundryAgentSessionStore NewStore(FakeStateStore backing) => new(_ => Task.FromResult(backing)); + private static AgentSessionStoreKey Key( + string sessionId, + string? partitionName = null, + string? partitionValue = null) + => partitionName is null + ? new AgentSessionStoreKey(sessionId) + : new AgentSessionStoreKey(sessionId).WithPartition(partitionName, partitionValue!); + /// /// An in-memory stand-in for the platform state store. exposes a /// protected constructor and virtual members precisely so it can be substituted like this. diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs index 68e7fb17984..296ee32defd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryStateStoreLocalFallbackTests.cs @@ -38,8 +38,9 @@ public async Task StoresWithoutCredential_RoundTripThroughTheSdkLocalFallbackAsy var checkpointStore = new FoundryJsonCheckpointStore(); // Act - await sessionStore.SaveSessionAsync(agent, "conversation-1", new TestSession(), userId: "user-1"); - AgentSession? session = await sessionStore.GetSessionAsync(agent, "conversation-1", userId: "user-1"); + var key = new AgentSessionStoreKey("conversation-1").WithPartition("user", "user-1"); + await sessionStore.SaveSessionAsync(agent, key, new TestSession()); + AgentSession? session = await sessionStore.GetSessionAsync(agent, key); using JsonDocument document = JsonDocument.Parse("""{"step":1}"""); CheckpointInfo checkpointInfo = await checkpointStore.CreateCheckpointAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs index 51f0e978746..63e1530c17d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/HostedSessionIdentityContextTests.cs @@ -123,7 +123,12 @@ public async Task Handler_ResumeSession_MatchingKeys_PassesAsync() // when it has a conversation id; here we plant it directly so we can drive a resume request). // The session is scoped to the same user ("alice") that will resume it. const string ConversationId = "resume-chat-id"; - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession, "alice", CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "alice"), + capturingAgent.LastSession, + CancellationToken.None); // Step 3: drive a resume request with the same isolation keys. var (resumeRequest, resumeContext) = BuildResumeRequest(ConversationId); @@ -151,12 +156,17 @@ public async Task Handler_ResumeSession_MismatchedUserId_Returns403Async() var (freshRequest, freshContext) = BuildFreshRequest(); await DrainAsync(aliceHandler.CreateAsync(freshRequest, freshContext.Object, CancellationToken.None)); const string ConversationId = "resume-chat-id"; + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); // Plant Alice's stamped session UNDER BOB'S partition to simulate a session that reached Bob's // key despite the per-user path partitioning (e.g. a non-partitioning custom store, or in-process // tampering). The 403 identity check is the defense-in-depth layer that must still reject it even // when the physical partition was bypassed. - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, capturingAgent.LastSession!, "bob", CancellationToken.None); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "bob"), + capturingAgent.LastSession!, + CancellationToken.None); // Bob attempts to resume Alice's conversation. var bobProvider = new FakeHostedSessionIsolationKeyProvider("bob"); @@ -181,7 +191,12 @@ public async Task Handler_ResumeSession_WithoutPriorContext_StampsAsFreshAsync() var sessionStore = new InMemoryAgentSessionStore(); const string ConversationId = "untagged-chat-id"; var untagged = await capturingAgent.CreateSessionAsync(CancellationToken.None); - await sessionStore.SaveSessionAsync(capturingAgent, ConversationId, untagged, "alice", CancellationToken.None); + AIAgent storageAgent = new FoundryHostingAgent(capturingAgent, "default"); + await sessionStore.SaveSessionAsync( + storageAgent, + new AgentSessionStoreKey(ConversationId).WithPartition("user", "alice"), + untagged, + CancellationToken.None); var fakeProvider = new FakeHostedSessionIsolationKeyProvider("alice"); var handler = BuildHandler(capturingAgent, fakeProvider, sessionStore); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs index 6c1bf39e809..98020b21e59 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/ResilientTwoLifetimeIntegrationTests.cs @@ -561,9 +561,8 @@ private sealed class PhaseObservingSessionStore( { public override async ValueTask SaveSessionAsync( AIAgent agent, - string conversationId, + AgentSessionStoreKey key, AgentSession session, - string? userId, CancellationToken cancellationToken = default) { JsonElement state = await agent.SerializeSessionAsync( @@ -572,9 +571,8 @@ public override async ValueTask SaveSessionAsync( coordinator.SerializedStates.Add(state.GetRawText()); await inner.SaveSessionAsync( agent, - conversationId, + key, session, - userId, cancellationToken); JsonProperty? phaseProperty = state @@ -592,13 +590,11 @@ await inner.SaveSessionAsync( public override ValueTask GetSessionAsync( AIAgent agent, - string conversationId, - string? userId, + AgentSessionStoreKey key, CancellationToken cancellationToken = default) => inner.GetSessionAsync( agent, - conversationId, - userId, + key, cancellationToken); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs index 48999255b2e..e4e4920a129 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -1792,17 +1792,17 @@ public async Task ExecuteAsync_Streaming_WithNullAdditionalProperties_ReturnsMes public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1826,7 +1826,7 @@ public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), It.IsAny()), Times.Once); @@ -1840,17 +1840,17 @@ public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesEmptyMessageAndSavesSessionAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1871,7 +1871,7 @@ public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesEmptyMessageAndSa mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), It.IsAny()), Times.Once); @@ -1962,17 +1962,17 @@ public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecut public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -1998,13 +1998,13 @@ public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() mockSessionStore.Verify( x => x.GetSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-1"), + It.Is(key => key.SessionId == "ctx-1"), It.IsAny()), Times.Once); mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-1"), + It.Is(key => key.SessionId == "ctx-1"), It.IsAny(), It.IsAny()), Times.Once); @@ -2169,12 +2169,19 @@ public async Task ExecuteAsync_OnContinuation_DoesNotInvokeDynamicModeCallbackAs public async Task ExecuteAsync_NonStreaming_WhenRunStreamingAsyncThrows_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2209,7 +2216,7 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2223,12 +2230,19 @@ await Assert.ThrowsAsync(() => public async Task ExecuteAsync_Streaming_WhenRunStreamingAsyncThrows_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2263,7 +2277,7 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2277,12 +2291,19 @@ await Assert.ThrowsAsync(() => public async Task ExecuteAsync_OnContinuation_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); Mock agentMock = new() { CallBase = true }; @@ -2323,7 +2344,7 @@ await Assert.ThrowsAsync(() => mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-cont"), + It.Is(key => key.SessionId == "ctx-cont"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2337,12 +2358,19 @@ await Assert.ThrowsAsync(() => public async Task ExecuteAsync_NonStreaming_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); @@ -2366,7 +2394,7 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx"), + It.Is(key => key.SessionId == "ctx"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2380,12 +2408,19 @@ await handler.ExecuteAsync( public async Task ExecuteAsync_Streaming_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponseUpdate[] updates = [new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }]; @@ -2409,7 +2444,7 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-stream"), + It.Is(key => key.SessionId == "ctx-stream"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); @@ -2423,12 +2458,19 @@ await handler.ExecuteAsync( public async Task ExecuteAsync_OnContinuation_SavesSessionWithUncancelledTokenAsync() { // Arrange - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(ValueTask.CompletedTask); AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); @@ -2457,7 +2499,7 @@ await handler.ExecuteAsync( mockSessionStore.Verify( x => x.SaveSessionAsync( It.IsAny(), - It.Is(s => s == "ctx-cont"), + It.Is(key => key.SessionId == "ctx-cont"), It.IsAny(), It.Is(ct => ct == CancellationToken.None)), Times.Once); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs index ce310dbec84..fb35a49abce 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -196,7 +196,7 @@ public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyA var services = new ServiceCollection(); services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; services.AddKeyedSingleton(AgentName, mockSessionStore.Object); // Act @@ -206,6 +206,7 @@ public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyA await using var provider = services.BuildServiceProvider(); var server = provider.GetKeyedService(AgentName); Assert.NotNull(server); + mockSessionStore.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Once); } /// @@ -423,17 +424,17 @@ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUs var services = new ServiceCollection(); services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); - var mockSessionStore = new Mock(); + var mockSessionStore = new Mock { CallBase = true }; mockSessionStore .Setup(x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny())) .ReturnsAsync(new TestAgentSession()); mockSessionStore .Setup(x => x.SaveSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .Returns(ValueTask.CompletedTask); @@ -452,7 +453,7 @@ public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUs mockSessionStore.Verify( x => x.GetSessionAsync( It.IsAny(), - It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index c8a43b41ae8..5c6367ef652 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -147,6 +147,7 @@ public void MapAGUIServer_WithAgent_ResolvesSessionStoreFromDI() Assert.NotNull(result); serviceProviderMock.As() .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once); + sessionStoreMock.Verify(s => s.GetService(typeof(IsolationKeyScopedAgentSessionStore), null), Times.Once); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs index 72972103607..a8ccb35eefd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobAgentSessionStoreTests.cs @@ -76,12 +76,14 @@ public async Task SaveAndGetSessionAsync_PersistsAcrossStoreAndAgentInstancesAsy var savingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); var loadingStore = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-1"); // Act - await savingStore.SaveSessionAsync(savingAgent, "session-1", session); - AgentSession restored = await loadingStore.GetSessionAsync(loadingAgent, "session-1"); + await savingStore.SaveSessionAsync(savingAgent, key, session); + AgentSession? restored = await loadingStore.GetSessionAsync(loadingAgent, key); // Assert + Assert.NotNull(restored); Assert.Equal("saved", restored.StateBag.GetValue("marker")); } @@ -94,6 +96,8 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() string commonPrefix = new('s', 2048); string firstId = commonPrefix + "\0:first"; string secondId = commonPrefix + "\u0001/second"; + var firstKey = new AgentSessionStoreKey(firstId); + var secondKey = new AgentSessionStoreKey(secondId); AgentSession firstSession = await agent.CreateSessionAsync(); firstSession.StateBag.SetValue("marker", "first"); @@ -101,10 +105,10 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() secondSession.StateBag.SetValue("marker", "second"); // Act - await store.SaveSessionAsync(agent, firstId, firstSession); - await store.SaveSessionAsync(agent, secondId, secondSession); - AgentSession restoredFirst = await store.GetSessionAsync(agent, firstId); - AgentSession restoredSecond = await store.GetSessionAsync(agent, secondId); + await store.SaveSessionAsync(agent, firstKey, firstSession); + await store.SaveSessionAsync(agent, secondKey, secondSession); + AgentSession? restoredFirst = await store.GetSessionAsync(agent, firstKey); + AgentSession? restoredSecond = await store.GetSessionAsync(agent, secondKey); List blobNames = []; await foreach (BlobItem blob in this._containerClient.GetBlobsAsync()) { @@ -112,6 +116,8 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() } // Assert + Assert.NotNull(restoredFirst); + Assert.NotNull(restoredSecond); Assert.Equal("first", restoredFirst.StateBag.GetValue("marker")); Assert.Equal("second", restoredSecond.StateBag.GetValue("marker")); Assert.Equal(2, blobNames.Count); @@ -119,22 +125,71 @@ public async Task SaveAndGetSessionAsync_SupportsDistinctLongOpaqueIdsAsync() } [Fact] - public async Task DeleteSessionAsync_RemovesStoredSessionAndIgnoresMissingSessionAsync() + public async Task GetSessionAsync_MissingSession_ReturnsNullAsync() { // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); - AgentSession session = await agent.CreateSessionAsync(); - session.StateBag.SetValue("marker", "saved"); - await store.SaveSessionAsync(agent, "session-to-delete", session); // Act - await store.DeleteSessionAsync(agent, "session-to-delete"); - AgentSession restored = await store.GetSessionAsync(agent, "session-to-delete"); - await store.DeleteSessionAsync(agent, "session-to-delete"); + AgentSession? restored = await store.GetSessionAsync( + agent, + new AgentSessionStoreKey("missing").WithPartition("user", "user-1")); + + // Assert + Assert.Null(restored); + } + + [Fact] + public async Task SaveAndGetSessionAsync_IsolatesPartitionsAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var user1Key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-1"); + var user2Key = new AgentSessionStoreKey("session-1").WithPartition("user", "user-2"); + AgentSession first = await agent.CreateSessionAsync(); + first.StateBag.SetValue("marker", "first"); + AgentSession second = await agent.CreateSessionAsync(); + second.StateBag.SetValue("marker", "second"); + + // Act + await store.SaveSessionAsync(agent, user1Key, first); + await store.SaveSessionAsync(agent, user2Key, second); + AgentSession? restoredFirst = await store.GetSessionAsync(agent, user1Key); + AgentSession? restoredSecond = await store.GetSessionAsync(agent, user2Key); + + // Assert + Assert.NotNull(restoredFirst); + Assert.NotNull(restoredSecond); + Assert.Equal("first", restoredFirst.StateBag.GetValue("marker")); + Assert.Equal("second", restoredSecond.StateBag.GetValue("marker")); + } + + [Fact] + public async Task SaveAndGetSessionAsync_ScopedAndUnscopedIdentifiersDoNotCollideAsync() + { + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var partitionedKey = new AgentSessionStoreKey("conversation").WithPartition("tenant", "tenant"); + var unpartitionedKey = new AgentSessionStoreKey("tenant::conversation"); + AgentSession scoped = await agent.CreateSessionAsync(); + scoped.StateBag.SetValue("marker", "scoped"); + AgentSession unscoped = await agent.CreateSessionAsync(); + unscoped.StateBag.SetValue("marker", "unscoped"); + + // Act + await store.SaveSessionAsync(agent, partitionedKey, scoped); + await store.SaveSessionAsync(agent, unpartitionedKey, unscoped); + AgentSession? restoredScoped = await store.GetSessionAsync(agent, partitionedKey); + AgentSession? restoredUnscoped = await store.GetSessionAsync(agent, unpartitionedKey); // Assert - Assert.Null(restored.StateBag.GetValue("marker")); + Assert.NotNull(restoredScoped); + Assert.NotNull(restoredUnscoped); + Assert.Equal("scoped", restoredScoped.StateBag.GetValue("marker")); + Assert.Equal("unscoped", restoredUnscoped.StateBag.GetValue("marker")); } [Fact] @@ -143,15 +198,16 @@ public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1"); AgentSession first = await agent.CreateSessionAsync(); first.StateBag.SetValue("marker", "first"); AgentSession second = await agent.CreateSessionAsync(); second.StateBag.SetValue("marker", "second"); // Act - await store.SaveSessionAsync(agent, "session-1", first); - await store.SaveSessionAsync(agent, "session-1", second); - AgentSession restored = await store.GetSessionAsync(agent, "session-1"); + await store.SaveSessionAsync(agent, key, first); + await store.SaveSessionAsync(agent, key, second); + AgentSession? restored = await store.GetSessionAsync(agent, key); List blobs = []; await foreach (BlobItem blob in this._containerClient.GetBlobsAsync()) { @@ -159,6 +215,7 @@ public async Task SaveSessionAsync_OverwritesExistingSessionAsJsonAsync() } // Assert + Assert.NotNull(restored); BlobItem storedBlob = Assert.Single(blobs); Assert.Equal("application/json", storedBlob.Properties.ContentType); Assert.Equal("second", restored.StateBag.GetValue("marker")); @@ -177,7 +234,7 @@ public async Task GetSessionAsync_MissingContainerWithoutAutoCreatePropagatesErr // Act RequestFailedException exception = await Assert.ThrowsAsync( - () => store.GetSessionAsync(agent, "session-1").AsTask()); + () => store.GetSessionAsync(agent, new AgentSessionStoreKey("session-1")).AsTask()); // Assert Assert.Equal(BlobErrorCode.ContainerNotFound.ToString(), exception.ErrorCode); @@ -189,17 +246,21 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshotsAsync() // Arrange AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new AzureBlobAgentSessionStore(this._containerClient, "assistant"); + var key = new AgentSessionStoreKey("session-1"); AgentSession original = await agent.CreateSessionAsync(); original.StateBag.SetValue("marker", "saved"); - await store.SaveSessionAsync(agent, "session-1", original); + await store.SaveSessionAsync(agent, key, original); // Act - AgentSession first = await store.GetSessionAsync(agent, "session-1"); - AgentSession second = await store.GetSessionAsync(agent, "session-1"); + AgentSession? first = await store.GetSessionAsync(agent, key); + AgentSession? second = await store.GetSessionAsync(agent, key); + Assert.NotNull(first); + Assert.NotNull(second); first.StateBag.SetValue("marker", "changed"); - AgentSession third = await store.GetSessionAsync(agent, "session-1"); + AgentSession? third = await store.GetSessionAsync(agent, key); // Assert + Assert.NotNull(third); Assert.NotSame(first, second); Assert.Equal("saved", second.StateBag.GetValue("marker")); Assert.Equal("saved", third.StateBag.GetValue("marker")); @@ -217,7 +278,10 @@ public async Task SaveSessionAsync_ConcurrentFirstWritesCreateContainerSafelyAsy { AgentSession session = await agent.CreateSessionAsync(); session.StateBag.SetValue("marker", index.ToString()); - writes.Add(store.SaveSessionAsync(agent, $"session-{index}", session).AsTask()); + writes.Add(store.SaveSessionAsync( + agent, + new AgentSessionStoreKey($"session-{index}"), + session).AsTask()); } // Act @@ -277,6 +341,17 @@ public void Constructor_BlobNamePrefixExceedsAzureLimit_Throws() () => new AzureBlobAgentSessionStore(this._containerClient, "assistant", options)); } + [Fact] + public void Constructor_InvalidUtf16AgentNamespace_Throws() + { + // Arrange + string invalid = new((char)0xD800, 1); + + // Act and assert + Assert.Throws( + () => new AzureBlobAgentSessionStore(this._containerClient, invalid)); + } + private static async Task IsAzuriteAvailableAsync() { using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromSeconds(3)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobHostedAgentBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobHostedAgentBuilderExtensionsTests.cs index 4d57d7c79ec..115d25994cd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobHostedAgentBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureStorage.UnitTests/AzureBlobHostedAgentBuilderExtensionsTests.cs @@ -33,7 +33,6 @@ public void WithAzureBlobSessionStore_RegistersSingletonWithIsolation() service.ServiceKey as string == "assistant"); Assert.Equal(ServiceLifetime.Singleton, descriptor.Lifetime); Assert.IsType(store); - Assert.NotNull(store.GetService()); } [Fact] diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs index 5c8cf78df3b..6c9ba0428d7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/AnthropicResponsesHostingLiveTests.cs @@ -38,7 +38,7 @@ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() // Act OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); @@ -62,7 +62,7 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; - AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(secondSessionStoreId)); AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); // Assert: continuation succeeded and the model produced a textual answer. @@ -75,10 +75,10 @@ private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore JsonElement body = ParseBody(bodyJson); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await sessionStore.SaveSessionAsync(agent, responseId, session); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(responseId), session); return responseId; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs index 1aa58c177dc..8452802ae93 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests/OpenAIResponsesHostingLiveTests.cs @@ -38,7 +38,7 @@ public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync() // Act OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options); JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId); @@ -62,7 +62,7 @@ public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync() JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }"""); OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody); string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!; - AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(secondSessionStoreId)); AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options); // Assert: continuation succeeded and the model produced a textual answer. @@ -75,10 +75,10 @@ private static async Task RunTurnAsync(AIAgent agent, AgentSessionStore JsonElement body = ParseBody(bodyJson); OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body); string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId); + AgentSession session = await sessionStore.GetOrCreateSessionAsync(agent, new AgentSessionStoreKey(sessionStoreId)); string responseId = OpenAIResponses.CreateResponseId(); _ = await agent.RunAsync(run.Messages, session, run.Options); - await sessionStore.SaveSessionAsync(agent, responseId, session); + await sessionStore.SaveSessionAsync(agent, new AgentSessionStoreKey(responseId), session); return responseId; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs index 8baa60598c2..8e23b52ea87 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesHostingTests.cs @@ -216,7 +216,10 @@ private async Task StartAgentHostAsync(IChatClient chatClient) } string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId(); - AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId, ct); + AgentSession session = await sessionStore.GetOrCreateSessionAsync( + agent, + new AgentSessionStoreKey(sessionStoreId), + ct); string responseId = OpenAIResponses.CreateResponseId(); // A stable conversation id is a mutable head (write back under the same id); a previous_response_id @@ -234,12 +237,20 @@ private async Task StartAgentHostAsync(IChatClient chatClient) await http.Response.WriteAsync(frame, ct); } - await sessionStore.SaveSessionAsync(agent, saveId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + new AgentSessionStoreKey(saveId), + session, + ct); return Results.Empty; } AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options, ct); - await sessionStore.SaveSessionAsync(agent, saveId, session, ct); + await sessionStore.SaveSessionAsync( + agent, + new AgentSessionStoreKey(saveId), + session, + ct); return Results.Json(OpenAIResponses.WriteResponse(result, responseId, responseId)); }); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentityAgentIsolationKeyProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentityAgentIsolationKeyProviderTests.cs index f62c62e7bd4..d4fb214506c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentityAgentIsolationKeyProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/ClaimsIdentityAgentIsolationKeyProviderTests.cs @@ -233,10 +233,10 @@ public async Task GetIsolationKeyAsyncReturnsFirstMatchingClaimAsync() } /// - /// Verify that GetIsolationKeyAsync handles empty claim values. + /// Verify that GetIsolationKeyAsync rejects empty claim values. /// [Fact] - public async Task GetIsolationKeyAsyncHandlesEmptyClaimValueAsync() + public async Task GetIsolationKeyAsyncReturnsNullForEmptyClaimValueAsync() { // Arrange this.SetupHttpContextWithClaim(ClaimTypes.NameIdentifier, string.Empty); @@ -246,7 +246,7 @@ public async Task GetIsolationKeyAsyncHandlesEmptyClaimValueAsync() string? result = await provider.GetIsolationKeyAsync(); // Assert - Assert.Equal(string.Empty, result); + Assert.Null(result); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs deleted file mode 100644 index e5f452aa795..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/DelegatingAgentSessionStoreTests.cs +++ /dev/null @@ -1,403 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Moq; - -namespace Microsoft.Agents.AI.Hosting.UnitTests; - -/// -/// Unit tests for the class. -/// -public class DelegatingAgentSessionStoreTests -{ - private readonly Mock _innerStoreMock; - private readonly Mock _agentMock; - private readonly TestDelegatingAgentSessionStore _delegatingStore; - private readonly AgentSession _testSession; - - /// - /// Initializes a new instance of the class. - /// - public DelegatingAgentSessionStoreTests() - { - this._innerStoreMock = new Mock(); - this._agentMock = new Mock(); - this._testSession = new TestAgentSession(); - - // Setup inner store mock - this._innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(this._testSession); - - this._innerStoreMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); - - this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); - } - - #region Constructor Tests - - /// - /// Verify that constructor throws ArgumentNullException when innerStore is null. - /// - [Fact] - public void RequiresInnerStore() => - // Act & Assert - Assert.Throws("innerStore", () => new TestDelegatingAgentSessionStore(null!)); - - /// - /// Verify that constructor sets the inner store correctly. - /// - [Fact] - public void Constructor_WithValidInnerStore_SetsInnerStore() - { - // Act - var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); - - // Assert - Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore); - } - - #endregion - - #region Method Delegation Tests - - /// - /// Verify that GetSessionAsync delegates to inner store with correct parameters. - /// - [Fact] - public async Task GetSessionAsyncDelegatesToInnerStoreAsync() - { - // Arrange - const string ExpectedConversationId = "test-conversation-id"; - var expectedCancellationToken = new CancellationToken(); - - this._innerStoreMock - .Setup(x => x.GetSessionAsync( - It.Is(a => a == this._agentMock.Object), - It.Is(c => c == ExpectedConversationId), - It.Is(ct => ct == expectedCancellationToken))) - .ReturnsAsync(this._testSession); - - // Act - var session = await this._delegatingStore.GetSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - expectedCancellationToken); - - // Assert - Assert.Same(this._testSession, session); - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - expectedCancellationToken), - Times.Once); - } - - /// - /// Verify that SaveSessionAsync delegates to inner store with correct parameters. - /// - [Fact] - public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() - { - // Arrange - const string ExpectedConversationId = "test-conversation-id"; - var expectedCancellationToken = new CancellationToken(); - var expectedSession = new TestAgentSession(); - - this._innerStoreMock - .Setup(x => x.SaveSessionAsync( - It.Is(a => a == this._agentMock.Object), - It.Is(c => c == ExpectedConversationId), - It.Is(s => s == expectedSession), - It.Is(ct => ct == expectedCancellationToken))) - .Returns(ValueTask.CompletedTask); - - // Act - await this._delegatingStore.SaveSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - expectedSession, - expectedCancellationToken); - - // Assert - this._innerStoreMock.Verify( - x => x.SaveSessionAsync( - this._agentMock.Object, - ExpectedConversationId, - expectedSession, - expectedCancellationToken), - Times.Once); - } - - /// - /// Verify that GetSessionAsync awaits the inner store's result before returning. - /// - [Fact] - public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() - { - // Arrange - const string ExpectedConversationId = "test-conversation-id"; - var taskCompletionSource = new TaskCompletionSource(); - - var innerStoreMock = new Mock(); - innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new ValueTask(taskCompletionSource.Task)); - - var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); - - // Act - var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, ExpectedConversationId); - - // Assert - Assert.False(resultTask.IsCompleted); - taskCompletionSource.SetResult(this._testSession); - Assert.True(resultTask.IsCompleted); - Assert.Same(this._testSession, await resultTask); - } - - /// - /// Verify that SaveSessionAsync awaits the inner store's completion before returning. - /// - [Fact] - public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() - { - // Arrange - const string ExpectedConversationId = "test-conversation-id"; - var expectedSession = new TestAgentSession(); - var taskCompletionSource = new TaskCompletionSource(); - - var innerStoreMock = new Mock(); - innerStoreMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new ValueTask(taskCompletionSource.Task)); - - var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); - - // Act - var resultTask = delegatingStore.SaveSessionAsync(this._agentMock.Object, ExpectedConversationId, expectedSession); - - // Assert - Assert.False(resultTask.IsCompleted); - taskCompletionSource.SetResult(); - Assert.True(resultTask.IsCompleted); - await resultTask; - } - - #endregion - - #region GetService Tests - - /// - /// Verify that GetService returns itself when requesting the exact type. - /// - [Fact] - public void GetServiceReturnsItselfForExactType() - { - // Act - var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore)); - - // Assert - Assert.Same(this._delegatingStore, result); - } - - /// - /// Verify that GetService returns itself when requesting a base type. - /// - [Fact] - public void GetServiceReturnsItselfForBaseType() - { - // Act - var result = this._delegatingStore.GetService(typeof(DelegatingAgentSessionStore)); - - // Assert - Assert.Same(this._delegatingStore, result); - } - - /// - /// Verify that GetService returns itself when requesting AgentSessionStore. - /// - [Fact] - public void GetServiceReturnsItselfForAgentSessionStoreType() - { - // Act - var result = this._delegatingStore.GetService(typeof(AgentSessionStore)); - - // Assert - Assert.Same(this._delegatingStore, result); - } - - /// - /// Verify that GetService chains to inner store when type is not satisfied by outer store. - /// - [Fact] - public void GetServiceChainsToInnerStore() - { - // Arrange - var innerStore = new ConcreteAgentSessionStore(); - var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); - - // Act - var result = delegatingStore.GetService(typeof(ConcreteAgentSessionStore)); - - // Assert - Assert.Same(innerStore, result); - } - - /// - /// Verify that GetService chains through multiple delegation layers. - /// - [Fact] - public void GetServiceChainsThoughMultipleDelegationLayers() - { - // Arrange - create a three-layer chain: outer -> middle -> inner - var innerStore = new ConcreteAgentSessionStore(); - var middleStore = new AnotherDelegatingAgentSessionStore(innerStore); - var outerStore = new TestDelegatingAgentSessionStore(middleStore); - - // Act - request the innermost store type - var result = outerStore.GetService(typeof(ConcreteAgentSessionStore)); - - // Assert - Assert.Same(innerStore, result); - } - - /// - /// Verify that GetService can find a store in the middle of the delegation chain. - /// - [Fact] - public void GetServiceFindsMiddleStoreInChain() - { - // Arrange - create a three-layer chain: outer -> middle -> inner - var innerStore = new ConcreteAgentSessionStore(); - var middleStore = new AnotherDelegatingAgentSessionStore(innerStore); - var outerStore = new TestDelegatingAgentSessionStore(middleStore); - - // Act - request the middle store type - var result = outerStore.GetService(typeof(AnotherDelegatingAgentSessionStore)); - - // Assert - Assert.Same(middleStore, result); - } - - /// - /// Verify that GetService returns null when the requested type is not found in the chain. - /// - [Fact] - public void GetServiceReturnsNullWhenTypeNotFound() - { - // Arrange - var innerStore = new ConcreteAgentSessionStore(); - var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); - - // Act - var result = delegatingStore.GetService(typeof(string)); - - // Assert - Assert.Null(result); - } - - /// - /// Verify that GetService returns null when a service key is provided but not matched. - /// - [Fact] - public void GetServiceReturnsNullWhenServiceKeyProvided() - { - // Act - var result = this._delegatingStore.GetService(typeof(TestDelegatingAgentSessionStore), "some-key"); - - // Assert - Assert.Null(result); - } - - /// - /// Verify that GetService throws ArgumentNullException when serviceType is null. - /// - [Fact] - public void GetServiceThrowsWhenServiceTypeIsNull() => - Assert.Throws("serviceType", () => this._delegatingStore.GetService(null!)); - - /// - /// Verify that GetService generic method works correctly. - /// - [Fact] - public void GetServiceGenericReturnsItself() - { - // Act - var result = this._delegatingStore.GetService(); - - // Assert - Assert.Same(this._delegatingStore, result); - } - - /// - /// Verify that GetService generic method chains to inner store. - /// - [Fact] - public void GetServiceGenericChainsToInnerStore() - { - // Arrange - var innerStore = new ConcreteAgentSessionStore(); - var delegatingStore = new TestDelegatingAgentSessionStore(innerStore); - - // Act - var result = delegatingStore.GetService(); - - // Assert - Assert.Same(innerStore, result); - } - - /// - /// Verify that GetService generic method returns null when type not found. - /// - [Fact] - public void GetServiceGenericReturnsNullWhenTypeNotFound() - { - // Act - var result = this._delegatingStore.GetService(); - - // Assert - Assert.Null(result); - } - - #endregion - - #region Test Implementation - - /// - /// Test implementation of DelegatingAgentSessionStore for testing purposes. - /// - private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore) - { - public new AgentSessionStore InnerStore => base.InnerStore; - } - - /// - /// Another delegating store implementation for testing multi-layer chains. - /// - private sealed class AnotherDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore); - - /// - /// Concrete (non-delegating) session store for testing GetService chaining. - /// - private sealed class ConcreteAgentSessionStore : AgentSessionStore - { - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => new(new TestAgentSession()); - - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - } - - private sealed class TestAgentSession : AgentSession; - - #endregion -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs index eb482964b0b..40a3434cb09 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/HostedAgentBuilderToolsExtensionsTests.cs @@ -424,6 +424,69 @@ public void WithSessionStoreFactory_ExplicitLifetimeOverridesDefault() Assert.Equal(ServiceLifetime.Singleton, storeDescriptor.Lifetime); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WithSessionStore_DecoratedIsolation_PreservesConfiguredStoreAsync(bool useFactory) + { + // Arrange + var services = new ServiceCollection(); + var agent = new Mock().Object; + var builder = services.AddAIAgent("test-agent", (sp, name) => agent); + var provider = new Mock(); + provider.Setup(p => p.GetIsolationKeyAsync(It.IsAny())).ReturnsAsync("tenant-1"); + var innerStore = new Mock(); + var session = new TestAgentSession(); + innerStore.Setup(s => s.GetSessionAsync(agent, It.IsAny(), It.IsAny())) + .ReturnsAsync(session); + var isolation = new IsolationKeyScopedAgentSessionStore(innerStore.Object, provider.Object); + var decoratedStore = new TestDelegatingAgentSessionStore(new TestDelegatingAgentSessionStore(isolation)); + if (useFactory) + { + builder.WithSessionStore((sp, name) => decoratedStore); + } + else + { + builder.WithSessionStore(decoratedStore); + } + + using var servicesProvider = services.BuildServiceProvider(); + + // Act + var resolvedStore = servicesProvider.GetRequiredKeyedService("test-agent"); + var key = new AgentSessionStoreKey("session-1").WithPartition("region", "west"); + var result = await resolvedStore.GetSessionAsync(agent, key); + + // Assert + Assert.Same(decoratedStore, resolvedStore); + Assert.Same(isolation, resolvedStore.GetService()); + Assert.Same(session, result); + innerStore.Verify(s => s.GetSessionAsync( + agent, key.WithPartition("isolation", "tenant-1"), It.IsAny()), Times.Once); + } + + [Fact] + public void WithSessionStore_WithoutExistingIsolation_AddsIsolation() + { + // Arrange + var services = new ServiceCollection(); + var builder = services.AddAIAgent("test-agent", (sp, name) => new Mock().Object); + var store = new InMemoryAgentSessionStore(); + builder.WithSessionStore(store); + using var provider = services.BuildServiceProvider(); + + // Act + var resolvedStore = provider.GetRequiredKeyedService("test-agent"); + + // Assert + Assert.IsType(resolvedStore); + Assert.Same(store, resolvedStore.GetService()); + } + + private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore); + + private sealed class TestAgentSession : AgentSession; + /// /// Dummy AITool implementation for testing. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs index 8af3fc43ece..694053273fe 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/InMemoryAgentSessionStoreTests.cs @@ -2,76 +2,30 @@ using System; using System.Collections.Generic; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Moq; -using Moq.Protected; namespace Microsoft.Agents.AI.Hosting.UnitTests; /// -/// Unit tests for across the in-box stores. +/// Unit tests for the in-box session stores. /// public class InMemoryAgentSessionStoreTests { [Fact] - public async Task DeleteSessionAsync_RemovesStoredSession_SoNextGetCreatesAsync() - { - // Arrange - var stored = JsonSerializer.SerializeToElement(new { marker = "stored" }); - var restoredSession = new TestAgentSession(); - var createdSession = new TestAgentSession(); - var agent = new Mock(); - agent.Protected() - .Setup>("SerializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) - .Returns(new ValueTask(stored)); - agent.Protected() - .Setup>("DeserializeSessionCoreAsync", ItExpr.IsAny(), ItExpr.IsAny(), ItExpr.IsAny()) - .Returns(new ValueTask(restoredSession)); - agent.Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .Returns(new ValueTask(createdSession)); - - var store = new InMemoryAgentSessionStore(); - - // Act & Assert - await store.SaveSessionAsync(agent.Object, "s1", new TestAgentSession()); - Assert.Same(restoredSession, await store.GetSessionAsync(agent.Object, "s1")); - - await store.DeleteSessionAsync(agent.Object, "s1"); - Assert.Same(createdSession, await store.GetSessionAsync(agent.Object, "s1")); - } - - [Fact] - public async Task DeleteSessionAsync_UnknownId_DoesNotThrowAsync() + public async Task GetSessionAsync_MissingSession_ReturnsNullAsync() { // Arrange var store = new InMemoryAgentSessionStore(); + var agent = new Mock(); - // Act & Assert (no exception) - await store.DeleteSessionAsync(new Mock().Object, "missing"); - } - - [Fact] - public async Task DeleteSessionAsync_NoopStore_CompletesAsync() - { - // Arrange - var store = new NoopAgentSessionStore(); - - // Act & Assert (no exception) - await store.DeleteSessionAsync(new Mock().Object, "any"); - } - - [Fact] - public async Task DeleteSessionAsync_StoreOptsOut_ThrowsNotSupportedAsync() - { - // Arrange: a store that chooses not to support deletion throws NotSupportedException itself. - AgentSessionStore store = new ConcreteAgentSessionStore(); + // Act + AgentSession? session = await store.GetSessionAsync(agent.Object, new AgentSessionStoreKey("missing")); - // Act & Assert - await Assert.ThrowsAsync(() => store.DeleteSessionAsync(new Mock().Object, "any").AsTask()); + // Assert + Assert.Null(session); } [Fact] @@ -81,16 +35,19 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranch // and a stored session that carries some state to copy. AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); var store = new InMemoryAgentSessionStore(); + var key = new AgentSessionStoreKey("s1").WithPartition("user", "user-1"); AgentSession original = await agent.CreateSessionAsync(); original.StateBag.SetValue("marker", "v1"); - await store.SaveSessionAsync(agent, "s1", original); + await store.SaveSessionAsync(agent, key, original); // Act: two concurrent branches read the same stored id. - AgentSession branchA = await store.GetSessionAsync(agent, "s1"); - AgentSession branchB = await store.GetSessionAsync(agent, "s1"); + AgentSession? branchA = await store.GetSessionAsync(agent, key); + AgentSession? branchB = await store.GetSessionAsync(agent, key); // Assert: each branch is an independent instance carrying the same content. + Assert.NotNull(branchA); + Assert.NotNull(branchB); Assert.NotSame(branchA, branchB); Assert.Equal("v1", branchA.StateBag.GetValue("marker")); Assert.Equal("v1", branchB.StateBag.GetValue("marker")); @@ -99,22 +56,31 @@ public async Task GetSessionAsync_ReturnsIndependentSnapshot_ForConcurrentBranch branchA.StateBag.SetValue("marker", "mutated"); Assert.Equal("v1", branchB.StateBag.GetValue("marker")); - AgentSession branchC = await store.GetSessionAsync(agent, "s1"); + AgentSession? branchC = await store.GetSessionAsync(agent, key); + Assert.NotNull(branchC); Assert.Equal("v1", branchC.StateBag.GetValue("marker")); } - private sealed class TestAgentSession : AgentSession; - - private sealed class ConcreteAgentSessionStore : AgentSessionStore + [Fact] + public async Task GetSessionAsync_DifferentUsers_AreIsolatedAsync() { - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => default; - - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => new(new TestAgentSession()); - - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); + // Arrange + AIAgent agent = new ChatClientAgent(new NotInvokedChatClient(), name: "assistant"); + var store = new InMemoryAgentSessionStore(); + var user1Key = new AgentSessionStoreKey("s1").WithPartition("user", "user-1"); + var user2Key = new AgentSessionStoreKey("s1").WithPartition("user", "user-2"); + AgentSession session = await agent.CreateSessionAsync(); + session.StateBag.SetValue("marker", "user-1"); + await store.SaveSessionAsync(agent, user1Key, session); + + // Act + AgentSession? matchingUser = await store.GetSessionAsync(agent, user1Key); + AgentSession? differentUser = await store.GetSessionAsync(agent, user2Key); + + // Assert + Assert.NotNull(matchingUser); + Assert.Equal("user-1", matchingUser.StateBag.GetValue("marker")); + Assert.Null(differentUser); } // A chat client that is never invoked: these tests only create, serialize, and deserialize sessions. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs index 2521d06a113..e9bdccbac6f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.UnitTests/IsolationKeyScopedAgentSessionStoreTests.cs @@ -13,421 +13,169 @@ namespace Microsoft.Agents.AI.Hosting.UnitTests; public class IsolationKeyScopedAgentSessionStoreTests { private const string TestIsolationKey = "test-key"; - private const string TestConversationId = "test-conversation-id"; - private readonly Mock _innerStoreMock; - private readonly Mock _agentMock; - private readonly AgentSession _testSession; + private readonly Mock _innerStoreMock = new(); + private readonly Mock _agentMock = new(); - /// - /// Initializes a new instance of the class. - /// - public IsolationKeyScopedAgentSessionStoreTests() - { - this._innerStoreMock = new Mock(); - this._agentMock = new Mock(); - this._testSession = new TestAgentSession(); - - this._innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(this._testSession); - - this._innerStoreMock - .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); - } - - #region Constructor Tests - - /// - /// Verify that constructor throws ArgumentNullException when innerStore is null. - /// [Fact] public void RequiresInnerStore() { // Arrange var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - // Act & Assert + // Act and assert Assert.Throws("innerStore", () => new IsolationKeyScopedAgentSessionStore(null!, provider)); } - /// - /// Verify that constructor uses default options when options is null. - /// [Fact] - public void UsesDefaultOptionsWhenNull() + public async Task GetSessionAsync_AddsIsolationPartitionAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - - // Act & Assert - should not throw - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider, options: null); - Assert.NotNull(store); - } - - #endregion - - #region GetSessionAsync Tests - - /// - /// Verify that GetSessionAsync scopes the conversation ID with the isolation key. - /// - [Fact] - public async Task GetSessionAsyncScopesConversationIdWithKeyAsync() - { - // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + var expectedSession = new TestAgentSession(); + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + this._agentMock.Object, + It.Is(actual => + actual.SessionId == "session-1" + && actual.Partitions != null + && actual.Partitions["tenant"] == "tenant-1" + && actual.Partitions["isolation"] == TestIsolationKey), + It.IsAny())) + .ReturnsAsync(expectedSession); + var store = this.CreateStore(TestIsolationKey); // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + AgentSession? session = await store.GetSessionAsync(this._agentMock.Object, key); // Assert - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"{TestIsolationKey}::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that GetSessionAsync throws InvalidOperationException when key is null in strict mode. - /// - [Fact] - public async Task GetSessionAsyncThrowsWhenKeyNullInStrictModeAsync() - { - // Arrange - var provider = new TestAgentIsolationKeyProvider(null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - provider, - new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - async () => await store.GetSessionAsync(this._agentMock.Object, TestConversationId)); - - Assert.Contains("Agent isolation key is required", exception.Message); + Assert.Same(expectedSession, session); + this._innerStoreMock.VerifyAll(); } - /// - /// Verify that GetSessionAsync does not throw when key is null in non-strict mode. - /// [Fact] - public async Task GetSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() + public async Task SaveSessionAsync_AddsIsolationPartitionAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - provider, - new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); - - // Act - should not throw - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - conversation ID should be passed through unmodified - this._innerStoreMock.Verify( - x => x.GetSessionAsync( + var key = new AgentSessionStoreKey("session-1"); + var session = new TestAgentSession(); + this._innerStoreMock + .Setup(x => x.SaveSessionAsync( this._agentMock.Object, - TestConversationId, - It.IsAny()), - Times.Once); - } - - /// - /// Verify that GetSessionAsync returns the session from the inner store. - /// - [Fact] - public async Task GetSessionAsyncReturnsSessionFromInnerStoreAsync() - { - // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + It.Is(actual => + actual.SessionId == "session-1" + && actual.Partitions != null + && actual.Partitions["isolation"] == TestIsolationKey), + session, + It.IsAny())) + .Returns(ValueTask.CompletedTask); + var store = this.CreateStore(TestIsolationKey); // Act - var result = await store.GetSessionAsync(this._agentMock.Object, TestConversationId); + await store.SaveSessionAsync(this._agentMock.Object, key, session); // Assert - Assert.Same(this._testSession, result); + this._innerStoreMock.VerifyAll(); } - #endregion - - #region SaveSessionAsync Tests - - /// - /// Verify that SaveSessionAsync scopes the conversation ID with the isolation key. - /// [Fact] - public async Task SaveSessionAsyncScopesConversationIdWithKeyAsync() + public async Task GetOrCreateSessionAsync_ForwardsScopedKeyToSpecializedInnerStoreAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); - var sessionToSave = new TestAgentSession(); + var expectedSession = new TestAgentSession(); + var key = new AgentSessionStoreKey("session-1"); + this._innerStoreMock + .Setup(x => x.GetOrCreateSessionAsync( + this._agentMock.Object, + It.Is(actual => + actual.Partitions != null && actual.Partitions["isolation"] == TestIsolationKey), + It.IsAny())) + .ReturnsAsync(expectedSession); + var store = this.CreateStore(TestIsolationKey); // Act - await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); + AgentSession session = await store.GetOrCreateSessionAsync(this._agentMock.Object, key); // Assert - this._innerStoreMock.Verify( - x => x.SaveSessionAsync( - this._agentMock.Object, - $"{TestIsolationKey}::{TestConversationId}", - sessionToSave, - It.IsAny()), - Times.Once); + Assert.Same(expectedSession, session); + this._innerStoreMock.VerifyAll(); } - /// - /// Verify that SaveSessionAsync throws InvalidOperationException when key is null in strict mode. - /// [Fact] - public async Task SaveSessionAsyncThrowsWhenKeyNullInStrictModeAsync() + public async Task GetSessionAsync_StrictModeWithoutIsolationKey_ThrowsAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - provider, + var store = this.CreateStore( + isolationKey: null, new IsolationKeyScopedAgentSessionStoreOptions { Strict = true }); - var sessionToSave = new TestAgentSession(); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - async () => await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave)); - - Assert.Contains("Agent isolation key is required", exception.Message); - } - - /// - /// Verify that SaveSessionAsync does not throw when key is null in non-strict mode. - /// - [Fact] - public async Task SaveSessionAsyncDoesNotThrowWhenKeyNullInNonStrictModeAsync() - { - // Arrange - var provider = new TestAgentIsolationKeyProvider(null); - var store = new IsolationKeyScopedAgentSessionStore( - this._innerStoreMock.Object, - provider, - new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); - var sessionToSave = new TestAgentSession(); - - // Act - should not throw - await store.SaveSessionAsync(this._agentMock.Object, TestConversationId, sessionToSave); - - // Assert - conversation ID should be passed through unmodified - this._innerStoreMock.Verify( - x => x.SaveSessionAsync( - this._agentMock.Object, - TestConversationId, - sessionToSave, - It.IsAny()), - Times.Once); - } - - #endregion - - #region Escaping Tests - - /// - /// Verify that colons in the isolation key are escaped. - /// - [Fact] - public async Task EscapesColonsInIsolationKeyAsync() - { - // Arrange - const string KeyWithColon = "key:with:colons"; - var provider = new TestAgentIsolationKeyProvider(KeyWithColon); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - colons should be escaped as \: - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"key\\:with\\:colons::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that backslashes in the isolation key are escaped. - /// - [Fact] - public async Task EscapesBackslashesInIsolationKeyAsync() - { - // Arrange - const string KeyWithBackslash = @"domain\key"; - var provider = new TestAgentIsolationKeyProvider(KeyWithBackslash); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - backslashes should be escaped as \\ - this._innerStoreMock.Verify( - x => x.GetSessionAsync( - this._agentMock.Object, - $"domain\\\\key::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - /// - /// Verify that both backslashes and colons in the isolation key are escaped correctly. - /// - [Fact] - public async Task EscapesBothBackslashesAndColonsInIsolationKeyAsync() - { - // Arrange - const string KeyWithBoth = @"domain\key:role"; - var provider = new TestAgentIsolationKeyProvider(KeyWithBoth); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); - - // Act - await store.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Assert - backslashes escaped first, then colons - this._innerStoreMock.Verify( - x => x.GetSessionAsync( + var exception = await Assert.ThrowsAsync( + () => store.GetSessionAsync( this._agentMock.Object, - $"domain\\\\key\\:role::{TestConversationId}", - It.IsAny()), - Times.Once); - } - - #endregion - - #region Isolation Tests - - /// - /// Verify that different isolation keys result in different scoped conversation IDs. - /// - [Fact] - public async Task DifferentKeysResultInDifferentScopedConversationIdsAsync() - { - // Arrange - const string Key1 = "key-1"; - const string Key2 = "key-2"; - string? capturedConversationId1 = null; - string? capturedConversationId2 = null; - - this._innerStoreMock - .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, conversationId, _) => - { - if (capturedConversationId1 == null) - { - capturedConversationId1 = conversationId; - } - else - { - capturedConversationId2 = conversationId; - } - }) - .ReturnsAsync(this._testSession); - - // Act - Key 1 - var provider1 = new TestAgentIsolationKeyProvider(Key1); - var store1 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider1); - await store1.GetSessionAsync(this._agentMock.Object, TestConversationId); - - // Act - Key 2 - var provider2 = new TestAgentIsolationKeyProvider(Key2); - var store2 = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider2); - await store2.GetSessionAsync(this._agentMock.Object, TestConversationId); + new AgentSessionStoreKey("session-1")).AsTask()); // Assert - Assert.Equal($"{Key1}::{TestConversationId}", capturedConversationId1); - Assert.Equal($"{Key2}::{TestConversationId}", capturedConversationId2); - Assert.NotEqual(capturedConversationId1, capturedConversationId2); + Assert.Contains("Agent isolation key is required", exception.Message); } - #endregion - - #region GetService Tests - - /// - /// Verify that GetService can retrieve IsolationKeyScopedAgentSessionStore from a delegation chain. - /// [Fact] - public void GetServiceReturnsIsolationKeyScopedAgentSessionStore() + public async Task GetSessionAsync_NonStrictModePreservesExistingPartitionsAsync() { // Arrange - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(this._innerStoreMock.Object, provider); + var key = new AgentSessionStoreKey("session-1").WithPartition("tenant", "tenant-1"); + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + this._agentMock.Object, + key, + It.IsAny())) + .ReturnsAsync((AgentSession?)null); + var store = this.CreateStore( + isolationKey: null, + new IsolationKeyScopedAgentSessionStoreOptions { Strict = false }); // Act - var result = store.GetService(); + await store.GetSessionAsync(this._agentMock.Object, key); // Assert - Assert.Same(store, result); + this._innerStoreMock.VerifyAll(); } - /// - /// Verify that GetService chains through to find inner store types. - /// [Fact] - public void GetServiceChainsToInnerStore() + public async Task GetSessionAsync_IsolationProviderReplacesExistingIsolationPartitionAsync() { // Arrange - var concreteInnerStore = new ConcreteAgentSessionStore(); - var provider = new TestAgentIsolationKeyProvider(TestIsolationKey); - var store = new IsolationKeyScopedAgentSessionStore(concreteInnerStore, provider); + var key = new AgentSessionStoreKey("session-1").WithPartition("isolation", "caller-value"); + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + this._agentMock.Object, + It.Is(actual => + actual.Partitions != null && actual.Partitions["isolation"] == TestIsolationKey), + It.IsAny())) + .ReturnsAsync((AgentSession?)null); + var store = this.CreateStore(TestIsolationKey); // Act - var result = store.GetService(); + await store.GetSessionAsync(this._agentMock.Object, key); // Assert - Assert.Same(concreteInnerStore, result); + this._innerStoreMock.VerifyAll(); } - #endregion - - #region Helper Classes + private IsolationKeyScopedAgentSessionStore CreateStore( + string? isolationKey, + IsolationKeyScopedAgentSessionStoreOptions? options = null) + => new( + this._innerStoreMock.Object, + new TestAgentIsolationKeyProvider(isolationKey), + options); - /// - /// Test implementation of for testing purposes. - /// - private sealed class TestAgentIsolationKeyProvider : AgentIsolationKeyProvider + private sealed class TestAgentIsolationKeyProvider(string? key) : AgentIsolationKeyProvider { - private readonly string? _key; - - public TestAgentIsolationKeyProvider(string? key) - { - this._key = key; - } - public override ValueTask GetIsolationKeyAsync(CancellationToken cancellationToken = default) - { - return new ValueTask(this._key); - } + => new(key); } private sealed class TestAgentSession : AgentSession; - - /// - /// Concrete (non-delegating) session store for testing GetService chaining. - /// - private sealed class ConcreteAgentSessionStore : AgentSessionStore - { - public override ValueTask GetSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => new(new TestAgentSession()); - - public override ValueTask SaveSessionAsync(AIAgent agent, string sessionStoreId, AgentSession session, CancellationToken cancellationToken = default) - => ValueTask.CompletedTask; - - public override ValueTask DeleteSessionAsync(AIAgent agent, string sessionStoreId, CancellationToken cancellationToken = default) - => throw new NotSupportedException(); - } - - #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs new file mode 100644 index 00000000000..7f75fae4e74 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/DelegatingAgentSessionStoreTests.cs @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Moq; + +namespace Microsoft.Agents.AI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DelegatingAgentSessionStoreTests +{ + private readonly Mock _innerStoreMock; + private readonly Mock _agentMock; + private readonly TestDelegatingAgentSessionStore _delegatingStore; + private readonly AgentSession _testSession; + + /// + /// Initializes a new instance of the class. + /// + public DelegatingAgentSessionStoreTests() + { + this._innerStoreMock = new Mock(); + this._agentMock = new Mock(); + this._testSession = new TestAgentSession(); + + // Setup inner store mock + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(this._testSession); + + this._innerStoreMock + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(default(ValueTask)); + + this._delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); + } + + #region Constructor Tests + + /// + /// Verify that constructor throws ArgumentNullException when innerStore is null. + /// + [Fact] + public void RequiresInnerStore() => + // Act & Assert + Assert.Throws("innerStore", () => new TestDelegatingAgentSessionStore(null!)); + + /// + /// Verify that constructor sets the inner store correctly. + /// + [Fact] + public void Constructor_WithValidInnerStore_SetsInnerStore() + { + // Act + var delegatingStore = new TestDelegatingAgentSessionStore(this._innerStoreMock.Object); + + // Assert + Assert.Same(this._innerStoreMock.Object, delegatingStore.InnerStore); + } + + #endregion + + #region Method Delegation Tests + + [Fact] + public void GetService_CompatibleUnkeyedType_ReturnsOutermostStore() + { + // Arrange + var outerStore = new TestDelegatingAgentSessionStore(this._delegatingStore); + + // Act and assert + Assert.Same(outerStore, outerStore.GetService()); + Assert.Same(outerStore, outerStore.GetService()); + this._innerStoreMock.Verify(s => s.GetService(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(null)] + [InlineData("service-key")] + public void GetService_InnerService_ForwardsThroughMultipleDecorators(string? serviceKey) + { + // Arrange + var service = new Mock().Object; + this._innerStoreMock.Setup(s => s.GetService(typeof(IDisposable), serviceKey)).Returns(service); + var outerStore = new TestDelegatingAgentSessionStore(this._delegatingStore); + + // Act + var result = outerStore.GetService(serviceKey); + + // Assert + Assert.Same(service, result); + this._innerStoreMock.Verify(s => s.GetService(typeof(IDisposable), serviceKey), Times.Once); + } + + [Fact] + public void GetService_KeyedStoreRequest_ForwardsToInnerStore() + { + // Arrange + this._innerStoreMock.Setup(s => s.GetService(typeof(AgentSessionStore), "store-key")) + .Returns(this._innerStoreMock.Object); + + // Act + var result = this._delegatingStore.GetService("store-key"); + + // Assert + Assert.Same(this._innerStoreMock.Object, result); + } + + [Fact] + public void GetService_UnknownService_ReturnsNull() + { + // Act and assert + Assert.Null(this._delegatingStore.GetService()); + this._innerStoreMock.Verify(s => s.GetService(typeof(IDisposable), null), Times.Once); + } + + [Fact] + public void GetService_NullType_ThrowsWithoutQueryingInnerStore() + { + // Act and assert + Assert.Throws("serviceType", () => this._delegatingStore.GetService(null!)); + this._innerStoreMock.Verify(s => s.GetService(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verify that GetSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task GetSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); + var expectedCancellationToken = new CancellationToken(); + + this._innerStoreMock + .Setup(x => x.GetSessionAsync( + It.Is(a => a == this._agentMock.Object), + It.Is(key => key.Equals(expectedKey)), + It.Is(ct => ct == expectedCancellationToken))) + .ReturnsAsync(this._testSession); + + // Act + var session = await this._delegatingStore.GetSessionAsync( + this._agentMock.Object, + expectedKey, + expectedCancellationToken); + + // Assert + Assert.Same(this._testSession, session); + this._innerStoreMock.Verify( + x => x.GetSessionAsync( + this._agentMock.Object, + expectedKey, + expectedCancellationToken), + Times.Once); + } + + /// + /// Verify that SaveSessionAsync delegates to inner store with correct parameters. + /// + [Fact] + public async Task SaveSessionAsyncDelegatesToInnerStoreAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); + var expectedCancellationToken = new CancellationToken(); + var expectedSession = new TestAgentSession(); + + this._innerStoreMock + .Setup(x => x.SaveSessionAsync( + It.Is(a => a == this._agentMock.Object), + It.Is(key => key.Equals(expectedKey)), + It.Is(s => s == expectedSession), + It.Is(ct => ct == expectedCancellationToken))) + .Returns(default(ValueTask)); + + // Act + await this._delegatingStore.SaveSessionAsync( + this._agentMock.Object, + expectedKey, + expectedSession, + expectedCancellationToken); + + // Assert + this._innerStoreMock.Verify( + x => x.SaveSessionAsync( + this._agentMock.Object, + expectedKey, + expectedSession, + expectedCancellationToken), + Times.Once); + } + + /// + /// Verify that GetSessionAsync awaits the inner store's result before returning. + /// + [Fact] + public async Task GetSessionAsyncAwaitsInnerStoreResultAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id"); + var taskCompletionSource = new TaskCompletionSource(); + + var innerStoreMock = new Mock(); + innerStoreMock + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(taskCompletionSource.Task)); + + var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); + + // Act + var resultTask = delegatingStore.GetSessionAsync(this._agentMock.Object, expectedKey); + + // Assert + Assert.False(resultTask.IsCompleted); + taskCompletionSource.SetResult(this._testSession); + Assert.True(resultTask.IsCompleted); + Assert.Same(this._testSession, await resultTask); + } + + /// + /// Verify that GetOrCreateSessionAsync honors a derived GetSessionAsync override. + /// + [Fact] + public async Task GetOrCreateSessionAsyncUsesOverriddenGetSessionAsyncAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id").WithPartition("user", "test-user-id"); + var store = new OverridingGetSessionStore(this._innerStoreMock.Object, this._testSession); + + // Act + AgentSession session = await store.GetOrCreateSessionAsync( + this._agentMock.Object, + expectedKey); + + // Assert + Assert.Same(this._testSession, session); + this._innerStoreMock.Verify( + x => x.GetOrCreateSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + /// + /// Verify that SaveSessionAsync awaits the inner store's completion before returning. + /// + [Fact] + public async Task SaveSessionAsyncAwaitsInnerStoreCompletionAsync() + { + // Arrange + var expectedKey = new AgentSessionStoreKey("test-conversation-id"); + var expectedSession = new TestAgentSession(); + var taskCompletionSource = new TaskCompletionSource(); + + var innerStoreMock = new Mock(); + innerStoreMock + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask(taskCompletionSource.Task)); + + var delegatingStore = new TestDelegatingAgentSessionStore(innerStoreMock.Object); + + // Act + var resultTask = delegatingStore.SaveSessionAsync( + this._agentMock.Object, + expectedKey, + expectedSession); + + // Assert + Assert.False(resultTask.IsCompleted); + taskCompletionSource.SetResult(true); + Assert.True(resultTask.IsCompleted); + await resultTask; + } + + #endregion + + #region Test Implementation + + /// + /// Test implementation of DelegatingAgentSessionStore for testing purposes. + /// + private sealed class TestDelegatingAgentSessionStore(AgentSessionStore innerStore) : DelegatingAgentSessionStore(innerStore) + { + public new AgentSessionStore InnerStore => base.InnerStore; + } + + private sealed class OverridingGetSessionStore(AgentSessionStore innerStore, AgentSession session) + : DelegatingAgentSessionStore(innerStore) + { + public override ValueTask GetSessionAsync( + AIAgent agent, + AgentSessionStoreKey key, + CancellationToken cancellationToken = default) + => new(session); + } + + private sealed class TestAgentSession : AgentSession; + + #endregion +} From e584a9f39c20f8210bbf4adcfd10755dafcae678 Mon Sep 17 00:00:00 2001 From: LI <2484593937@qq.com> Date: Tue, 15 Sep 2026 14:14:06 +0000 Subject: [PATCH 087/108] Python: fix(mcp): do not duplicate structuredContent when content present (#8274) * fix(mcp): do not duplicate structuredContent when content present Fixes #7866. Prefer content blocks for model-visible tool results; structuredContent remains on the Host payload. Serialize structuredContent only when content is empty. * test(mcp): align expectations with single content+structured item Address Copilot review on #8274: update suites that still expected the old duplicated model-visible content shape. * fix(mcp): add explicit tool_result_content selection policy Replace hard-coded content-first behavior with structured_first/content_first/content_only/structured_only/both, defaulting to structured_first per #7866 discussion. * fix(mcp): document tool_result_content and tighten test typing Add undocumented-param docstring entries on MCP transport constructors and assert tool result text before json.loads. --- python/packages/core/agent_framework/_mcp.py | 76 +++++++++++++++++++- python/packages/core/tests/core/test_mcp.py | 50 ++++++++++--- 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index aa08453b2e9..0dd82180792 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -97,6 +97,29 @@ class MCPSpecificApproval(TypedDict, total=False): never_require_approval: Collection[str] | None + +MCPToolResultContentMode = Literal[ + "structured_first", + "content_first", + "content_only", + "structured_only", + "both", +] +"""How model-visible text is selected when a tool result has both ``content`` and ``structuredContent``. + +MCP servers disagree on whether the two fields are duplicates or complementary (#7866). +Callers pick an explicit policy: + +- ``structured_first`` (default): use ``structuredContent`` when present, else ``content`` +- ``content_first``: use ``content`` when non-empty, else ``structuredContent`` +- ``content_only``: ignore ``structuredContent`` for the model-visible result +- ``structured_only``: ignore ``content`` for the model-visible result +- ``both``: append serialized ``structuredContent`` after ``content`` blocks + +The full MCP payload is still retained on the Host channel regardless of mode. +Custom ``parse_tool_results`` overrides this policy entirely. +""" + _MCP_REMOTE_NAME_KEY = "_mcp_remote_name" _MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name" _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY = "_mcp_tool_result_host_payload" @@ -869,6 +892,7 @@ def __init__( always_load: Collection[str] | None = None, *, max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES, + tool_result_content: MCPToolResultContentMode = "structured_first", ) -> None: """Initialize the MCP Tool base. @@ -938,6 +962,9 @@ def __init__( max_host_payload_size_bytes: Maximum encoded size of the complete MCP result retained for Host transports. Oversized payloads are omitted from the Host channel while the parsed model result is preserved. Set to ``None`` to disable the limit. + tool_result_content: How to choose model-visible text when both ``content`` and + ``structuredContent`` are present. See :data:`MCPToolResultContentMode`. + Ignored when ``parse_tool_results`` is set. Default ``structured_first``. """ if use_progressive_disclosure and not load_tools: raise ValueError("use_progressive_disclosure=True requires load_tools=True.") @@ -950,6 +977,18 @@ def __init__( ) if max_host_payload_size_bytes is not None and max_host_payload_size_bytes <= 0: raise ValueError("max_host_payload_size_bytes must be positive or None.") + allowed_modes = { + "structured_first", + "content_first", + "content_only", + "structured_only", + "both", + } + if tool_result_content not in allowed_modes: + raise ValueError( + f"tool_result_content must be one of {sorted(allowed_modes)}, " + f"got {tool_result_content!r}." + ) self.name = name self.description = description or "" self.approval_mode = approval_mode @@ -958,6 +997,7 @@ def __init__( self.additional_properties = additional_properties self.load_tools_flag = load_tools self.parse_tool_results = parse_tool_results + self.tool_result_content = tool_result_content self.load_prompts_flag = load_prompts self.parse_prompt_results = parse_prompt_results self.max_host_payload_size_bytes = max_host_payload_size_bytes @@ -1140,8 +1180,30 @@ def _parse_tool_result_from_mcp( case _: result.append(Content.from_text(str(item), **additional_kwargs)) + structured_block: Content | None = None if mcp_type.structuredContent is not None: - result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str), **additional_kwargs)) + structured_block = Content.from_text( + json.dumps(mcp_type.structuredContent, default=str), **additional_kwargs + ) + + # Select model-visible content per explicit policy (#7866). Host payload still + # retains the full MCP result regardless of this choice. + mode = self.tool_result_content + if mode == "both": + if structured_block is not None: + result.append(structured_block) + elif mode == "content_only": + pass + elif mode == "structured_only": + result = [structured_block] if structured_block is not None else [] + elif mode == "content_first": + if not result and structured_block is not None: + result.append(structured_block) + elif mode == "structured_first": + if structured_block is not None: + result = [structured_block] + else: # pragma: no cover - validated in __init__ + raise ValueError(f"Unknown tool_result_content mode: {mode!r}") if not result: result.append(Content.from_text("null", **additional_kwargs)) @@ -3372,6 +3434,7 @@ def __init__( task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES, + tool_result_content: MCPToolResultContentMode = "structured_first", **kwargs: Any, ) -> None: """Initialize the MCP stdio tool. @@ -3464,6 +3527,8 @@ def __init__( process you do not control. max_host_payload_size_bytes: Maximum encoded MCP result size retained for Host transports. ``None`` disables the limit. + tool_result_content: How to choose model-visible text when both ``content`` and + ``structuredContent`` are present. See :data:`MCPToolResultContentMode`. kwargs: Any extra arguments to pass to the stdio client. """ super().__init__( @@ -3488,6 +3553,7 @@ def __init__( sampling_max_tokens=sampling_max_tokens, sampling_max_requests=sampling_max_requests, max_host_payload_size_bytes=max_host_payload_size_bytes, + tool_result_content=tool_result_content, ) self.command = command self.args = args or [] @@ -3575,6 +3641,7 @@ def __init__( task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES, + tool_result_content: MCPToolResultContentMode = "structured_first", **kwargs: Any, ) -> None: """Initialize the MCP streamable HTTP tool. @@ -3733,6 +3800,8 @@ def __init__( ``http_client``. max_host_payload_size_bytes: Maximum encoded MCP result size retained for Host transports. ``None`` disables the limit. + tool_result_content: How to choose model-visible text when both ``content`` and + ``structuredContent`` are present. See :data:`MCPToolResultContentMode`. kwargs: Additional keyword arguments (accepted for backward compatibility but not used). """ super().__init__( @@ -3757,6 +3826,7 @@ def __init__( sampling_max_tokens=sampling_max_tokens, sampling_max_requests=sampling_max_requests, max_host_payload_size_bytes=max_host_payload_size_bytes, + tool_result_content=tool_result_content, ) self.url = url self.terminate_on_close = terminate_on_close @@ -4165,6 +4235,7 @@ def __init__( task_options: MCPTaskOptions | None = None, additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None, max_host_payload_size_bytes: int | None = _DEFAULT_MCP_HOST_PAYLOAD_SIZE_BYTES, + tool_result_content: MCPToolResultContentMode = "structured_first", **kwargs: Any, ) -> None: """Initialize the MCP WebSocket tool. @@ -4255,6 +4326,8 @@ def __init__( ``function_invocation_kwargs`` for servers you do not control. max_host_payload_size_bytes: Maximum encoded MCP result size retained for Host transports. ``None`` disables the limit. + tool_result_content: How to choose model-visible text when both ``content`` and + ``structuredContent`` are present. See :data:`MCPToolResultContentMode`. kwargs: Any extra arguments to pass to the WebSocket client. """ super().__init__( @@ -4279,6 +4352,7 @@ def __init__( sampling_max_tokens=sampling_max_tokens, sampling_max_requests=sampling_max_requests, max_host_payload_size_bytes=max_host_payload_size_bytes, + tool_result_content=tool_result_content, ) self.url = url self._client_kwargs = kwargs diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 832fead68e2..8a5ccf18a30 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -546,7 +546,7 @@ def test_parse_tool_result_from_mcp_structured_content_only(): def test_parse_tool_result_from_mcp_structured_content_with_text(): - """Test that structuredContent is appended alongside regular content items.""" + """Default structured_first prefers structuredContent when both are present (#7866).""" mcp_result = types.CallToolResult( content=[types.TextContent(type="text", text="Summary")], structuredContent={"data": [1, 2, 3]}, @@ -554,13 +554,43 @@ def test_parse_tool_result_from_mcp_structured_content_with_text(): result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result) assert isinstance(result, list) - assert len(result) == 2 + assert len(result) == 1 assert result[0].type == "text" - assert result[0].text == "Summary" - assert result[1].type == "text" - assert result[1].text is not None - parsed = json.loads(result[1].text) - assert parsed == {"data": [1, 2, 3]} + assert result[0].text is not None + assert json.loads(result[0].text) == {"data": [1, 2, 3]} + + +def test_parse_tool_result_content_modes_for_complementary_and_duplicate_payloads(): + """tool_result_content covers structured-only, content-only, either-first, and both.""" + mcp_result = types.CallToolResult( + content=[types.TextContent(type="text", text="Summary")], + structuredContent={"data": [1, 2, 3]}, + ) + + content_first = MCPTool(name="helper", tool_result_content="content_first") # type: ignore[abstract] + assert [c.text for c in content_first._parse_tool_result_from_mcp(mcp_result)] == ["Summary"] + + content_only = MCPTool(name="helper", tool_result_content="content_only") # type: ignore[abstract] + assert [c.text for c in content_only._parse_tool_result_from_mcp(mcp_result)] == ["Summary"] + + structured_only = MCPTool(name="helper", tool_result_content="structured_only") # type: ignore[abstract] + structured_only_text = structured_only._parse_tool_result_from_mcp(mcp_result)[0].text + assert structured_only_text is not None + assert json.loads(structured_only_text) == {"data": [1, 2, 3]} + + both = MCPTool(name="helper", tool_result_content="both") # type: ignore[abstract] + both_result = both._parse_tool_result_from_mcp(mcp_result) + assert both_result[0].text == "Summary" + assert both_result[1].text is not None + assert json.loads(both_result[1].text) == {"data": [1, 2, 3]} + + structured_only_empty = types.CallToolResult(content=[], structuredContent={"x": 1}) + empty_structured_text = content_first._parse_tool_result_from_mcp(structured_only_empty)[0].text + assert empty_structured_text is not None + assert json.loads(empty_structured_text) == {"x": 1} + + empty = types.CallToolResult(content=[], structuredContent=None) + assert content_only._parse_tool_result_from_mcp(empty)[0].text == "null" async def test_generated_mcp_tool_preserves_complete_host_payload_once() -> None: @@ -584,7 +614,7 @@ async def test_generated_mcp_tool_preserves_complete_host_payload_once() -> None "isError": False, } - assert [item.additional_properties["_meta"] for item in function_result.items] == [{"widget": "image"}] * 2 + assert [item.additional_properties["_meta"] for item in function_result.items] == [{"widget": "image"}] assert function_result.additional_properties[_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY] == expected_host_payload assert all(_MCP_TOOL_RESULT_HOST_PAYLOAD_KEY not in item.additional_properties for item in function_result.items) restored = Content.from_dict(function_result.to_dict()) @@ -1198,7 +1228,7 @@ async def test_secure_mcp_builtin_parser_restricts_all_result_shapes(result_shap ) assert function_result.items is not None - assert len(function_result.items) == (2 if result_shape == "both" else 1) + assert len(function_result.items) == 1 for hidden_item in function_result.items: assert hidden_item.additional_properties["_variable_reference"] is True assert hidden_item.additional_properties["security_label"]["integrity"] == "untrusted" @@ -8292,7 +8322,7 @@ async def test_secure_mcp_task_results_cannot_relax_local_label(result_path: str ) assert function_result.items is not None - assert len(function_result.items) == 2 + assert len(function_result.items) == 1 for item in function_result.items: assert item.additional_properties["_variable_reference"] is True assert item.additional_properties["security_label"]["integrity"] == "untrusted" From 6da8680c7900ed16723a89306e4f98ab32bd1ab1 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Tue, 15 Sep 2026 14:40:59 +0000 Subject: [PATCH 088/108] Python: Reject per-call Mistral transport options (#8392) Apply the existing transport-argument restriction to both options and client_kwargs. Add mock-transport regression coverage while preserving constructor configuration and normal generation options. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/hosting_responses/test_parsing.py | 16 ++ python/packages/mistral/README.md | 6 + .../agent_framework_mistral/_chat_client.py | 5 +- .../tests/mistral/test_mistral_chat_client.py | 220 +++++++++++++++++- 4 files changed, 236 insertions(+), 11 deletions(-) diff --git a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py index e3c3079de15..b98ccec596a 100644 --- a/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py +++ b/python/packages/hosting-responses/tests/hosting_responses/test_parsing.py @@ -269,6 +269,22 @@ def test_responses_to_run_returns_messages_options_and_stream(self) -> None: assert run["stream"] is True assert run["options"] == {"max_tokens": 32, "model": "gpt-x"} + @pytest.mark.parametrize("stream", [False, True]) + def test_responses_to_run_preserves_provider_options_and_omits_nulls(self, stream: bool) -> None: + run = responses_to_run({ + "input": "hi", + "stream": stream, + "max_output_tokens": 32, + "safe_prompt": True, + "server_url": None, + "http_headers": None, + "retries": None, + "timeout_ms": None, + }) + + assert run["stream"] is stream + assert run["options"] == {"max_tokens": 32, "safe_prompt": True} + def test_responses_to_run_rejects_conflicting_continuation_mechanisms(self) -> None: with pytest.raises(ValueError, match="mutually exclusive"): responses_to_run({ diff --git a/python/packages/mistral/README.md b/python/packages/mistral/README.md index 8c48d1b5ea8..5b3c0e99cc4 100644 --- a/python/packages/mistral/README.md +++ b/python/packages/mistral/README.md @@ -42,6 +42,12 @@ finally: | `MISTRAL_CHAT_MODEL` | Chat model name (e.g., `mistral-large-latest`) | | `MISTRAL_SERVER_URL` | Optional server URL override | +Chat connection settings belong on the client, not on individual requests. Both +`MistralChatClient` and `RawMistralChatClient` reject `server_url`, `http_headers`, +`retries`, and `timeout_ms` in per-call `options` or `client_kwargs`. Configure +the endpoint with the constructor's `server_url`, or inject a configured SDK +`client` or `http_client`. Normal model generation options remain per-call. + ## Embedding Client The `MistralEmbeddingClient` provides embedding generation using Mistral AI models. diff --git a/python/packages/mistral/agent_framework_mistral/_chat_client.py b/python/packages/mistral/agent_framework_mistral/_chat_client.py index 597cbe60774..749c4a437ae 100644 --- a/python/packages/mistral/agent_framework_mistral/_chat_client.py +++ b/python/packages/mistral/agent_framework_mistral/_chat_client.py @@ -526,9 +526,10 @@ def _prepare_request( The request body for ``POST /v1/chat/completions``. Raises: - ValueError: If no model is set on the options or the client instance. + ValueError: If transport arguments are supplied in options or kwargs, + or no model is set on the options or the client instance. """ - if transport_arguments := _SDK_TRANSPORT_ARGUMENTS.intersection(kwargs): + if transport_arguments := _SDK_TRANSPORT_ARGUMENTS.intersection(options.keys() | kwargs.keys()): arguments = ", ".join(sorted(transport_arguments)) raise ValueError( f"Mistral transport arguments cannot be supplied per call: {arguments}. Configure the client instead." diff --git a/python/packages/mistral/tests/mistral/test_mistral_chat_client.py b/python/packages/mistral/tests/mistral/test_mistral_chat_client.py index c1c86042a6f..10a1a524b64 100644 --- a/python/packages/mistral/tests/mistral/test_mistral_chat_client.py +++ b/python/packages/mistral/tests/mistral/test_mistral_chat_client.py @@ -18,10 +18,11 @@ ) from mistralai.client import Mistral from mistralai.client.models import AssistantMessage, ThinkChunk +from mistralai.client.utils.retries import BackoffStrategy, RetryConfig from pydantic import BaseModel import agent_framework_mistral._chat_client as chat_client_module -from agent_framework_mistral import MistralChatClient, MistralChatOptions +from agent_framework_mistral import MistralChatClient, MistralChatOptions, RawMistralChatClient from agent_framework_mistral._chat_client import _sanitize_tool_call_id # pyright: ignore[reportPrivateUsage] from agent_framework_mistral._http_client import ( # pyright: ignore[reportPrivateUsage] AsyncClientUsingConfiguredTimeout, @@ -431,17 +432,218 @@ async def test_get_response_instructions_prepended_as_system_message() -> None: assert "instructions" not in server.last_request -@pytest.mark.parametrize("argument", ["http_headers", "retries", "server_url", "timeout_ms"]) -async def test_get_response_rejects_per_call_transport_arguments(argument: str) -> None: +@pytest.mark.parametrize("client_type", [RawMistralChatClient, MistralChatClient]) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("source", ["options", "client_kwargs"]) +@pytest.mark.parametrize("use_none", [False, True]) +@pytest.mark.parametrize( + ("argument", "value"), + [ + ("http_headers", {"X-Test-Override": "value"}), + ("retries", RetryConfig("backoff", BackoffStrategy(1, 10, 2, 100), False)), + ("server_url", "https://override.example"), + ("timeout_ms", 1000), + ], +) +async def test_get_response_rejects_per_call_transport_arguments( + client_type: type[RawMistralChatClient], + stream: bool, + source: str, + use_none: bool, + argument: str, + value: Any, +) -> None: + server = MockMistral([]) + async with httpx.AsyncClient(transport=httpx.MockTransport(server.handler)) as http_client: + client = client_type(model="mistral-small-latest", api_key="test-key", http_client=http_client) + overrides = {argument: None if use_none else value} + options = cast("MistralChatOptions", overrides if source == "options" else {}) + client_kwargs = overrides if source == "client_kwargs" else {} + try: + with pytest.raises(ValueError, match="cannot be supplied per call") as exc: + if stream: + await client.get_response( + [Message("user", ["hi"])], stream=True, options=options, client_kwargs=client_kwargs + ).get_final_response() + else: + await client.get_response([Message("user", ["hi"])], options=options, client_kwargs=client_kwargs) + assert str(exc.value) == ( + f"Mistral transport arguments cannot be supplied per call: {argument}. Configure the client instead." + ) + assert overrides == {argument: None if use_none else value} + assert server.http_requests == [] + finally: + await client.close() + + +@pytest.mark.parametrize("stream", [False, True]) +async def test_get_response_rejects_transport_arguments_split_across_options_and_kwargs(stream: bool) -> None: client, server = make_client() + options = cast("MistralChatOptions", {"server_url": "https://override.example", "http_headers": {}}) + client_kwargs = {"server_url": "https://another.example", "timeout_ms": 1000, "retries": None} + try: + with pytest.raises(ValueError, match="http_headers, retries, server_url, timeout_ms"): + if stream: + await client.get_response( + [Message("user", ["hi"])], stream=True, options=options, client_kwargs=client_kwargs + ).get_final_response() + else: + await client.get_response([Message("user", ["hi"])], options=options, client_kwargs=client_kwargs) + assert server.http_requests == [] + finally: + await client.close() - with pytest.raises(ValueError, match="cannot be supplied per call"): - await client.get_response( - [Message("user", ["hi"])], - client_kwargs={argument: "override"}, - ) - assert server.requests == [] +@pytest.mark.parametrize("client_type", [RawMistralChatClient, MistralChatClient]) +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("configuration", ["server_url", "http_client", "sdk"]) +async def test_get_response_preserves_configured_transport_and_generation_options( + client_type: type[RawMistralChatClient], + stream: bool, + configuration: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("MISTRAL_SERVER_URL", raising=False) + endpoint = "https://configured.example" + server = MockMistral( + [ + stream_response(make_chunk_payload(content="ok", finish_reason="stop")) + if stream + else json_response(make_response_payload(content="ok")) + ] + ) + async with httpx.AsyncClient( + base_url=endpoint if configuration == "http_client" else "https://unused.example", + transport=httpx.MockTransport(server.handler), + ) as http_client: + sdk_client = None + if configuration == "sdk": + sdk_client = Mistral(api_key="test-key", server_url=endpoint, async_client=http_client) + client = client_type(model="mistral-small-latest", client=sdk_client) + else: + client = client_type( + model="mistral-small-latest", + api_key="test-key", + server_url=endpoint if configuration == "server_url" else None, + http_client=http_client, + ) + options: MistralChatOptions = { + "temperature": 0.5, + "max_tokens": 32, + "seed": 42, + "safe_prompt": True, + "allow_multiple_tool_calls": False, + } + try: + if stream: + response = await client.get_response( + [Message("user", ["hi"])], stream=True, options=options, client_kwargs={"n": 1} + ).get_final_response() + else: + response = await client.get_response([Message("user", ["hi"])], options=options, client_kwargs={"n": 1}) + assert response.text == "ok" + assert len(server.http_requests) == 1 + assert server.http_requests[0].url.copy_with(fragment=None) == httpx.URL(f"{endpoint}/v1/chat/completions") + assert server.http_requests[0].headers["Authorization"] == "Bearer test-key" + assert server.last_request["messages"] == [{"role": "user", "content": "hi"}] + assert server.last_request["temperature"] == 0.5 + assert server.last_request["max_tokens"] == 32 + assert server.last_request["random_seed"] == 42 + assert server.last_request["safe_prompt"] is True + assert server.last_request["parallel_tool_calls"] is False + assert server.last_request["n"] == 1 + assert "seed" not in server.last_request + assert "allow_multiple_tool_calls" not in server.last_request + finally: + await client.close() + if sdk_client is not None: + await sdk_client.__aexit__(None, None, None) + sdk_client.__exit__(None, None, None) + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize( + ("argument", "value"), + [ + ("server_url", "https://override.example"), + ("http_headers", {"X-Test-Override": "value"}), + ("retries", {}), + ("timeout_ms", 1000), + ], +) +async def test_responses_agent_rejects_per_call_transport_arguments(stream: bool, argument: str, value: Any) -> None: + responses = pytest.importorskip( + "agent_framework_hosting_responses", reason="Requires the Responses hosting package" + ) + run = responses.responses_to_run({"input": "hi", "stream": stream, argument: value}) + assert run["options"][argument] == value + client, server = make_client() + agent = Agent(client=client) + try: + with pytest.raises(ValueError, match=f"cannot be supplied per call: {argument}"): + if stream: + await agent.run(run["messages"], options=run["options"], stream=True).get_final_response() + else: + await agent.run(run["messages"], options=run["options"]) + assert server.http_requests == [] + finally: + await client.close() + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("include_nulls", [False, True]) +async def test_responses_agent_preserves_normal_options_and_configured_endpoint( + stream: bool, include_nulls: bool +) -> None: + responses = pytest.importorskip( + "agent_framework_hosting_responses", reason="Requires the Responses hosting package" + ) + body: dict[str, Any] = { + "input": "hi", + "stream": stream, + "max_output_tokens": 32, + "temperature": 0.5, + "safe_prompt": True, + } + if include_nulls: + body.update(dict.fromkeys(["http_headers", "retries", "server_url", "timeout_ms"])) + run = responses.responses_to_run(body) + assert run["options"] == {"max_tokens": 32, "temperature": 0.5, "safe_prompt": True} + server = MockMistral( + [ + stream_response(make_chunk_payload(content="ok", finish_reason="stop")) + if stream + else json_response(make_response_payload(content="ok")) + ] + ) + async with httpx.AsyncClient(transport=httpx.MockTransport(server.handler)) as http_client: + client = MistralChatClient( + model="mistral-small-latest", + api_key="test-key", + server_url="https://configured.example", + http_client=http_client, + ) + agent = Agent(client=client, instructions="Be brief.") + try: + if stream: + result = await agent.run(run["messages"], options=run["options"], stream=True).get_final_response() + else: + result = await agent.run(run["messages"], options=run["options"]) + assert result.text == "ok" + assert len(server.http_requests) == 1 + assert server.http_requests[0].url.copy_with(fragment=None) == httpx.URL( + "https://configured.example/v1/chat/completions" + ) + assert server.http_requests[0].headers["Authorization"] == "Bearer test-key" + assert server.last_request["messages"] == [ + {"role": "system", "content": "Be brief."}, + {"role": "user", "content": "hi"}, + ] + assert server.last_request["max_tokens"] == 32 + assert server.last_request["temperature"] == 0.5 + assert server.last_request["safe_prompt"] is True + finally: + await client.close() async def test_get_response_model_override() -> None: From 5ce7f36b001df344218c5629c4fc53309e8345ed Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:53:42 +0000 Subject: [PATCH 089/108] Python: Propagate runtime arguments to skill callbacks (#8387) * Python: Propagate runtime arguments to skill callbacks Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Pass runtime mappings through skill helpers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Specify agent options type in skill tests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_skills.py | 94 ++- .../packages/core/tests/core/test_skills.py | 545 +++++++++++++++++- .../code_defined_skill/code_defined_skill.py | 39 +- 3 files changed, 631 insertions(+), 47 deletions(-) diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index 4c14e5b07b5..e4101609d28 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -55,7 +55,7 @@ import time import zipfile from abc import ABC, abstractmethod -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import timedelta from enum import Enum @@ -75,6 +75,7 @@ from pydantic import AnyUrl from ._agents import SupportsAgentRun + from ._middleware import FunctionInvocationContext from ._sessions import AgentSession, SessionContext from ._types import Content @@ -2549,13 +2550,52 @@ def _create_tools( async def _load(skill_name: str) -> str: return await self._load_skill(skills, skill_name) - async def _read_resource(skill_name: str, resource_name: str, **kwargs: Any) -> Any: - return await self._read_skill_resource(skills, skill_name, resource_name, **kwargs) + # The runtime kwargs these handlers forward come from the host's + # ``agent.run(function_invocation_kwargs=...)``, never from the model. Two + # things keep the channels apart at this seam, and both are required. + # + # First, declaring ``ctx`` opts the handler into FunctionInvocationContext + # injection; without it the function-invocation layer has nowhere to put the + # host values and silently drops them. Second, the handlers take no ``**kwargs`` + # and the outer schemas below set ``additionalProperties: False``, so an + # undeclared top-level argument returned by the model is rejected during + # validation instead of binding into the runtime namespace. + # + # ``ctx`` is injected by name and is absent from the advertised schema, so it is + # never something the model can supply or override. + # + # This guarantee covers ``ctx.kwargs``, not everything a skill callback ends up + # seeing. ``args`` stays deliberately free-form because scripts declare their own + # parameters, and :meth:`InlineSkillScript.run` expands it alongside these runtime + # kwargs, so a script's ``**kwargs`` can also hold model-supplied entries that are + # not declared parameters. Resources take no model arguments, so a resource's + # ``**kwargs`` is runtime-only. Do not treat a name appearing in a script's + # ``**kwargs`` as proof the host supplied it. + async def _read_resource( + ctx: FunctionInvocationContext, + skill_name: str, + resource_name: str, + ) -> Any: + return await self._read_skill_resource( + skills, + skill_name, + resource_name, + runtime_kwargs=ctx.kwargs, + ) async def _run_script( - skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, **kwargs: Any + ctx: FunctionInvocationContext, + skill_name: str, + script_name: str, + args: dict[str, Any] | list[str] | None = None, ) -> Any: - return await self._run_skill_script(skills, skill_name, script_name, args, **kwargs) + return await self._run_skill_script( + skills, + skill_name, + script_name, + args, + runtime_kwargs=ctx.kwargs, + ) return [ FunctionTool( @@ -2569,6 +2609,7 @@ async def _run_script( "skill_name": {"type": "string", "description": "The name of the skill to load."}, }, "required": ["skill_name"], + "additionalProperties": False, }, ), FunctionTool( @@ -2586,6 +2627,7 @@ async def _run_script( }, }, "required": ["skill_name", "resource_name"], + "additionalProperties": False, }, ), FunctionTool( @@ -2637,6 +2679,9 @@ async def _run_script( }, }, "required": ["skill_name", "script_name"], + # Scripts declare their own parameters, so the nested ``args`` object + # above stays free-form; only the outer envelope is closed. + "additionalProperties": False, }, ), ] @@ -2678,7 +2723,8 @@ async def _run_skill_script( skill_name: str, script_name: str, args: dict[str, Any] | list[str] | None = None, - **kwargs: Any, + *, + runtime_kwargs: Mapping[str, Any] | None = None, ) -> Any: """Run a named script from a skill. @@ -2691,9 +2737,19 @@ async def _run_skill_script( script_name: The script name to look up (case-insensitive). args: Optional arguments for the script, provided by the agent/LLM. - **kwargs: Runtime keyword arguments forwarded only to script - functions that accept ``**kwargs`` (e.g. arguments passed via - ``agent.run(user_id="123")``). + runtime_kwargs: Runtime keyword arguments forwarded only to script + functions that accept ``**kwargs``. This parameter carries host + request context supplied via + ``agent.run(..., function_invocation_kwargs={"user_id": "123"})`` + and delivered through :class:`FunctionInvocationContext`; it is + never sourced from model-supplied tool arguments. Note that the + receiving script's own ``**kwargs`` is not runtime-only: these + values are expanded alongside any undeclared entries in *args*, + which the model supplies. Taking these as one mapping keeps this + helper's own parameter names (``skills``, ``skill_name``, + ``script_name``, ``args``) usable as runtime kwarg names; the + public :meth:`SkillScript.run` signature still expands them, so + ``skill`` and ``args`` remain reserved at that boundary. Returns: The script result. Returns a user-facing error string for @@ -2719,13 +2775,18 @@ async def _run_skill_script( return f"Error: Script '{script_name}' not found in skill '{skill_name}'." try: - return await script.run(skill, args, **kwargs) + return await script.run(skill, args, **(runtime_kwargs or {})) except Exception: logger.exception("Error running script '%s' in skill '%s'", script_name, skill_name) raise async def _read_skill_resource( - self, skills: Sequence[Skill], skill_name: str, resource_name: str, **kwargs: Any + self, + skills: Sequence[Skill], + skill_name: str, + resource_name: str, + *, + runtime_kwargs: Mapping[str, Any] | None = None, ) -> Any: """Read a named resource from a skill. @@ -2737,9 +2798,12 @@ async def _read_skill_resource( skills: The skills to look up the skill from. skill_name: The name of the owning skill. resource_name: The resource name to look up (case-insensitive). - **kwargs: Runtime keyword arguments forwarded to resource functions - that accept ``**kwargs`` (e.g. arguments passed via - ``agent.run(user_id="123")``). + runtime_kwargs: Runtime keyword arguments forwarded to resource functions + that accept ``**kwargs``. These carry host request context + supplied via + ``agent.run(..., function_invocation_kwargs={"user_id": "123"})`` + and delivered through :class:`FunctionInvocationContext`; they are + never sourced from model-supplied tool arguments. Returns: The resource content (any type). Returns a user-facing error @@ -2768,7 +2832,7 @@ async def _read_skill_resource( return f"Error: Resource '{resource_name}' not found in skill '{skill_name}'." try: - return await resource.read(**kwargs) + return await resource.read(**(runtime_kwargs or {})) except Exception: logger.exception("Failed to read resource '%s' from skill '%s'", resource_name, skill_name) raise diff --git a/python/packages/core/tests/core/test_skills.py b/python/packages/core/tests/core/test_skills.py index 94b98f2114e..bf6fc571b77 100644 --- a/python/packages/core/tests/core/test_skills.py +++ b/python/packages/core/tests/core/test_skills.py @@ -16,8 +16,11 @@ import pytest from agent_framework import ( + Agent, AggregatingSkillsSource, CachingSkillsSource, + ChatOptions, + ChatResponse, ClassSkill, Content, DeduplicatingSkillsSource, @@ -26,6 +29,7 @@ FileSkillsSource, InlineSkill, InMemorySkillsSource, + Message, SessionContext, Skill, SkillFrontmatter, @@ -37,6 +41,7 @@ SkillsSource, SkillsSourceContext, ) +from agent_framework._middleware import FunctionInvocationContext from agent_framework._skills import ( DEFAULT_RESOURCE_EXTENSIONS, DEFAULT_SCRIPT_EXTENSIONS, @@ -49,7 +54,7 @@ _SkillPathScope, ) -from .conftest import MockAgent, MockAgentSession, create_junction_or_skip +from .conftest import MockAgent, MockAgentSession, MockBaseChatClient, create_junction_or_skip # Cross-platform absolute path prefix for tests _ABS = "C:\\skills" if os.name == "nt" else "/skills" @@ -80,6 +85,17 @@ async def _noop_script_runner(skill: Any, script: Any, args: Any = None) -> None return +def _invocation_context(tool: Any, **runtime_kwargs: Any) -> FunctionInvocationContext: + """Build the invocation context a skill tool handler receives at dispatch time. + + ``read_skill_resource`` and ``run_skill_script`` take host runtime values through + :class:`FunctionInvocationContext` rather than through model-supplied arguments, so + tests that call ``tool.func(...)`` directly must supply one. Pass runtime values as + keyword arguments to stand in for ``agent.run(function_invocation_kwargs=...)``. + """ + return FunctionInvocationContext(function=tool, arguments={}, kwargs=runtime_kwargs) + + class _CountingSkillsSource(SkillsSource): """Test source that records how many times ``get_skills`` is called.""" @@ -1543,7 +1559,10 @@ def get_user_config(**kwargs: Any) -> Any: provider = SkillsProvider([skill]) await _init_provider(provider) result = await provider._read_skill_resource( - _raw_skills(provider), "prog-skill", "get_user_config", user_id="user_123" + _raw_skills(provider), + "prog-skill", + "get_user_config", + runtime_kwargs={"user_id": "user_123"}, ) assert result == "config for user_123" @@ -1560,7 +1579,10 @@ async def get_user_data(**kwargs: Any) -> Any: provider = SkillsProvider([skill]) await _init_provider(provider) result = await provider._read_skill_resource( - _raw_skills(provider), "prog-skill", "get_user_data", auth_token="abc" + _raw_skills(provider), + "prog-skill", + "get_user_data", + runtime_kwargs={"auth_token": "abc"}, ) assert result == "data with token=abc" @@ -1577,7 +1599,10 @@ def static_resource() -> Any: provider = SkillsProvider([skill]) await _init_provider(provider) result = await provider._read_skill_resource( - _raw_skills(provider), "prog-skill", "static_resource", user_id="ignored" + _raw_skills(provider), + "prog-skill", + "static_resource", + runtime_kwargs={"user_id": "ignored"}, ) assert result == "static content" @@ -3676,7 +3701,12 @@ def my_function(key: str = "") -> str: provider = SkillsProvider([skill]) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="s1", args={"key": "hello"}) + result = await run_tool.func( + _invocation_context(run_tool), + skill_name="my-skill", + script_name="s1", + args={"key": "hello"}, + ) assert result == "executed: hello" @@ -3723,7 +3753,7 @@ async def test_read_skill_resource_tool_returns_content(self) -> None: provider = SkillsProvider([skill]) await _init_provider(provider) read_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "read_skill_resource") - result = await read_tool.func(skill_name="my-skill", resource_name="ref") + result = await read_tool.func(_invocation_context(read_tool), skill_name="my-skill", resource_name="ref") assert result == "reference data" async def test_file_skills_with_custom_runner(self, tmp_path: Path) -> None: @@ -3791,7 +3821,12 @@ def sync_runner(skill, script, args=None): ) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="scripts/run.py", args={"key": "val"}) + result = await run_tool.func( + _invocation_context(run_tool), + skill_name="my-skill", + script_name="scripts/run.py", + args={"key": "val"}, + ) assert result == "sync: scripts/run.py args={'key': 'val'}" async def test_file_skills_with_callback_runner(self, tmp_path: Path) -> None: @@ -3861,12 +3896,12 @@ async def test_file_script_error_without_runner(self) -> None: run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") # Code script works - result = await run_tool.func(skill_name="my-skill", script_name="code-s") + result = await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="code-s") assert result == "ok" # File script without runner propagates an error by default with pytest.raises(TypeError, match="requires a FileSkill"): - await run_tool.func(skill_name="my-skill", script_name="file-s") + await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="file-s") async def test_async_code_script_runs_directly(self) -> None: async def async_func(x: int = 0) -> str: @@ -3878,7 +3913,12 @@ async def async_func(x: int = 0) -> str: provider = SkillsProvider([skill]) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="s1", args={"x": 42}) + result = await run_tool.func( + _invocation_context(run_tool), + skill_name="my-skill", + script_name="s1", + args={"x": 42}, + ) assert result == "async: 42" async def test_code_script_returns_object(self) -> None: @@ -3893,7 +3933,7 @@ def returns_dict() -> dict: provider = SkillsProvider([skill]) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="s1") + result = await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="s1") assert result == {"status": "ok", "value": 42} async def test_code_script_returns_none(self) -> None: @@ -3904,7 +3944,7 @@ async def test_code_script_returns_none(self) -> None: provider = SkillsProvider([skill]) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="s1") + result = await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="s1") assert result is None async def test_script_with_path_errors_without_runner(self) -> None: @@ -3918,12 +3958,12 @@ async def test_script_with_path_errors_without_runner(self) -> None: run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") # Code-only script still works - result = await run_tool.func(skill_name="my-skill", script_name="code-s") + result = await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="code-s") assert result == "ok" # Path+function script without runner propagates an error by default with pytest.raises(TypeError, match="requires a FileSkill"): - await run_tool.func(skill_name="my-skill", script_name="path-s") + await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="path-s") async def test_run_skill_script_error_on_missing_skill(self) -> None: skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body") @@ -3932,7 +3972,7 @@ async def test_run_skill_script_error_on_missing_skill(self) -> None: provider = SkillsProvider([skill]) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="nonexistent", script_name="s1") + result = await run_tool.func(_invocation_context(run_tool), skill_name="nonexistent", script_name="s1") assert "Error" in result assert "nonexistent" in result @@ -3947,7 +3987,11 @@ def greet(name: str, **kwargs: Any) -> str: provider = SkillsProvider([skill]) await _init_provider(provider) result = await provider._run_skill_script( - _raw_skills(provider), "my-skill", "greet", args={"name": "Alice"}, user_id="u42" + _raw_skills(provider), + "my-skill", + "greet", + args={"name": "Alice"}, + runtime_kwargs={"user_id": "u42"}, ) assert result == "Hello Alice (user=u42)" @@ -3962,7 +4006,11 @@ async def fetch(url: str, **kwargs: Any) -> str: provider = SkillsProvider([skill]) await _init_provider(provider) result = await provider._run_skill_script( - _raw_skills(provider), "my-skill", "fetch", args={"url": "http://x"}, auth_token="abc" + _raw_skills(provider), + "my-skill", + "fetch", + args={"url": "http://x"}, + runtime_kwargs={"auth_token": "abc"}, ) assert result == "fetched http://x with token=abc" @@ -3977,7 +4025,11 @@ def simple(query: str) -> str: provider = SkillsProvider([skill]) await _init_provider(provider) result = await provider._run_skill_script( - _raw_skills(provider), "my-skill", "simple", args={"query": "test"}, user_id="ignored" + _raw_skills(provider), + "my-skill", + "simple", + args={"query": "test"}, + runtime_kwargs={"user_id": "ignored"}, ) assert result == "result: test" @@ -3993,7 +4045,11 @@ def process(**kwargs: Any) -> str: await _init_provider(provider) with pytest.raises(TypeError): await provider._run_skill_script( - _raw_skills(provider), "my-skill", "process", args={"mode": "llm-value"}, mode="runtime-value" + _raw_skills(provider), + "my-skill", + "process", + args={"mode": "llm-value"}, + runtime_kwargs={"mode": "runtime-value"}, ) async def test_run_skill_script_error_on_missing_script(self) -> None: @@ -4003,7 +4059,7 @@ async def test_run_skill_script_error_on_missing_script(self) -> None: provider = SkillsProvider([skill]) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="nonexistent") + result = await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="nonexistent") assert "Error" in result assert "nonexistent" in result @@ -4015,10 +4071,10 @@ async def test_run_skill_script_error_on_empty_names(self) -> None: await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="", script_name="s1") + result = await run_tool.func(_invocation_context(run_tool), skill_name="", script_name="s1") assert "Error" in result - result = await run_tool.func(skill_name="my-skill", script_name="") + result = await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="") assert "Error" in result async def test_instructions_include_script_runner_hints(self) -> None: @@ -4239,7 +4295,7 @@ def failing_script() -> str: await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") with pytest.raises(RuntimeError, match="Something went wrong"): - await run_tool.func(skill_name="my-skill", script_name="boom") + await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="boom") async def test_custom_template_without_runner_placeholder_raises(self) -> None: """Providers accept custom templates without {runner_instructions}.""" @@ -6167,7 +6223,7 @@ async def source_runner(skill: Any, script: Any, args: Any = None) -> str: # The source-level runner should be discovered and used run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="scripts/run.py") + result = await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="scripts/run.py") assert result == "source" assert call_log == ["source"] @@ -6648,7 +6704,12 @@ def runner(skill: Any, script: Any, args: Any = None) -> str: provider = SkillsProvider([skill]) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="run.py", args=["input.docx", "--verbose"]) + result = await run_tool.func( + _invocation_context(run_tool), + skill_name="my-skill", + script_name="run.py", + args=["input.docx", "--verbose"], + ) assert result == "list_result" assert captured["args"] == ["input.docx", "--verbose"] @@ -6661,7 +6722,7 @@ async def test_run_skill_script_inline_with_list_args_propagates_error(self) -> await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") with pytest.raises(TypeError, match="requires keyword arguments"): - await run_tool.func(skill_name="my-skill", script_name="s1", args=["arg1"]) + await run_tool.func(_invocation_context(run_tool), skill_name="my-skill", script_name="s1", args=["arg1"]) async def test_file_skill_content_includes_scripts_block(self) -> None: """FileSkill.content appends an block when scripts are present.""" @@ -6846,7 +6907,12 @@ def greet(name: str = "world") -> str: provider = SkillsProvider([skill]) await _init_provider(provider) run_tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") - result = await run_tool.func(skill_name="my-skill", script_name="greet", args={"q": "Eve"}) + result = await run_tool.func( + _invocation_context(run_tool), + skill_name="my-skill", + script_name="greet", + args={"q": "Eve"}, + ) assert result == "hello Eve" async def test_inline_string_args_without_parser_raises(self) -> None: @@ -6855,3 +6921,428 @@ async def test_inline_string_args_without_parser_raises(self) -> None: skill = InlineSkill(frontmatter=SkillFrontmatter(name="s", description="d"), instructions="c") with pytest.raises(TypeError, match="argument_parser"): await script.run(skill, args='{"name": "Alice"}') + + +# --------------------------------------------------------------------------- +# Tests: runtime kwargs provenance (host context vs. model arguments) +# --------------------------------------------------------------------------- + + +def _function_call_response(*, call_id: str, name: str, arguments: str) -> ChatResponse: + """Build a chat response containing a single tool call.""" + return ChatResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call(call_id=call_id, name=name, arguments=arguments)], + ) + ] + ) + + +class TestSkillsRuntimeKwargsProvenance: + """End-to-end tests that runtime kwargs reach skills from the host, not the model. + + The framework exposes two distinct argument channels for skill tools: + + * **Trusted** — host values supplied via ``agent.run(function_invocation_kwargs=...)``, + delivered through :class:`FunctionInvocationContext`. + * **Untrusted** — the arguments the model produced for the tool call, which are + constrained by the tool's advertised JSON schema. + + Runtime values such as ``tenant_id`` or ``auth_token`` must come only from the + trusted channel. These tests drive the real ``Agent.run`` dispatch path rather + than the provider's private helpers, so they cover schema validation and context + injection — the layers where the two channels are told apart. + """ + + @staticmethod + def _tenant_skill(calls: list[dict[str, Any]]) -> InlineSkill: + """Build a skill whose resource and script record the runtime kwargs they receive.""" + skill = InlineSkill( + frontmatter=SkillFrontmatter(name="tenant-data", description="Tenant-scoped data."), + instructions="Body", + ) + + @skill.resource(name="account-record", description="The current tenant's account record.") + def account_record(**kwargs: Any) -> Any: + calls.append(dict(kwargs)) + return f"record for {kwargs.get('tenant_id', 'MISSING')}" + + @skill.script(name="export-report", description="Export the current tenant's report.") + def export_report(report: str, **kwargs: Any) -> Any: + calls.append(dict(kwargs)) + return f"{report} for {kwargs.get('tenant_id', 'MISSING')}" + + return skill + + @staticmethod + def _agent(client: MockBaseChatClient, skill: InlineSkill) -> Agent: + """Build an agent exposing the skill's tools without approval prompts.""" + provider = SkillsProvider( + [skill], + disable_load_skill_approval=True, + disable_read_skill_resource_approval=True, + disable_run_skill_script_approval=True, + ) + return Agent[ChatOptions[None]](client=client, context_providers=[provider]) + + async def test_resource_receives_host_runtime_kwargs(self, chat_client_base: MockBaseChatClient) -> None: + """Host ``function_invocation_kwargs`` must reach a callable resource. + + ``SkillsProvider`` documents ``agent.run(function_invocation_kwargs=...)`` as the + channel for request-scoped values, so a resource that accepts ``**kwargs`` has to + observe them on the real dispatch path. + """ + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="read_skill_resource", + arguments='{"skill_name": "tenant-data", "resource_name": "account-record"}', + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + agent = self._agent(chat_client_base, skill) + session = agent.create_session() + await agent.run( + "Read the account record.", + function_invocation_kwargs={"tenant_id": "host-tenant"}, + session=session, + ) + + assert calls == [{"tenant_id": "host-tenant", "session": session}] + + async def test_resource_runtime_kwargs_do_not_collide_with_dispatcher_names( + self, chat_client_base: MockBaseChatClient + ) -> None: + """Private resource-dispatcher names must remain valid runtime kwarg names.""" + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="read_skill_resource", + arguments='{"skill_name": "tenant-data", "resource_name": "account-record"}', + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + runtime_kwargs = { + "skills": "runtime-skills", + "skill_name": "runtime-skill-name", + "resource_name": "runtime-resource-name", + "runtime_kwargs": "runtime-runtime-kwargs", + } + agent = self._agent(chat_client_base, skill) + session = agent.create_session() + await agent.run( + "Read the account record.", + function_invocation_kwargs=runtime_kwargs, + session=session, + ) + + assert calls == [{**runtime_kwargs, "session": session}] + + async def test_script_receives_host_runtime_kwargs(self, chat_client_base: MockBaseChatClient) -> None: + """Host ``function_invocation_kwargs`` must reach a callable script.""" + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="run_skill_script", + arguments=( + '{"skill_name": "tenant-data", "script_name": "export-report", "args": {"report": "summary"}}' + ), + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + agent = self._agent(chat_client_base, skill) + session = agent.create_session() + await agent.run( + "Export the report.", + function_invocation_kwargs={"tenant_id": "host-tenant"}, + session=session, + ) + + assert calls == [{"tenant_id": "host-tenant", "session": session}] + + async def test_script_runtime_kwargs_do_not_collide_with_dispatcher_names( + self, chat_client_base: MockBaseChatClient + ) -> None: + """Private script-dispatcher names must remain valid runtime kwarg names. + + ``skill`` and ``args`` are deliberately absent: those are parameters of the + public :meth:`SkillScript.run` signature, which still expands runtime kwargs + and therefore still constrains those two names. Closing that gap would change + a public contract, so only the private dispatch chain is covered here. + """ + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="run_skill_script", + arguments=( + '{"skill_name": "tenant-data", "script_name": "export-report", "args": {"report": "summary"}}' + ), + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + runtime_kwargs = { + "skills": "runtime-skills", + "skill_name": "runtime-skill-name", + "script_name": "runtime-script-name", + "runtime_kwargs": "runtime-runtime-kwargs", + } + agent = self._agent(chat_client_base, skill) + session = agent.create_session() + await agent.run( + "Export the report.", + function_invocation_kwargs=runtime_kwargs, + session=session, + ) + + assert calls == [{**runtime_kwargs, "session": session}] + + async def test_resource_rejects_undeclared_model_argument(self, chat_client_base: MockBaseChatClient) -> None: + """A model argument outside the advertised schema must not become a runtime kwarg. + + ``read_skill_resource`` advertises only ``skill_name`` and ``resource_name``. An + extra top-level property returned by the model must be rejected before the + resource runs, otherwise the model — not the host — chooses the tenant. + """ + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="read_skill_resource", + arguments=( + '{"skill_name": "tenant-data", "resource_name": "account-record", "tenant_id": "model-tenant"}' + ), + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + await self._agent(chat_client_base, skill).run( + "Read the account record.", + function_invocation_kwargs={"tenant_id": "host-tenant"}, + ) + + assert calls == [] + + async def test_script_rejects_undeclared_model_argument(self, chat_client_base: MockBaseChatClient) -> None: + """An undeclared top-level model argument must not reach a script as a runtime kwarg. + + Free-form script parameters belong inside the nested ``args`` property, which + stays permissive; the outer schema must not. + """ + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="run_skill_script", + arguments=( + '{"skill_name": "tenant-data", "script_name": "export-report", ' + '"args": {"report": "summary"}, "tenant_id": "model-tenant"}' + ), + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + await self._agent(chat_client_base, skill).run( + "Export the report.", + function_invocation_kwargs={"tenant_id": "host-tenant"}, + ) + + assert calls == [] + + async def test_script_kwargs_also_receive_undeclared_nested_args( + self, chat_client_base: MockBaseChatClient + ) -> None: + """Undeclared entries in the nested ``args`` object still reach a script's ``**kwargs``. + + This pins the deliberate limit of the outer ``additionalProperties: False`` + rule. Scripts declare their own parameters, so ``args`` stays free-form and + :meth:`InlineSkillScript.run` expands it alongside the host runtime kwargs. + A script therefore cannot treat a name in its ``**kwargs`` as host-supplied. + Closing ``args`` would be a deliberate behavior change and should fail here. + """ + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="run_skill_script", + arguments=( + '{"skill_name": "tenant-data", "script_name": "export-report", ' + '"args": {"report": "summary", "region": "model-region"}}' + ), + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + agent = self._agent(chat_client_base, skill) + session = agent.create_session() + await agent.run( + "Export the report.", + function_invocation_kwargs={"tenant_id": "host-tenant"}, + session=session, + ) + + assert calls == [{"tenant_id": "host-tenant", "region": "model-region", "session": session}] + + async def test_model_cannot_override_host_value_through_nested_args( + self, chat_client_base: MockBaseChatClient + ) -> None: + """A model value colliding with a host runtime kwarg must not win. + + ``args`` is free-form, so the model can name a key the host also supplies. + Binding both raises :class:`TypeError` in the script call, which the + function-invocation pipeline turns into a tool error. The point is that the + collision fails closed: the script never runs with the model's value. + """ + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="run_skill_script", + arguments=( + '{"skill_name": "tenant-data", "script_name": "export-report", ' + '"args": {"report": "summary", "tenant_id": "model-tenant"}}' + ), + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + response = await self._agent(chat_client_base, skill).run( + "Export the report.", + function_invocation_kwargs={"tenant_id": "host-tenant"}, + ) + + assert calls == [] + # Assert the tool was actually dispatched and failed on the collision, so this + # cannot pass for the unrelated reason that the call never reached the script. + exceptions = [ + content.exception + for message in response.messages + for content in message.contents + if content.type == "function_result" and content.exception + ] + assert any("multiple values for keyword argument 'tenant_id'" in str(exc) for exc in exceptions) + + async def test_host_runtime_kwargs_win_over_model_argument(self, chat_client_base: MockBaseChatClient) -> None: + """When the model supplies a colliding value, the host value must still be used. + + This is the cross-tenant case: the resource must never be invoked with the + model's tenant, whether the extra argument is rejected outright or ignored. + """ + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="read_skill_resource", + arguments=( + '{"skill_name": "tenant-data", "resource_name": "account-record", "tenant_id": "model-tenant"}' + ), + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + await self._agent(chat_client_base, skill).run( + "Read the account record.", + function_invocation_kwargs={"tenant_id": "host-tenant"}, + ) + + assert all(call.get("tenant_id") != "model-tenant" for call in calls) + + async def test_resource_without_host_kwargs_receives_session(self, chat_client_base: MockBaseChatClient) -> None: + """Without caller-supplied kwargs, only the framework session is forwarded.""" + calls: list[dict[str, Any]] = [] + skill = self._tenant_skill(calls) + + chat_client_base.run_responses = [ + _function_call_response( + call_id="call_1", + name="read_skill_resource", + arguments='{"skill_name": "tenant-data", "resource_name": "account-record"}', + ), + ChatResponse(messages=[Message(role="assistant", contents=["Done!"])]), + ] + + agent = self._agent(chat_client_base, skill) + session = agent.create_session() + await agent.run("Read the account record.", session=session) + + assert calls == [{"session": session}] + + @pytest.mark.parametrize( + "tool_name", + ["load_skill", "read_skill_resource", "run_skill_script"], + ) + async def test_outer_schema_forbids_additional_properties(self, tool_name: str) -> None: + """Each skill tool must close its outer schema to undeclared model arguments. + + ``_validate_arguments_against_schema`` rejects unexpected arguments only when + ``additionalProperties`` is explicitly ``False``, so omitting the flag silently + admits them into the handler's ``**kwargs``. + """ + skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body") + skill._scripts.append(InlineSkillScript(name="s1", function=lambda: None)) + + provider = SkillsProvider([skill]) + await _init_provider(provider) + tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == tool_name) + + assert tool.parameters().get("additionalProperties") is False + + async def test_script_args_property_still_allows_free_form_values(self) -> None: + """Closing the outer schema must not restrict the nested ``args`` property. + + Scripts declare their own parameters, so ``args`` intentionally stays open. + """ + skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body") + skill._scripts.append(InlineSkillScript(name="s1", function=lambda: None)) + + provider = SkillsProvider([skill]) + await _init_provider(provider) + tool = next(t for t in _ctx(provider)[2] if hasattr(t, "name") and t.name == "run_skill_script") + + args_schema = tool.parameters()["properties"]["args"] + object_variant = next(v for v in args_schema["oneOf"] if v.get("type") == "object") + assert object_variant["additionalProperties"] is True + + async def test_resource_handler_declares_invocation_context(self) -> None: + """The provider's tool handlers must opt in to trusted context injection. + + ``FunctionTool`` forwards host runtime kwargs only to handlers that declare a + :class:`FunctionInvocationContext` parameter; without one the trusted values are + dropped before the handler runs. + """ + skill = InlineSkill(frontmatter=SkillFrontmatter(name="my-skill", description="test"), instructions="body") + skill._scripts.append(InlineSkillScript(name="s1", function=lambda: None)) + + provider = SkillsProvider([skill]) + await _init_provider(provider) + tools = {t.name: t for t in _ctx(provider)[2] if hasattr(t, "name")} + + assert tools["read_skill_resource"]._context_parameter_name is not None + assert tools["run_skill_script"]._context_parameter_name is not None diff --git a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py index c3589b4b2a5..d6bb4b72ac0 100644 --- a/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py +++ b/python/samples/02-agents/skills/code_defined_skill/code_defined_skill.py @@ -38,6 +38,14 @@ Attach a callable script via the @skill.script decorator. Scripts are executable functions the agent can invoke directly in-process. +Resources and scripts that accept ``**kwargs`` also receive host-supplied +runtime context from ``agent.run(..., function_invocation_kwargs={...})``. +This sample passes ``precision`` that way. Scripts additionally receive the +model-supplied nested ``args`` dictionary: declared parameters bind by name, +and extra entries can enter the callback's ``**kwargs``. Unlike resource +callbacks, script callbacks therefore do not have a runtime-only ``**kwargs`` +mapping. + Code-defined skills can be combined with file-based skills in a single SkillsProvider — see the mixed_skills sample. """ @@ -95,12 +103,23 @@ def conversion_policy(**kwargs: Any) -> Any: When the resource function accepts ``**kwargs``, runtime keyword arguments passed to ``agent.run()`` are forwarded automatically. + These runtime values are *host-controlled request context*: they come only + from the application calling ``agent.run()``, never from the model. That + distinction matters for values that select authority — a tenant ID, a user + ID, or an auth token — so this resource treats a missing ``precision`` as a + bug rather than silently falling back to a default and masking it. + Args: **kwargs: Runtime keyword arguments from ``agent.run()``. For example, ``agent.run(..., function_invocation_kwargs={"precision": 2})`` makes ``kwargs["precision"]`` available here. """ - precision = kwargs.get("precision", 4) + if "precision" not in kwargs: + raise RuntimeError( + "Expected host-supplied 'precision' in runtime kwargs. Runtime context must reach " + "resources via agent.run(function_invocation_kwargs=...)." + ) + precision = kwargs["precision"] return dedent(f"""\ # Conversion Policy @@ -119,17 +138,27 @@ def convert_units(value: float, factor: float, **kwargs: Any) -> str: The caller looks up the correct factor from the conversion-tables resource and passes it here. + The model supplies ``value`` and ``factor`` through the script's nested + ``args`` dictionary, while ``main()`` supplies ``precision`` through + ``function_invocation_kwargs``. Both dictionaries are expanded into this + callback, so additional nested ``args`` entries can also enter ``**kwargs``. + Checking for ``precision`` below verifies its presence, not its source. + Args: value: The numeric value to convert. factor: Conversion factor from the conversion table. - **kwargs: Runtime keyword arguments from ``agent.run()``. - The ``precision`` kwarg controls how many decimal places - the result is rounded to (default 4). + **kwargs: Runtime keyword arguments from ``agent.run()`` and any extra + entries in the script's nested ``args`` dictionary. The ``precision`` + kwarg controls how many decimal places the result is rounded to. Returns: JSON string with the inputs and converted result. """ - precision = kwargs.get("precision", 4) + if "precision" not in kwargs: + raise RuntimeError( + "This sample requires 'precision'. main() supplies it through agent.run(function_invocation_kwargs=...)." + ) + precision = kwargs["precision"] result = round(value * factor, precision) return json.dumps({"value": value, "factor": factor, "result": result}) From 301a4ae386570c279f6a54734992bd0475f33920 Mon Sep 17 00:00:00 2001 From: Ricky-7-Yan <2314530442@qq.com> Date: Tue, 15 Sep 2026 14:56:32 +0000 Subject: [PATCH 090/108] .NET: Preserve hosted session key boundaries (#8263) * fix: preserve hosted session key boundaries Signed-off-by: Ricky-7-Yan <2314530442@qq.com> * test(hosting): extend session key harness Cover identifier boundaries, escaping, streaming, and session reuse. Fix missing test imports and document opaque hosted identifiers. Refs #8251 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 165465df-61e4-45eb-a147-08b425b4f5bd * perf(hosting): reuse composite session keys Use tuple lookups in the session harness to avoid serialization and throwaway session construction on cache hits. Preserve existing JSON session identifiers by encoding only when a session is created. Refs #8251 * docs(hosting): keep composite session IDs opaque Describe the identifier contract without exposing its internal representation as a compatibility guarantee. Refs #8251 --------- Signed-off-by: Ricky-7-Yan <2314530442@qq.com> Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 165465df-61e4-45eb-a147-08b425b4f5bd --- python/packages/foundry_hosting/README.md | 6 + .../_invocations.py | 22 ++- .../foundry_hosting/tests/test_invocations.py | 128 +++++++++++++++++- .../invocations/break_glass/main.py | 23 ++-- 4 files changed, 160 insertions(+), 19 deletions(-) diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index 33264ec47c3..9f85be244b5 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -86,6 +86,12 @@ Native Responses refusal parts are stored as text carrying `additional_properties["model_output_kind"] == "refusal"` and emitted as `response.refusal.*` events when streamed back to clients. +`InvocationsHostServer` keeps sessions in memory. When hosted, `AgentSession.session_id` is an +opaque composite identifier that preserves the boundaries between the platform session ID +and user ID. Consumers must use it as a whole and must not parse it or depend on its internal +representation. Repeated requests for the same identifier pair reuse the session. Locally, +the platform session ID is used unchanged. + ### Workflow checkpoints `ResponsesHostServer` persists workflow checkpoints durably. By default, it uses the diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index 2b321c67046..cd539da4fec 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -1,5 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. +import json + from agent_framework import AgentSession, SupportsAgentRun from agent_framework._telemetry import mark_feature_used from azure.ai.agentserver.core import get_request_context @@ -35,15 +37,15 @@ def __init__( super().__init__(openapi_spec=openapi_spec, **kwargs) self._agent = agent - self._sessions: dict[str, AgentSession] = {} + self._sessions: dict[str | tuple[str, str], AgentSession] = {} self.invoke_handler(self._handle_invoke) mark_feature_used(FeatureIndex.FOUNDRY_HOSTING) - def _partition_key(self) -> str: + def _partition_key(self) -> str | tuple[str, str]: """Get the partition key for the current request. - A partition key is made up of the session ID and user ID. If the request is not - from a hosted environment, the partition key will be just the session ID. In the + A hosted partition key is a tuple containing the session ID and user ID, + preserving their boundaries. Locally, the key is just the session ID. In the Foundry hosted environment, the partition key is used to maintain isolation between different sessions and users, such that one user cannot access another user's sessions. @@ -61,7 +63,7 @@ def _partition_key(self) -> str: "The hosted environment is missing session_id or user_id in the request context. " "Please ensure that the request is coming from a valid Foundry platform service." ) - return f"{context.session_id}:{context.user_id}" + return context.session_id, context.user_id if not context.session_id: raise RuntimeError( @@ -73,7 +75,7 @@ def _partition_key(self) -> str: async def _handle_invoke(self, request: Request) -> Response: """Invoke the agent with the given request.""" try: - session_id = self._partition_key() + partition_key = self._partition_key() except Exception as e: return Response(content=str(e), status_code=500) @@ -87,7 +89,13 @@ async def _handle_invoke(self, request: Request) -> Response: return StreamingResponse(content=error, status_code=400) return Response(content=error, status_code=400) - session = self._sessions.setdefault(session_id, AgentSession(session_id=session_id)) + session = self._sessions.get(partition_key) + if session is None: + session_id = ( + json.dumps(partition_key, separators=(",", ":")) if isinstance(partition_key, tuple) else partition_key + ) + session = AgentSession(session_id=session_id) + self._sessions[partition_key] = session if stream: diff --git a/python/packages/foundry_hosting/tests/test_invocations.py b/python/packages/foundry_hosting/tests/test_invocations.py index d5f8b180230..95857ab7d5b 100644 --- a/python/packages/foundry_hosting/tests/test_invocations.py +++ b/python/packages/foundry_hosting/tests/test_invocations.py @@ -11,9 +11,11 @@ from __future__ import annotations +import json from collections.abc import AsyncIterator, Iterator from contextlib import contextmanager -from unittest.mock import AsyncMock, MagicMock +from itertools import product +from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ( @@ -169,11 +171,20 @@ def test_local_missing_session_id_raises(self) -> None: with _request_context(), pytest.raises(RuntimeError, match="missing session_id"): server._partition_key() # pyright: ignore[reportPrivateUsage] - def test_hosted_missing_user_id_raises(self) -> None: + def test_local_ignores_user_id(self) -> None: + server = InvocationsHostServer(_make_agent(response_text="hi")) + with _request_context(session_id="sess-1", user_id="user-1"): + assert server._partition_key() == "sess-1" # pyright: ignore[reportPrivateUsage] + + @pytest.mark.parametrize( + ("session_id", "user_id"), + [(None, "user-1"), ("", "user-1"), ("sess-1", None), ("sess-1", ""), (None, None)], + ) + def test_hosted_requires_both_identifiers(self, session_id: str | None, user_id: str | None) -> None: server = InvocationsHostServer(_make_agent(response_text="hi")) server.config.is_hosted = True with ( - _request_context(call_id="call-1", session_id="sess-1"), + _request_context(call_id="call-1", session_id=session_id, user_id=user_id), pytest.raises(RuntimeError, match="missing session_id or user_id"), ): server._partition_key() # pyright: ignore[reportPrivateUsage] @@ -182,7 +193,31 @@ def test_hosted_returns_composite_key(self) -> None: server = InvocationsHostServer(_make_agent(response_text="hi")) server.config.is_hosted = True with _request_context(call_id="call-1", session_id="sess-1", user_id="user-1"): - assert server._partition_key() == "sess-1:user-1" # pyright: ignore[reportPrivateUsage] + assert server._partition_key() == ("sess-1", "user-1") # pyright: ignore[reportPrivateUsage] + + async def test_hosted_keys_and_session_ids_preserve_identifier_values(self) -> None: + agent = _make_agent(response_text="hi") + server = InvocationsHostServer(agent) + server.config.is_hosted = True + identifiers = ["part", "part:part", "part,part", "[part]", 'part"\\', "part\n\t", "\u00e9", r"\u00e9", " part "] + keys: set[tuple[str, str]] = set() + request = _make_request({"message": "Hi"}) + + for session_id, user_id in product(identifiers, repeat=2): + with _request_context(call_id="call-1", session_id=session_id, user_id=user_id): + key = server._partition_key() # pyright: ignore[reportPrivateUsage] + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + + assert isinstance(key, tuple) + assert key == (session_id, user_id) + assert key not in keys + keys.add(key) + assert response.status_code == 200 + session = agent.calls[-1]["session"] + assert isinstance(session, AgentSession) + expected_id = json.dumps([session_id, user_id], separators=(",", ":")) + assert session.session_id == expected_id + assert session.to_dict()["session_id"] == expected_id # endregion @@ -192,6 +227,32 @@ def test_hosted_returns_composite_key(self) -> None: class TestHandleInvoke: + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize("hosted", [False, True]) + async def test_reusing_session_skips_serialization_and_construction(self, hosted: bool, stream: bool) -> None: + agent = _make_agent(response_text="ok", stream_texts=["ok"]) + server = InvocationsHostServer(agent) + server.config.is_hosted = hosted + request = _make_request({"message": "Hi", "stream": stream}) + expected_id = '["sess-1","user-1"]' if hosted else "sess-1" + + with ( + _request_context(call_id="call-1", session_id="sess-1", user_id="user-1"), + patch("agent_framework_foundry_hosting._invocations.json", wraps=json) as serializer, + patch("agent_framework_foundry_hosting._invocations.AgentSession", wraps=AgentSession) as session_factory, + ): + for _ in range(2): + response = await server._handle_invoke(request) # pyright: ignore[reportPrivateUsage] + if isinstance(response, StreamingResponse): + assert await _collect_stream(response) == "ok" + else: + assert bytes(response.body).decode() == "ok" + + assert agent.calls[0]["session"] is agent.calls[1]["session"] + assert agent.calls[0]["session"].session_id == expected_id + assert serializer.dumps.call_count == (1 if hosted else 0) + session_factory.assert_called_once_with(session_id=expected_id) + async def test_missing_message_returns_400(self) -> None: server = InvocationsHostServer(_make_agent(response_text="hi")) request = _make_request({"stream": False}) @@ -259,5 +320,64 @@ async def test_session_is_reused_across_requests(self) -> None: assert list(server._sessions) == ["sess-1"] # pyright: ignore[reportPrivateUsage] assert agent.calls[0]["session"] is agent.calls[1]["session"] + @pytest.mark.parametrize("stream", [False, True]) + @pytest.mark.parametrize( + ("first_session_id", "first_user_id", "second_session_id", "second_user_id"), + [ + ("session:segment", "user", "session", "segment:user"), + ("session,segment", "user", "session", "segment,user"), + ("session", "first-user", "session", "second-user"), + ("first-session", "user", "second-session", "user"), + ], + ) + async def test_hosted_sessions_preserve_identifier_boundaries( + self, + stream: bool, + first_session_id: str, + first_user_id: str, + second_session_id: str, + second_user_id: str, + ) -> None: + agent = _make_agent(response_text="ok", stream_texts=["ok"]) + server = InvocationsHostServer(agent) + server.config.is_hosted = True + identifiers = [(first_session_id, first_user_id), (second_session_id, second_user_id)] + sessions: list[AgentSession] = [] + + for session_id, user_id in identifiers: + with _request_context(call_id="call-1", session_id=session_id, user_id=user_id): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "Hi", "stream": stream}) + ) + if isinstance(response, StreamingResponse): + assert await _collect_stream(response) == "ok" + else: + assert bytes(response.body).decode() == "ok" + assert response.status_code == 200 + + session = agent.calls[-1]["session"] + assert isinstance(session, AgentSession) + assert session.state == {} + session.state["turn"] = (session_id, user_id) + sessions.append(session) + + assert sessions[0] is not sessions[1] + assert sessions[0].session_id != sessions[1].session_id + assert len(server._sessions) == 2 # pyright: ignore[reportPrivateUsage] + + for (session_id, user_id), session in zip(identifiers, sessions): + with _request_context(call_id="call-2", session_id=session_id, user_id=user_id): + response = await server._handle_invoke( # pyright: ignore[reportPrivateUsage] + _make_request({"message": "Continue", "stream": stream}) + ) + if isinstance(response, StreamingResponse): + assert await _collect_stream(response) == "ok" + else: + assert bytes(response.body).decode() == "ok" + assert response.status_code == 200 + + assert agent.calls[-1]["session"] is session + assert session.state == {"turn": (session_id, user_id)} + # endregion diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/break_glass/main.py b/python/samples/04-hosting/foundry-hosted-agents/invocations/break_glass/main.py index 102734a9e17..18e5e2f7a0e 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/invocations/break_glass/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/break_glass/main.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import json import os from collections.abc import AsyncGenerator @@ -16,9 +17,9 @@ load_dotenv() -# In-memory session store — keyed by session ID. +# In-memory session store — keyed by session and user IDs when a user is present. # WARNING: This is lost on restart. Use durable storage in production. -_sessions: dict[str, AgentSession] = {} +_sessions: dict[str | tuple[str, str], AgentSession] = {} # Create the agent client = FoundryChatClient( @@ -39,11 +40,11 @@ app = InvocationAgentServerHost() -def get_session_partition_key() -> str: +def get_session_partition_key() -> str | tuple[str, str]: """Get the partition key for the current request. - A partition key is made up of the session ID and user ID. If the request is not - from a hosted environment, the partition key will be just the session ID. In the + A partition key is a tuple containing the session ID and user ID when a user ID + is present, preserving their boundaries. Otherwise, the key is just the session ID. In the Foundry hosted environment, the partition key is used to maintain isolation between different sessions and users, such that one user cannot access another user's sessions. @@ -56,7 +57,7 @@ def get_session_partition_key() -> str: "The request context is missing session_id. Please ensure that the request is a valid request." ) if context.user_id is not None: - return f"{context.session_id}:{context.user_id}" + return context.session_id, context.user_id return context.session_id @@ -72,8 +73,14 @@ async def handle_invoke(request: Request): return StreamingResponse(content=error, status_code=400) return Response(content=error, status_code=400) - session_id = get_session_partition_key() - session = _sessions.setdefault(session_id, AgentSession(session_id=session_id)) + partition_key = get_session_partition_key() + session = _sessions.get(partition_key) + if session is None: + session_id = ( + json.dumps(partition_key, separators=(",", ":")) if isinstance(partition_key, tuple) else partition_key + ) + session = AgentSession(session_id=session_id) + _sessions[partition_key] = session if stream: From 0e277164c6955ecc903c9e2c16cfec2866988f8a Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 15 Sep 2026 14:56:46 +0000 Subject: [PATCH 091/108] Python: Reuse registered tools in Monty callbacks (#8395) --- python/packages/monty/README.md | 8 ++ .../_execute_code_tool.py | 3 +- .../monty/tests/monty/test_monty_codeact.py | 113 ++++++++++++++++++ .../monty/test_monty_codeact_integration.py | 55 ++++++++- 4 files changed, 176 insertions(+), 3 deletions(-) diff --git a/python/packages/monty/README.md b/python/packages/monty/README.md index 47b781098f4..d6f5a8475a4 100644 --- a/python/packages/monty/README.md +++ b/python/packages/monty/README.md @@ -105,6 +105,14 @@ agent = Agent( ) ``` +### Host tool lifetime + +Registered `FunctionTool` instances retain their invocation and exception counters +across `execute_code` calls and provider runs. Their `max_invocations` and +`max_invocation_exceptions` limits use the same counters as direct invocations of +those instances. A provider's run-scoped snapshot captures tool membership; it +does not reset host tool counters. + ### File mounts and resource limits Mount host directories into the sandbox and cap execution resources: diff --git a/python/packages/monty/agent_framework_monty/_execute_code_tool.py b/python/packages/monty/agent_framework_monty/_execute_code_tool.py index 4d5e957e4a8..18e0ab85bd0 100644 --- a/python/packages/monty/agent_framework_monty/_execute_code_tool.py +++ b/python/packages/monty/agent_framework_monty/_execute_code_tool.py @@ -15,7 +15,6 @@ import json import mimetypes from collections.abc import Callable, Iterator, Sequence -from copy import copy from functools import partial from pathlib import Path, PurePosixPath from typing import Any, cast @@ -161,7 +160,7 @@ def _make_tool_callback(tool_obj: FunctionTool) -> Callable[..., Any]: receives real Python objects. ``FunctionTool.invoke`` accepts direct keyword arguments and handles both sync and async underlying functions internally. """ - return partial(copy(tool_obj).invoke, skip_parsing=True) + return partial(tool_obj.invoke, skip_parsing=True) class MontyExecuteCodeTool(FunctionTool): diff --git a/python/packages/monty/tests/monty/test_monty_codeact.py b/python/packages/monty/tests/monty/test_monty_codeact.py index b1ccb3828e0..35548130ae4 100644 --- a/python/packages/monty/tests/monty/test_monty_codeact.py +++ b/python/packages/monty/tests/monty/test_monty_codeact.py @@ -9,6 +9,7 @@ from __future__ import annotations +import asyncio import json import sys import types @@ -21,6 +22,7 @@ import pytest from agent_framework import Content, FunctionTool, Message, tool from agent_framework._sessions import SessionContext +from agent_framework.exceptions import ToolException from agent_framework_monty import MontyCodeActProvider, MontyExecuteCodeTool from agent_framework_monty import _execute_code_tool as execute_code_module @@ -851,3 +853,114 @@ def adder(a: Annotated[int, ""], b: Annotated[int, ""]) -> int: cid, payload = await bridge._invoke_tool(7, "adder", {"a": 6, "b": 7}) assert cid == 7 assert payload == {"return_value": 13}, payload + + +async def test_tool_callbacks_share_registered_invocation_limit() -> None: + @tool(max_invocations=1) + def limited() -> int: + return 42 + + first = execute_code_module._make_tool_callback(limited) + second = execute_code_module._make_tool_callback(limited) + assert await first() == 42 + assert limited.invocation_count == 1 + for callback in (first, second, execute_code_module._make_tool_callback(limited)): + with pytest.raises(ToolException, match="maximum invocation limit"): + await callback() + assert limited.invocation_count == 1 + + limited.invocation_count = 0 + assert await second() == 42 + assert limited.invocation_count == 1 + + +@pytest.mark.parametrize("is_async", [False, True]) +async def test_tool_callbacks_share_registered_exception_limit(is_async: bool) -> None: + def fail() -> int: + raise ValueError("expected failure") + + async def async_fail() -> int: + await asyncio.sleep(0) + return fail() + + failing = FunctionTool(name="failing", func=async_fail if is_async else fail, max_invocation_exceptions=1) + with pytest.raises(ValueError, match="expected failure"): + await execute_code_module._make_tool_callback(failing)() + with pytest.raises(ToolException, match="maximum exception limit"): + await execute_code_module._make_tool_callback(failing)() + assert failing.invocation_count == 1 + assert failing.invocation_exception_count == 1 + + +async def test_concurrent_tool_callbacks_share_registered_invocation_limit() -> None: + @tool(max_invocations=1) + async def limited() -> int: + await asyncio.sleep(0) + return 42 + + bridges = [ + bridge_module.InlineCodeBridge({"limited": execute_code_module._make_tool_callback(limited)}) for _ in range(8) + ] + results = await asyncio.gather(*(bridge._invoke_tool(index, "limited", {}) for index, bridge in enumerate(bridges))) + + assert sum(payload == {"return_value": 42} for _, payload in results) == 1 + assert sum(payload.get("exc_type") == "ToolException" for _, payload in results) == 7 + assert limited.invocation_count == 1 + + +async def test_tool_callback_preserves_bound_instance_and_result_parser() -> None: + parser = MagicMock(return_value=[Content.from_text("parsed")]) + + class Counter: + def __init__(self) -> None: + self.value = 0 + + @tool(max_invocations=1, result_parser=parser) + def increment(self) -> int: + self.value += 1 + return self.value + + first_owner = Counter() + second_owner = Counter() + first_tool = first_owner.increment + second_tool = second_owner.increment + + assert await execute_code_module._make_tool_callback(first_tool)() == 1 + with pytest.raises(ToolException, match="maximum invocation limit"): + await execute_code_module._make_tool_callback(first_tool)() + assert await execute_code_module._make_tool_callback(second_tool)() == 1 + assert first_owner.value == second_owner.value == 1 + assert first_tool.invocation_count == second_tool.invocation_count == 1 + assert first_tool.result_parser is parser + parser.assert_not_called() + + first_tool.invocation_count = 0 + assert await first_tool.invoke() == [Content.from_text("parsed")] + parser.assert_called_once_with(2) + + +async def test_provider_runs_share_registered_tool_invocation_limit() -> None: + @tool(max_invocations=1) + def limited() -> int: + return 42 + + provider = MontyCodeActProvider(tools=[limited]) + for run_index in range(2): + context = SessionContext(input_messages=[]) + await provider.before_run(agent=MagicMock(), session=None, context=context, state={}) + run_tool = context.tools[0] + assert isinstance(run_tool, MontyExecuteCodeTool) + assert run_tool.get_tools()[0] is limited + script = _set_script( + _FakeFunctionSnapshot(function_name="limited", call_id=1), + _FakeFutureSnapshot(pending_call_ids=[1]), + _FakeMontyComplete(), + ) + await run_tool._run_code(code="await limited()") + payload = script.resume_log[-1][2][1] + if run_index == 0: + assert payload == {"return_value": 42} + else: + assert payload["exc_type"] == "ToolException" + assert "maximum invocation limit" in payload["message"] + assert limited.invocation_count == 1 diff --git a/python/packages/monty/tests/monty/test_monty_codeact_integration.py b/python/packages/monty/tests/monty/test_monty_codeact_integration.py index 2eeffb02a67..c52fb0e00ee 100644 --- a/python/packages/monty/tests/monty/test_monty_codeact_integration.py +++ b/python/packages/monty/tests/monty/test_monty_codeact_integration.py @@ -17,7 +17,7 @@ from unittest.mock import MagicMock import pytest -from agent_framework import Agent, Content, Message, tool +from agent_framework import Agent, Content, FunctionTool, Message, tool from agent_framework._sessions import SessionContext from agent_framework_monty import MontyCodeActProvider, MontyExecuteCodeTool @@ -138,11 +138,64 @@ async def test_async_host_tool_is_awaited() -> None: assert any("ping" in text for text in texts) +@pytest.mark.parametrize("is_async", [False, True]) +@pytest.mark.parametrize("call", ["await limited()", "await call_tool('limited')"]) +async def test_host_tool_invocation_limit_across_code_executions(is_async: bool, call: str) -> None: + calls: list[int] = [] + + def limited() -> int: + calls.append(42) + return 42 + + async def async_limited() -> int: + await asyncio.sleep(0) + return limited() + + host_tool = FunctionTool(name="limited", func=async_limited if is_async else limited, max_invocations=1) + monty_tool = MontyExecuteCodeTool(tools=[host_tool]) + + first = await monty_tool._run_code(code=call) + assert _text_outputs(first) == ["42"] + for _ in range(2): + result = await monty_tool._run_code(code=call) + assert any(content.type == "error" for content in result) + assert calls == [42] + assert host_tool.invocation_count == 1 + + host_tool.invocation_count = 0 + result = await monty_tool._run_code(code=f"{call}\n{call}") + assert any(content.type == "error" for content in result) + assert calls == [42, 42] + assert host_tool.invocation_count == 1 + + # --------------------------------------------------------------------------- # Concurrency # --------------------------------------------------------------------------- +@pytest.mark.parametrize("separate_executions", [False, True]) +async def test_concurrent_code_calls_share_host_tool_invocation_limit(separate_executions: bool) -> None: + calls: list[int] = [] + + @tool(max_invocations=1) + async def limited() -> int: + calls.append(42) + await asyncio.sleep(0) + return 42 + + monty_tool = MontyExecuteCodeTool(tools=[limited]) + if separate_executions: + results = await asyncio.gather(*(monty_tool._run_code(code="await limited()") for _ in range(4))) + assert sum(_text_outputs(result) == ["42"] for result in results) == 1 + assert sum(any(content.type == "error" for content in result) for result in results) == 3 + else: + result = await monty_tool._run_code(code="await asyncio.gather(limited(), limited(), limited(), limited())") + assert any(content.type == "error" for content in result) + assert calls == [42] + assert limited.invocation_count == 1 + + async def test_asyncio_gather_fans_out_tool_calls_concurrently() -> None: """Two async tools dispatched via ``asyncio.gather`` should run on the event loop in parallel. From ab05d32e36fd1c71848238c34fe5270ccb48916c Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 15 Sep 2026 15:09:43 +0000 Subject: [PATCH 092/108] docs: Clarify Cosmos history provider storage scope (#8391) --- .../_history_provider.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index 03d7617f1e1..b62c5467ad8 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -36,7 +36,20 @@ class AzureCosmosHistorySettings(TypedDict, total=False): class CosmosHistoryProvider(HistoryProvider): - """Azure Cosmos DB-backed history provider using HistoryProvider hooks.""" + """Azure Cosmos DB-backed history provider using HistoryProvider hooks. + + Providers using the same Cosmos DB account, database, and container with + identical ``source_id`` and non-empty ``session_id`` values access the same + persisted history for reads, writes, and clearing. This supports resuming + conversations across provider instances. ``session_id`` is the partition key; + ``source_id`` filters history within that partition. + + These identifiers select stored history; they are not authentication or + authorization boundaries. Applications must bind them to authenticated and + authorized context and use distinct, trusted namespaces when isolation is + intended. Different identifiers prevent accidental overlap but do not restrict + a client whose Cosmos DB credentials already authorize access to that data. + """ DEFAULT_SOURCE_ID: ClassVar[str] = "azure_cosmos_history" _BATCH_OPERATION_LIMIT: ClassVar[int] = 100 @@ -62,7 +75,8 @@ def __init__( """Initialize the Azure Cosmos DB history provider. Args: - source_id: Unique identifier for this provider instance. + source_id: Provider identifier used to scope stored history within a session. + Defaults to ``azure_cosmos_history`` and is shared across instances. load_messages: Whether to load messages before invocation. store_outputs: Whether to store response messages. store_inputs: Whether to store input messages. From a876d142932fdd2eb616c85ac47beb20a56a3ebe Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 15 Sep 2026 15:09:53 +0000 Subject: [PATCH 093/108] Python: Clarify MCP annotation override scope (#8399) --- python/packages/core/agent_framework/security.py | 14 +++++++++++--- .../02-agents/security/FIDES_DEVELOPER_GUIDE.md | 6 ++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index 9278ff3b292..12e0a597e00 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -4077,7 +4077,10 @@ async def apply_mcp_security_labels( annotation_overrides: Optional per-tool-name overrides. Keys are *remote* MCP tool names (as the server exposes them). Values are ``(IntegrityLabel, ConfidentialityLabel | None)`` tuples that - replace the annotation-derived labels entirely. + replace the annotation-derived labels entirely. Overrides apply + only to the supplied ``mcp_tool``; the mapping itself is not bound + to a server identity. Reuse it for another connection only after + independently authorizing that policy for the other server's tools. mark_write_tools_as_sinks: When ``True`` (default), apply the annotation-derived ``max_allowed_confidentiality=PUBLIC`` cap. trust_server_ifc: Whether complete, valid server ``_meta.ifc`` labels @@ -4348,7 +4351,9 @@ class SecureMCPToolProxy: description: Tool description for the internal tool. default_integrity: Default integrity for tools without annotations. annotation_overrides: Per-tool-name label overrides (keyed by remote - MCP tool name). + MCP tool name), applied only to this proxy's MCP connection. + The mapping is not bound to a server identity; reuse it for another + connection only after independently authorizing that policy there. mark_write_tools_as_sinks: Whether to apply the annotation-derived PUBLIC confidentiality cap. trust_server_ifc: Whether complete, valid server ``_meta.ifc`` labels @@ -4387,7 +4392,10 @@ def __init__( description: Tool description for the internal tool. default_integrity: Default integrity for tools without annotations. Defaults to ``IntegrityLabel.UNTRUSTED``. - annotation_overrides: Per-tool-name label overrides keyed by remote MCP tool name. + annotation_overrides: Per-tool-name label overrides keyed by remote MCP tool name, + applied only to this proxy's MCP connection. The mapping is not bound to a + server identity; reuse it for another connection only after independently + authorizing that policy there. mark_write_tools_as_sinks: Whether to apply the annotation-derived PUBLIC confidentiality cap. Defaults to ``True``. trust_server_ifc: Whether complete, valid server ``_meta.ifc`` diff --git a/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md b/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md index 44f8eefee0f..5313d2f6b9c 100644 --- a/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md +++ b/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md @@ -493,6 +493,12 @@ ToolAnnotations authoritative: `readOnlyHint` and `openWorldHint` remain restric secure_mcp = SecureMCPToolProxy(url="https://trusted.example.com/mcp/", trust_server_ifc=True) ``` +`annotation_overrides` supplies explicit local policy keyed by remote MCP tool name. It applies only to the +`MCPTool` passed to `apply_mcp_security_labels`, or the connection wrapped by `SecureMCPToolProxy`; the mapping +itself is not bound to a server identity. Reusing a mapping for another connection applies its overrides to +matching tool names on that connection. Independently authorize the policy for each server's tools before +reusing it; a shared tool name does not establish shared trust. + #### Operational checklist 1. Prefer `async with SecureMCPToolProxy(...)` so connection and label application happen together. From 6c3c58a4b2d8834ad3dce3d0b01ad10f7edaf352 Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan <35326118+manjunathshiva@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:27:26 +0000 Subject: [PATCH 094/108] .NET: Preserve cached and reasoning token counts in Foundry Hosting (#8334) --- .../OutputConverter.cs | 9 +- .../OutputConverterTests.cs | 101 ++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index 2a4682b5296..8cbb0aa2ce5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -557,10 +557,11 @@ private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? e var outputTokens = details.OutputTokenCount ?? 0; var totalTokens = details.TotalTokenCount ?? 0; - var cachedTokens = details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) ?? false - ? cached : 0; - var reasoningTokens = details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) ?? false - ? reasoning : 0; + // Prefer the dedicated counters, retaining the legacy dictionary keys as a fallback. + var cachedTokens = details.CachedInputTokenCount ?? + (details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) is true ? cached : 0); + var reasoningTokens = details.ReasoningTokenCount ?? + (details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) is true ? reasoning : 0); if (existing is not null) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index db036cfa73f..0aca1b238eb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -184,6 +184,107 @@ public async Task ConvertUpdatesToEventsAsync_UsageContent_IncludesUsageInComple Assert.NotNull(completedEvent); } + [Theory] + [InlineData(34304L, 128L, null, null, 34304L, 128L)] + [InlineData(34304L, 128L, 999L, 999L, 34304L, 128L)] + [InlineData(0L, 0L, 999L, 999L, 0L, 0L)] + [InlineData(null, null, null, null, 0L, 0L)] + [InlineData(null, null, 34304L, 128L, 34304L, 128L)] + [InlineData(34304L, null, 999L, 128L, 34304L, 128L)] + [InlineData(null, 128L, 34304L, 999L, 34304L, 128L)] + public async Task ConvertUpdatesToEventsAsync_UsageCounters_PreservesDetailsAsync( + long? cachedTokens, long? reasoningTokens, long? legacyCachedTokens, long? legacyReasoningTokens, + long expectedCachedTokens, long expectedReasoningTokens) + { + // Arrange + var (stream, _) = CreateTestStream(); + var details = new UsageDetails + { + InputTokenCount = 34847, + OutputTokenCount = 1000, + TotalTokenCount = 35847, + CachedInputTokenCount = cachedTokens, + ReasoningTokenCount = reasoningTokens, + }; + if (legacyCachedTokens is { } cached) + { + (details.AdditionalCounts ??= [])["InputTokenDetails.CachedTokenCount"] = cached; + } + + if (legacyReasoningTokens is { } reasoning) + { + (details.AdditionalCounts ??= [])["OutputTokenDetails.ReasoningTokenCount"] = reasoning; + } + + var updates = new[] { new AgentResponseUpdate { Contents = [new UsageContent(details)] } }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + var completed = Assert.Single(events.OfType()); + var usage = Assert.IsType(completed.Response.Usage); + Assert.Equal(34847, usage.InputTokens); + Assert.Equal(1000, usage.OutputTokens); + Assert.Equal(35847, usage.TotalTokens); + Assert.Equal(expectedCachedTokens, usage.InputTokensDetails.CachedTokens); + Assert.Equal(expectedReasoningTokens, usage.OutputTokensDetails.ReasoningTokens); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConvertUpdatesToEventsAsync_UsageCounters_AccumulatesInTerminalEventAsync(bool fail) + { + // Arrange + var (stream, _) = CreateTestStream(); + var updates = new List + { + new() + { + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 100, OutputTokenCount = 10, TotalTokenCount = 110, + CachedInputTokenCount = 64, ReasoningTokenCount = 2, + })], + }, + new() + { + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 200, OutputTokenCount = 20, TotalTokenCount = 220, + CachedInputTokenCount = 128, ReasoningTokenCount = 4, + })], + }, + }; + if (fail) + { + updates.Add(new AgentResponseUpdate { Contents = [new ErrorContent("Test failure")] }); + } + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Assert + var terminal = Assert.Single(events); + var usage = Assert.IsType(fail + ? Assert.IsType(terminal).Response.Usage + : Assert.IsType(terminal).Response.Usage); + Assert.Equal(300, usage.InputTokens); + Assert.Equal(30, usage.OutputTokens); + Assert.Equal(330, usage.TotalTokens); + Assert.Equal(192, usage.InputTokensDetails.CachedTokens); + Assert.Equal(6, usage.OutputTokensDetails.ReasoningTokens); + } + [Fact] public async Task ConvertUpdatesToEventsAsync_ReasoningContent_EmitsReasoningEventsAsync() { From 6ad7ba2f62dd3da5cb7c1d31ef195cee9f6b915f Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:27:48 +0000 Subject: [PATCH 095/108] Python/.NET: verify supplied MCP skill archive digests (#8404) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Skills/AgentMcpSkillsSource.cs | 12 +- .../Skills/Loaders/ArchiveEntryLoader.cs | 40 ++- .../Skills/McpSkillIndex.cs | 17 +- .../AgentMcpSkillsSourceArchiveTests.cs | 277 ++++++++++++++++++ python/packages/core/AGENTS.md | 2 + .../packages/core/agent_framework/_skills.py | 40 ++- .../core/tests/core/test_mcp_skills.py | 237 ++++++++++++++- 7 files changed, 609 insertions(+), 16 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs index 8ae78521ad0..ebbf4ec9793 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs @@ -40,6 +40,14 @@ namespace Microsoft.Agents.AI; /// empty list. /// /// +/// Archive entries may supply a digest in the form sha256: followed by 64 lowercase +/// hexadecimal characters. It is verified against the decoded archive bytes before extraction. +/// Malformed, unsupported, or mismatched digest strings are logged as warnings and the affected archives +/// are skipped; omitted or null digests remain supported. Discovery returns the remaining skills, +/// so a successful cache refresh replaces its list without rejected archives. This verification +/// does not apply to lazily fetched skill-md entries or their supporting resources. +/// +/// /// Thread safety and archive reconciliation. For archive-type skills, every call to /// reconciles a shared on-disk directory: it extracts newly advertised /// skills, re-extracts existing ones, and prunes those the server no longer advertises. Because that @@ -70,7 +78,9 @@ namespace Microsoft.Agents.AI; /// adversarial skill content designed to manipulate the agent through indirect prompt injection, or /// instructions/scripts designed to exfiltrate data once loaded and, for script-capable skills, /// executed. Only connect this source to MCP servers you trust, and review archive extraction limits () -/// to bound the impact of a malicious server. +/// to bound the impact of a malicious server. A matching digest proves consistency with the index, +/// not trustworthiness: a server controlling both the index and archive can replace both or omit +/// the digest. /// /// internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Loaders/ArchiveEntryLoader.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Loaders/ArchiveEntryLoader.cs index 1bc1ff2667a..9b4791747f8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Loaders/ArchiveEntryLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Loaders/ArchiveEntryLoader.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -22,7 +24,8 @@ namespace Microsoft.Agents.AI; /// /// Because MCP-delivered skills are treated strictly as instructor-format text, scripts bundled /// inside an archive are surfaced as readable resources only; they are never discovered as -/// executable scripts. +/// executable scripts. Supplied SHA-256 digests are verified before extraction; archives without +/// a digest remain supported. /// internal sealed partial class ArchiveEntryLoader : IMcpSkillEntryLoader, IDisposable { @@ -256,7 +259,8 @@ private async Task> TryDownloadAndExtractSkillAsync(McpSki /// /// Downloads and decodes the binary content of a skill's archive resource. Returns - /// bytes when the resource cannot be read, contains no binary content, or is empty. + /// bytes when the resource cannot be read, contains no binary content, is empty, exceeds the + /// size limit, or has an invalid or mismatched digest. /// private async Task<(byte[]? Bytes, string? MimeType)> DownloadSkillBytesAsync(McpSkillIndexEntry entry, CancellationToken cancellationToken) { @@ -306,9 +310,38 @@ private async Task> TryDownloadAndExtractSkillAsync(McpSki return (null, null); } + if (entry.Digest is not null && !this.VerifyDigest(entry.Name!, entry.Digest, bytes)) + { + return (null, null); + } + return (bytes, blobContent.MimeType); } + /// + /// Verifies a supplied digest against decoded archive bytes before extraction. + /// + private bool VerifyDigest(string skillName, string digest, byte[] bytes) + { + if (!ArchiveDigestRegex().IsMatch(digest)) + { + LogArchiveDigestVerificationFailed(this._logger, skillName, "digest must be 'sha256:' followed by 64 lowercase hexadecimal characters"); + return false; + } + + string actualDigest = Convert.ToHexString(SHA256.HashData(bytes)); + if (!actualDigest.AsSpan().Equals(digest.AsSpan("sha256:".Length), StringComparison.OrdinalIgnoreCase)) + { + LogArchiveDigestVerificationFailed(this._logger, skillName, "digest does not match downloaded content"); + return false; + } + + return true; + } + + [GeneratedRegex(@"\Asha256:[0-9a-f]{64}\z", RegexOptions.CultureInvariant)] + private static partial Regex ArchiveDigestRegex(); + /// /// Filters archive entries to those that are valid for materialization. Entries with missing or /// invalid names (not usable as directory names) or missing URLs are excluded and logged. @@ -421,6 +454,9 @@ private bool TryDeleteSkillDirectory(string skillName, string directory) [LoggerMessage(LogLevel.Warning, "Failed to decode archive resource for skill '{SkillName}'.")] private static partial void LogArchiveDecodeFailed(ILogger logger, string skillName, Exception exception); + [LoggerMessage(LogLevel.Warning, "Skipping archive skill '{SkillName}': {Reason}")] + private static partial void LogArchiveDigestVerificationFailed(ILogger logger, string skillName, string reason); + [LoggerMessage(LogLevel.Warning, "Failed to extract archive for skill '{SkillName}'.")] private static partial void LogArchiveExtractFailed(ILogger logger, string skillName, Exception exception); } diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/McpSkillIndex.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/McpSkillIndex.cs index d822cee9501..9d032bba902 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/McpSkillIndex.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/McpSkillIndex.cs @@ -11,10 +11,10 @@ namespace Microsoft.Agents.AI; /// /// /// Schema reference: -/// (Agent Skills Discovery v0.2.0), as bound to MCP by SEP-2640. The MCP binding differs from the -/// base schema in two ways: the url field contains a full MCP resource URI, and the -/// digest field is omitted (integrity is the transport's concern over an authenticated -/// MCP connection). +/// (Agent Skills Discovery v0.2.0), as used by the legacy SEP-2640 MCP index binding. +/// The url field contains a full MCP resource URI. Unlike the base schema, the +/// digest field is optional; supplied archive digests are verified in addition to +/// relying on the authenticated MCP transport. /// /// /// All properties are nullable so that deserialization succeeds even when the server-side index @@ -47,7 +47,7 @@ internal sealed class McpSkillIndex /// /// type, description, and url are REQUIRED. /// name is REQUIRED for skill-md and archive entries; OMITTED for mcp-resource-template. -/// digest is part of the base schema but OMITTED under the SEP-2640 MCP binding; carried here for compatibility with non-MCP indices. +/// digest is optional under this MCP index binding; supplied archive digests are verified before extraction. /// /// All properties are nullable to keep deserialization lenient; callers validate required fields before use. /// @@ -87,9 +87,10 @@ internal sealed class McpSkillIndexEntry public string? Url { get; set; } /// - /// Gets or sets the SHA-256 digest of the artifact bytes (e.g. sha256:abcd1234...). - /// Required by the base v0.2.0 schema, but OMITTED under the SEP-2640 MCP binding because - /// integrity is the transport's concern over an authenticated MCP connection. + /// Gets or sets the SHA-256 digest of the artifact bytes, formatted as sha256: followed + /// by 64 lowercase hexadecimal characters. Required by the base v0.2.0 schema but optional + /// under this MCP index binding. Supplied archive digests are verified before extraction; + /// verification does not apply to skill-md entries. /// [JsonPropertyName("digest")] public string? Digest { get; set; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/AgentMcpSkillsSourceArchiveTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/AgentMcpSkillsSourceArchiveTests.cs index f80798d38a7..418ecd9c19c 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/AgentMcpSkillsSourceArchiveTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/AgentMcpSkillsSourceArchiveTests.cs @@ -2,14 +2,19 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Linq; +using System.Security.Cryptography; using System.Text; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using Moq; namespace Microsoft.Agents.AI.Skills.Mcp.UnitTests; @@ -57,6 +62,253 @@ Content B. private const int ManyFileArchiveFileCount = 60; + /// + /// Malformed, unsupported, and mismatched archive digests that must be rejected. + /// + public static TheoryData RejectedDigests => new() + { + "", + " ", + new string('0', 64), + "sha256:" + new string('a', 63), + "sha256:" + new string('a', 65), + "sha256:" + new string('g', 64), + "sha256:" + new string('A', 64), + "SHA256:" + new string('a', 64), + " sha256:" + new string('a', 64), + "sha256:" + new string('a', 64) + "\n", + "sha512:" + new string('a', 128), + "sha256:" + new string('0', 64), + }; + + [Theory] + [InlineData("omitted")] + [InlineData("null")] + [InlineData("matching")] + public async Task GetSkillsAsync_ArchiveDigest_LoadsValidArchiveAsync(string digestMode) + { + // Arrange + byte[] archive = BuildZip(("SKILL.md", ArchivedSkillMd), ("reference.md", "Verified resource.")); + string index = digestMode == "omitted" + ? ArchiveIndex("archived-skill", "skill://archives/archived-skill.zip") + : ArchiveIndexWithDigest(digestMode == "matching" ? ArchiveDigest(archive) : null); + await using var server = CreateArchiveServer(index, new Dictionary { ["archived-skill"] = archive }); + await using var client = await server.CreateClientAsync(); + var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot }); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + var skill = Assert.Single(skills); + Assert.Contains("Body from the archive.", await skill.GetContentAsync()); + var resource = await skill.GetResourceAsync("reference.md"); + Assert.NotNull(resource); + Assert.Equal("Verified resource.", await resource.ReadAsync()); + } + + [Theory] + [MemberData(nameof(RejectedDigests))] + public async Task GetSkillsAsync_RejectedDigest_SkipsBeforeExtractionAndLogsWarningAsync(string digest) + { + // Arrange + byte[] archive = BuildZip(("SKILL.md", ArchivedSkillMd)); + string index = ArchiveIndexWithDigest(digest); + await using var server = CreateArchiveServer(index, new Dictionary { ["archived-skill"] = archive }); + await using var client = await server.CreateClientAsync(); + var logger = new Mock(); + logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); + var loggerFactory = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + var source = new AgentMcpSkillsSource( + client, new() { ArchiveSkillsDirectory = this._extractionRoot }, loggerFactory.Object); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Empty(skills); + Assert.False(Directory.Exists(Path.Combine(this._extractionRoot, "archived-skill"))); + logger.Verify( + l => l.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((state, _) => + state.ToString()!.Contains("Skipping archive skill 'archived-skill': digest", StringComparison.Ordinal) && + !state.ToString()!.Contains("skill://", StringComparison.Ordinal)), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Theory] + [InlineData("original")] + [InlineData("base64")] + [InlineData("skill-md")] + public async Task GetSkillsAsync_DigestOfDifferentBytes_SkipsArchiveAsync(string digestSource) + { + // Arrange + byte[] original = BuildZip(("SKILL.md", ArchivedSkillMd)); + string tamperedContent = ArchivedSkillMd.Replace("Body from the archive.", "Substituted instructions.", StringComparison.Ordinal); + byte[] archive = BuildZip(("SKILL.md", tamperedContent)); + byte[] digestBytes = digestSource switch + { + "original" => original, + "base64" => Encoding.UTF8.GetBytes(Convert.ToBase64String(archive)), + _ => Encoding.UTF8.GetBytes(tamperedContent), + }; + await using var server = CreateArchiveServer( + ArchiveIndexWithDigest(ArchiveDigest(digestBytes)), + new Dictionary { ["archived-skill"] = archive }); + await using var client = await server.CreateClientAsync(); + var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot }); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Empty(skills); + Assert.False(Directory.Exists(Path.Combine(this._extractionRoot, "archived-skill"))); + } + + [Theory] + [InlineData("")] + [InlineData("sha256:0000000000000000000000000000000000000000000000000000000000000000")] + public async Task GetSkillsAsync_RejectedDigest_KeepsValidSiblingAsync(string digest) + { + // Arrange + byte[] rejectedArchive = BuildZip(("SKILL.md", ArchivedSkillMd)); + byte[] validArchive = BuildZip(("SKILL.md", SkillAMd)); + JsonNode index = JsonNode.Parse(ArchiveIndexWithDigest(digest))!; + index["skills"]!.AsArray().Add(new JsonObject + { + ["name"] = "skill-a", + ["type"] = "archive", + ["description"] = "Skill A.", + ["url"] = "skill://archives/skill-a.zip", + ["digest"] = ArchiveDigest(validArchive), + }); + await using var server = CreateArchiveServer(index.ToJsonString(), new Dictionary + { + ["archived-skill"] = rejectedArchive, + ["skill-a"] = validArchive, + }); + await using var client = await server.CreateClientAsync(); + var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot }); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Equal("skill-a", Assert.Single(skills).Frontmatter.Name); + Assert.False(Directory.Exists(Path.Combine(this._extractionRoot, "archived-skill"))); + Assert.True(File.Exists(Path.Combine(this._extractionRoot, "skill-a", "SKILL.md"))); + } + + [Theory] + [InlineData("123")] + [InlineData("false")] + [InlineData("{}")] + [InlineData("[]")] + public async Task GetSkillsAsync_NonStringDigest_RejectsIndexAsync(string digestJson) + { + // Arrange + JsonNode index = JsonNode.Parse(ArchiveIndex("archived-skill", "skill://archives/archived-skill.zip"))!; + index["skills"]![0]!["digest"] = JsonNode.Parse(digestJson); + await using var server = CreateArchiveServer( + index.ToJsonString(), + new Dictionary { ["archived-skill"] = BuildZip(("SKILL.md", ArchivedSkillMd)) }); + await using var client = await server.CreateClientAsync(); + var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot }); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Empty(skills); + Assert.False(Directory.Exists(this._extractionRoot)); + } + + [Theory] + [InlineData("invalid-zip")] + [InlineData("gzip")] + [InlineData("file-count")] + [InlineData("uncompressed-size")] + [InlineData("download-size")] + public async Task GetSkillsAsync_MatchingDigest_PreservesArchiveGuardsAsync(string failure) + { + // Arrange + byte[] archive = failure switch + { + "invalid-zip" => [0x50, 0x4B, 0x03, 0x04], + "gzip" => [0x1F, 0x8B], + _ => BuildZip(("SKILL.md", ArchivedSkillMd), ("reference.md", "Reference.")), + }; + await using var server = CreateArchiveServer( + ArchiveIndexWithDigest(ArchiveDigest(archive)), + new Dictionary { ["archived-skill"] = archive }); + await using var client = await server.CreateClientAsync(); + var source = new AgentMcpSkillsSource(client, new() + { + ArchiveSkillsDirectory = this._extractionRoot, + ArchiveMaxFileCount = failure == "file-count" ? 1 : 20, + ArchiveMaxSizeBytes = failure == "download-size" ? 1 : null, + ArchiveMaxUncompressedSizeBytes = failure == "uncompressed-size" ? 1 : null, + }); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Empty(skills); + Assert.False(Directory.Exists(Path.Combine(this._extractionRoot, "archived-skill"))); + } + + [Fact] + public async Task GetSkillsAsync_MatchingDigest_SkipsTraversalEntryAsync() + { + // Arrange + byte[] archive = BuildZip(("SKILL.md", ArchivedSkillMd), ("../escape.md", "Outside skill.")); + await using var server = CreateArchiveServer( + ArchiveIndexWithDigest(ArchiveDigest(archive)), + new Dictionary { ["archived-skill"] = archive }); + await using var client = await server.CreateClientAsync(); + var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot }); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Single(skills); + Assert.True(File.Exists(Path.Combine(this._extractionRoot, "archived-skill", "SKILL.md"))); + Assert.False(File.Exists(Path.Combine(this._extractionRoot, "escape.md"))); + } + + [Fact] + public async Task GetSkillsAsync_DigestMismatchOnRefresh_RemovesPreviousExtractionAsync() + { + // Arrange + byte[] original = BuildZip(("SKILL.md", ArchivedSkillMd)); + var archives = new Dictionary { ["archived-skill"] = original }; + await using var server = CreateArchiveServer(ArchiveIndexWithDigest(ArchiveDigest(original)), archives); + await using var client = await server.CreateClientAsync(); + var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot }); + var context = TestAgentSkillsSourceContextFactory.Create(); + Assert.Single(await source.GetSkillsAsync(context)); + Assert.True(File.Exists(Path.Combine(this._extractionRoot, "archived-skill", "SKILL.md"))); + archives["archived-skill"] = BuildZip(("SKILL.md", ArchivedSkillMd.Replace( + "Body from the archive.", "Substituted instructions.", StringComparison.Ordinal))); + + // Act + var skills = await source.GetSkillsAsync(context); + + // Assert + Assert.Empty(skills); + Assert.False(Directory.Exists(Path.Combine(this._extractionRoot, "archived-skill"))); + archives["archived-skill"] = original; + Assert.Single(await source.GetSkillsAsync(context)); + } + [Fact] public async Task GetSkillsAsync_ZipArchive_DiscoversSkillAsync() { @@ -628,6 +880,20 @@ private static byte[] BuildZip(params (string Path, string Content)[] entries) return ms.ToArray(); } + [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Archive digests require lowercase hexadecimal characters.")] + private static string ArchiveDigest(byte[] archive) => + "sha256:" + Convert.ToHexString(SHA256.HashData(archive)).ToLowerInvariant(); + + private static string ArchiveIndexWithDigest(string? digest) + { + JsonNode index = JsonNode.Parse(ArchiveIndex("archived-skill", "skill://archives/archived-skill.zip"))!; + index["skills"]![0]!["digest"] = digest; + return index.ToJsonString(); + } + + private static InMemoryMcpServer CreateArchiveServer(string index, IReadOnlyDictionary archives) => + new(builder => builder.WithResources(new DigestArchiveServer(index, archives))); + private static string ArchiveIndex(string skillName, string url) => $$""" { "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", @@ -646,6 +912,17 @@ private static string ArchiveIndex(string skillName, string url) => $$""" #pragma warning disable CA1812 + [McpServerResourceType] + private sealed class DigestArchiveServer(string index, IReadOnlyDictionary archives) + { + [McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")] + public string Index() => index; + + [McpServerResource(UriTemplate = "skill://archives/{skillName}.zip", Name = "archive", MimeType = "application/zip")] + public BlobResourceContents Archive(string skillName) => BlobResourceContents.FromBytes( + archives[skillName], $"skill://archives/{skillName}.zip", "application/zip"); + } + [McpServerResourceType] private sealed class ZipArchiveServer { diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index f9438a887a0..f29d2a1d3f2 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -156,6 +156,8 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`MCPSkillsSource`** - `SkillsSource` that discovers Agent Skills served over MCP by reading the well-known `skill://index.json` (SEP-2640). Index entries are dispatched by their `type` (case-insensitive): `skill-md` entries become one `MCPSkill` each (its `SKILL.md` body and sibling resources are fetched on demand via `resources/read`), and `archive` entries are downloaded as a single ZIP blob and unpacked **entirely in memory** (via the private `_ArchiveEntryLoader`) into a `FileSkill` whose `SKILL.md` body drives it and whose sibling files (matching the resource extensions, within the search depth) become in-memory `InlineSkillResource` resources. **Nothing is written to disk** — there are no temporary directories to create, own, or prune (this is a deliberate divergence from .NET, which extracts ZIP archives to disk; it removes the temp-dir leak and the dangerous prune-of-unowned-subdirs footgun). Entries whose type has no handler (e.g. `mcp-resource-template`) are skipped. **MCP-delivered scripts are never runnable**: the loader emits no `SkillScript`s, so a bundled script can at most surface as a readable resource (and only if it matches the resource extensions — `.py` is not a default resource extension). The archive `SKILL.md` frontmatter `name` must match the advertised index-entry `name` or the skill is skipped. ZIP extraction is hardened: a `..` path-traversal ("zip-slip") member name raises via `_normalize_archive_member_name` and aborts the whole skill (like the file-count/size limits), and file-count / uncompressed-size (`_read_member_with_limit`) / download-size limits are enforced. Archive behavior is configured with `archive_*` constructor kwargs (`archive_resource_extensions`, `archive_resource_search_depth`, `archive_max_file_count`, `archive_max_size_bytes`, `archive_max_uncompressed_size_bytes`) — Python uses plain kwargs, not a `*Options` object as in .NET. A non-"resource not found" error while downloading an archive propagates (so a failed `CachingSkillsSource` refresh does not overwrite a cached list with a partial result). Unlike .NET's `AgentMcpSkillsSourceOptions.RefreshInterval`, this source has no built-in refresh interval; wrap it in `CachingSkillsSource(..., refresh_interval=...)` for caching/refresh. This is a port of .NET PR #6631; the `FileSkillsSource` `script_extensions`/`resource_extensions` kwargs default to the built-in tuples and treat `None` as "use defaults" and an empty tuple as "discover none" (an empty tuple previously fell back to defaults). `FoundryToolbox.as_skills_provider()` forwards matching `archive_*` kwargs to this source. - **`SkillsSource` decorators** - Skill sources are composable: `SkillsSource` is the abstract base, with concrete sources (`InMemorySkillsSource`, `FileSkillsSource`, `MCPSkillsSource`) and decorators that wrap an inner source — `AggregatingSkillsSource` (concatenate several sources), `FilteringSkillsSource` (predicate filter), `DeduplicatingSkillsSource` (first-wins by name), and `CachingSkillsSource` (cache the inner source's skills list). `DelegatingSkillsSource` is the abstract base for decorators. **`get_skills` takes a `SkillsSourceContext`**: every source/decorator implements `async def get_skills(self, context: SkillsSourceContext) -> list[Skill]` and forwards `context` to inner sources. `SkillsSourceContext` (frozen) carries the invoking `agent` (`SupportsAgentRun`) and optional `session` (`AgentSession | None`); `SkillsProvider` builds it from `before_run`'s `agent`/`session` and passes it into the pipeline. `FilteringSkillsSource`'s predicate is context-aware: `Callable[[Skill, SkillsSourceContext], bool]` (port of .NET #6797). **Default caching is applied only to the built-in, context-independent leaf sources**: for the `Skill` / sequence-of-skills / `from_paths` constructors, `SkillsProvider` builds `DeduplicatingSkillsSource(CachingSkillsSource())` so expensive filesystem/network discovery runs once. A **caller-supplied `SkillsSource` is used as-is — never auto-wrapped in caching or deduplication** — because auto-caching a context-aware caller source in a single shared bucket would replay the first invocation's skills for later `SkillsSourceContext`s and leak skills across agents/tenants (matches .NET, whose custom-source constructor also adds no caching/dedup). Callers who want caching on a custom pipeline compose `CachingSkillsSource(inner, cache_isolation_key_selector=...)` themselves. `disable_caching=True` only affects the built-in leaf caching (it has no effect on a caller-supplied source, which is never cached). `CachingSkillsSource` shares a single in-flight fetch across concurrent callers (per cache key) and does not update its cache on a failed fetch, so the next call retries (an initial failure leaves the cache empty; a refresh failure keeps the previously cached list). By default all callers share one cache bucket; pass `cache_isolation_key_selector=Callable[[SkillsSourceContext], str | None]` to cache separately per key (e.g. per agent name) for context-aware inner sources — the key should be low-cardinality and stable, and returning `None` (or leaving the selector `None`) uses the shared bucket. By default a cached list never expires; pass `refresh_interval=timedelta(...)` (port of .NET `CachingAgentSkillsSourceOptions.RefreshInterval`) to treat a cached list as stale once it is older than the interval so the next call re-queries the inner source (useful when an inner source such as `MCPSkillsSource` changes over the process lifetime; a zero/negative interval makes every result immediately stale, and a failed refresh keeps the prior list and retries). Freshness is measured with a monotonic clock (`time.monotonic()`). `SkillsProvider.__init__` / `from_paths` expose a `cache_refresh_interval` kwarg that is threaded into the built-in `CachingSkillsSource` (it has no effect on a caller-supplied source or when `disable_caching=True`). **`MCPSkillsSource` and `MCPSkill` accept exactly one of `client` (a fixed `ClientSession`) or `session_provider` (`Callable[[], ClientSession]`, resolved on every fetch); providing both/neither raises `ValueError`.** Use `session_provider` when the underlying session may be swapped over time — e.g. a reconnecting `MCPTool`/`FoundryToolbox` whose `session` is replaced on reconnect — so cached `MCPSkill`s keep fetching against the live session instead of a closed one (`MCPSkillsSource` forwards its provider to every `MCPSkill` it creates). A fixed `client` is safe only when the session outlives the skills. +- **MCP archive digests** - `MCPSkillsSource` verifies each non-null archive `digest` against the decoded ZIP bytes after the download-size guard and before extraction. The accepted form is `sha256:` followed by 64 lowercase hexadecimal characters. Omitted/null digests remain supported; malformed values (including non-string JSON), unsupported algorithms, and mismatches warn and skip only that archive. A successful `CachingSkillsSource` refresh therefore replaces its list without rejected archives; transport errors still propagate and leave the stored cache unchanged. Verification does not apply to lazy `skill-md` entries or their supporting resources. A digest only proves consistency with the index, not that the server or skill content is trustworthy. + ### Model Context Protocol (`_mcp.py`) - **`MCPTool`** - Base wrapper that owns the MCP `ClientSession` and exposes the remote server's tools as `FunctionTool`s. diff --git a/python/packages/core/agent_framework/_skills.py b/python/packages/core/agent_framework/_skills.py index e4101609d28..b02ec2cf5e5 100644 --- a/python/packages/core/agent_framework/_skills.py +++ b/python/packages/core/agent_framework/_skills.py @@ -46,6 +46,7 @@ import asyncio import base64 +import hashlib import inspect import io import json @@ -4746,6 +4747,8 @@ class _ArchiveEntryLoader: Extraction is hardened against path-traversal ("zip-slip") member names, oversized downloads, excessive file counts, and decompression bombs. + Supplied SHA-256 digests are verified before extraction; archives without + a digest remain supported. """ def __init__( @@ -4822,7 +4825,8 @@ async def _download(self, entry: _McpSkillIndexEntry) -> tuple[bytes, str | None Returns: A ``(data, mime_type)`` tuple, or ``None`` when the resource is not found, - contains no binary content, is empty, or exceeds the configured size limit. + contains no binary content, is empty, exceeds the configured size limit, + or has an invalid or mismatched digest. Raises: Exception: Any error other than a "resource not found" MCP error raised @@ -4855,8 +4859,28 @@ async def _download(self, entry: _McpSkillIndexEntry) -> tuple[bytes, str | None ) return None + if entry.digest is not None and not self._verify_digest(entry, data): + return None + return data, mime_type + @staticmethod + def _verify_digest(entry: _McpSkillIndexEntry, data: bytes) -> bool: + """Verify a supplied digest against decoded archive bytes, before extraction.""" + digest = entry.digest + if not isinstance(digest, str) or re.fullmatch(r"sha256:[0-9a-f]{64}", digest) is None: + logger.warning( + "Skipping skill '%s': archive digest must be 'sha256:' followed by 64 lowercase hexadecimal characters", + entry.name, + ) + return False + + if hashlib.sha256(data).hexdigest() != digest[7:]: + logger.warning("Skipping skill '%s': archive digest does not match downloaded content", entry.name) + return False + + return True + def _build_skill(self, entry: _McpSkillIndexEntry, data: bytes, mime_type: str | None) -> FileSkill | None: """Detect the format of and unpack one archive entry into an in-memory :class:`FileSkill`. @@ -5008,6 +5032,16 @@ class MCPSkillsSource(SkillsSource): already provides refresh/caching for any source, this source does not offer a separate refresh interval; wrap it in :class:`CachingSkillsSource` to cache. + Archive digests: + An archive entry's non-null ``digest`` must be ``sha256:`` followed by + 64 lowercase hexadecimal characters. It is verified against the decoded + archive bytes before extraction. Invalid, unsupported, or mismatched + digests cause a warning and the archive is skipped; other entries remain + available. A cache refresh therefore replaces its list without rejected + archives. Omitted or null digests remain allowed. This verification + applies only to ``archive`` entries, not lazily fetched ``skill-md`` + entries or their supporting resources. + Security considerations: Discovering skills over MCP means an *external* MCP server controls what skill content (including instructions and, for script-capable @@ -5021,7 +5055,9 @@ class MCPSkillsSource(SkillsSource): servers you have vetted and trust, and treat their responses as untrusted input. Archive extraction is hardened against path-traversal ("zip-slip") and decompression bombs, but the skill *content* is still - untrusted. + untrusted. A matching digest proves consistency with the index, not + trustworthiness: a server controlling both the index and archive can + replace both, or omit the digest. Examples: .. code-block:: python diff --git a/python/packages/core/tests/core/test_mcp_skills.py b/python/packages/core/tests/core/test_mcp_skills.py index 647981f0da4..20cb8db5857 100644 --- a/python/packages/core/tests/core/test_mcp_skills.py +++ b/python/packages/core/tests/core/test_mcp_skills.py @@ -5,10 +5,12 @@ from __future__ import annotations import base64 +import hashlib import io import json import zipfile -from unittest.mock import AsyncMock +from datetime import timedelta +from unittest.mock import AsyncMock, patch import pytest from mcp.shared.exceptions import McpError @@ -20,7 +22,7 @@ ) from pydantic import AnyUrl -from agent_framework import MCPSkill, MCPSkillResource, MCPSkillsSource, SkillsSourceContext +from agent_framework import CachingSkillsSource, MCPSkill, MCPSkillResource, MCPSkillsSource, SkillsSourceContext from agent_framework._skills import _parse_mcp_skill_index from .conftest import MockAgent @@ -723,7 +725,7 @@ def _make_zip(files: dict[str, bytes]) -> bytes: """ -def _make_archive_index(name: str, url: str, entry_type: str = "archive") -> str: +def _make_archive_index(name: str, url: str, entry_type: str = "archive", *, digest: object = None) -> str: """Build a skill index JSON document with a single archive entry.""" return json.dumps({ "$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json", @@ -733,6 +735,7 @@ def _make_archive_index(name: str, url: str, entry_type: str = "archive") -> str "type": entry_type, "description": "A skill delivered as an archive.", "url": url, + **({"digest": digest} if digest is not None else {}), } ], }) @@ -1006,6 +1009,234 @@ async def test_zip_slip_archive_skips_whole_skill(self) -> None: assert skills == [] +class TestMCPSkillsSourceArchiveDigest: + """Tests for archive digest verification through the MCP discovery pipeline.""" + + @pytest.mark.parametrize("digest_mode", ["omitted", "null", "matching"]) + async def test_valid_archive_loads(self, digest_mode: str) -> None: + from agent_framework._skills import _ArchiveEntryLoader + + url = "skill://archives/packaged-skill.zip" + archive = _make_zip({ + "SKILL.md": ARCHIVE_SKILL_MD.encode(), + "references/guide.md": b"Verified resource.", + }) + index_data = json.loads(_make_archive_index("packaged-skill", url)) + if digest_mode == "null": + index_data["skills"][0]["digest"] = None + elif digest_mode == "matching": + index_data["skills"][0]["digest"] = f"sha256:{hashlib.sha256(archive).hexdigest()}" + client = _archive_client(json.dumps(index_data), url, archive, "application/zip") + + with patch.object( + _ArchiveEntryLoader, "_verify_digest", wraps=_ArchiveEntryLoader._verify_digest + ) as verify_digest: + skills = await MCPSkillsSource(client=client).get_skills(_SOURCE_CTX) + + assert len(skills) == 1 + if digest_mode == "matching": + verify_digest.assert_called_once() + else: + verify_digest.assert_not_called() + assert "Instructions from an archive." in await skills[0].get_content() + resource = await skills[0].get_resource("references/guide.md") + assert resource is not None + assert await resource.read() == "Verified resource." + + @pytest.mark.parametrize( + "digest", + [ + "", + " ", + "0" * 64, + "sha256:" + "a" * 63, + "sha256:" + "a" * 65, + "sha256:" + "g" * 64, + "sha256:" + "A" * 64, + "SHA256:" + "a" * 64, + " sha256:" + "a" * 64, + "sha256:" + "a" * 64 + "\n", + "sha512:" + "a" * 128, + 123, + False, + [], + {"value": "untrusted"}, + ], + ) + async def test_invalid_digest_skips_before_extraction( + self, digest: object, caplog: pytest.LogCaptureFixture + ) -> None: + url = "skill://archives/packaged-skill.zip" + archive = _make_zip({"SKILL.md": ARCHIVE_SKILL_MD.encode()}) + index = _make_archive_index("packaged-skill", url, digest=digest) + client = _archive_client(index, url, archive, "application/zip") + + with patch("agent_framework._skills._ArchiveEntryLoader._build_skill") as build_skill: + skills = await MCPSkillsSource(client=client).get_skills(_SOURCE_CTX) + + assert skills == [] + build_skill.assert_not_called() + assert "Skipping skill 'packaged-skill': archive digest must be" in caplog.text + assert url not in caplog.text + assert ARCHIVE_SKILL_MD not in caplog.text + + @pytest.mark.parametrize("digest_source", ["wrong", "original", "base64", "skill-md"]) + async def test_mismatched_digest_skips_before_extraction( + self, digest_source: str, caplog: pytest.LogCaptureFixture + ) -> None: + url = "skill://archives/packaged-skill.zip" + original = _make_zip({"SKILL.md": ARCHIVE_SKILL_MD.encode()}) + tampered_content = ARCHIVE_SKILL_MD.replace("Instructions from an archive.", "Substituted instructions.") + archive = _make_zip({"SKILL.md": tampered_content.encode()}) + digest_inputs = { + "original": original, + "base64": base64.b64encode(archive), + "skill-md": tampered_content.encode(), + } + expected = "0" * 64 if digest_source == "wrong" else hashlib.sha256(digest_inputs[digest_source]).hexdigest() + index = _make_archive_index("packaged-skill", url, digest=f"sha256:{expected}") + client = _archive_client(index, url, archive, "application/zip") + + with patch("agent_framework._skills._ArchiveEntryLoader._build_skill") as build_skill: + skills = await MCPSkillsSource(client=client).get_skills(_SOURCE_CTX) + + assert skills == [] + build_skill.assert_not_called() + assert "Skipping skill 'packaged-skill': archive digest does not match downloaded content" in caplog.text + assert expected not in caplog.text + assert "Substituted instructions." not in caplog.text + assert url not in caplog.text + assert client.read_resource.await_count == 2 + + @pytest.mark.parametrize("digest", ["sha256:" + "0" * 64, "", 123]) + async def test_rejected_entry_does_not_hide_valid_archive(self, digest: object) -> None: + url = "skill://archives/packaged-skill.zip" + rejected_url = "skill://archives/rejected-skill.zip" + archive = _make_zip({"SKILL.md": ARCHIVE_SKILL_MD.encode()}) + rejected_archive = _make_zip({ + "SKILL.md": ARCHIVE_SKILL_MD.replace("name: packaged-skill", "name: rejected-skill").encode() + }) + index_data = json.loads(_make_archive_index("rejected-skill", rejected_url, digest=digest)) + index_data["skills"].extend( + json.loads( + _make_archive_index("packaged-skill", url, digest=f"sha256:{hashlib.sha256(archive).hexdigest()}") + )["skills"] + ) + client = _make_client(**{ + "skill://index.json": _make_text_result(json.dumps(index_data)), + url: _make_blob_result(archive, uri=url, mime_type="application/zip"), + rejected_url: _make_blob_result(rejected_archive, uri=rejected_url, mime_type="application/zip"), + }) + + skills = await MCPSkillsSource(client=client).get_skills(_SOURCE_CTX) + + assert len(skills) == 1 + assert skills[0].frontmatter.name == "packaged-skill" + + async def test_size_guard_runs_before_hashing(self) -> None: + url = "skill://archives/packaged-skill.zip" + archive = _make_zip({"SKILL.md": ARCHIVE_SKILL_MD.encode()}) + index = _make_archive_index("packaged-skill", url, digest=f"sha256:{hashlib.sha256(archive).hexdigest()}") + client = _archive_client(index, url, archive, "application/zip") + source = MCPSkillsSource(client=client, archive_max_size_bytes=len(archive) - 1) + + with patch("agent_framework._skills.hashlib.sha256") as sha256: + skills = await source.get_skills(_SOURCE_CTX) + + assert skills == [] + sha256.assert_not_called() + + @pytest.mark.parametrize( + "failure", ["invalid-zip", "gzip", "traversal", "file-count", "uncompressed-size", "name-mismatch"] + ) + async def test_matching_digest_does_not_bypass_archive_guards(self, failure: str) -> None: + url = "skill://archives/packaged-skill.zip" + files = {"SKILL.md": ARCHIVE_SKILL_MD.encode()} + if failure == "traversal": + files["../escape.md"] = b"Outside skill." + elif failure == "file-count": + files["extra.md"] = b"Extra resource." + elif failure == "name-mismatch": + files["SKILL.md"] = ARCHIVE_SKILL_MD.replace("name: packaged-skill", "name: another-skill").encode() + archive = _make_zip(files) + if failure == "invalid-zip": + archive = b"PK\x03\x04invalid" + elif failure == "gzip": + archive = b"\x1f\x8b" + index = _make_archive_index("packaged-skill", url, digest=f"sha256:{hashlib.sha256(archive).hexdigest()}") + client = _archive_client(index, url, archive, "application/zip") + source = MCPSkillsSource( + client=client, + archive_max_file_count=1 if failure == "file-count" else 20, + archive_max_uncompressed_size_bytes=1 if failure == "uncompressed-size" else 1_000_000, + ) + + assert await source.get_skills(_SOURCE_CTX) == [] + + @pytest.mark.parametrize("entry_type", ["archive", "ARCHIVE", " Archive "]) + async def test_verification_applies_to_all_archive_type_spellings(self, entry_type: str) -> None: + url = "skill://archives/packaged-skill.zip" + archive = _make_zip({"SKILL.md": ARCHIVE_SKILL_MD.encode()}) + index = _make_archive_index("packaged-skill", url, entry_type, digest="sha256:" + "0" * 64) + client = _archive_client(index, url, archive, "application/zip") + + assert await MCPSkillsSource(client=client).get_skills(_SOURCE_CTX) == [] + + @pytest.mark.parametrize("digest", ["sha256:" + "0" * 64, 123]) + async def test_skill_md_digest_does_not_change_lazy_discovery(self, digest: object) -> None: + index_data = json.loads(SAMPLE_SKILL_INDEX) + index_data["skills"][0]["digest"] = digest + client = _make_client(**{"skill://index.json": _make_text_result(json.dumps(index_data))}) + + skills = await MCPSkillsSource(client=client).get_skills(_SOURCE_CTX) + + assert len(skills) == 1 + assert isinstance(skills[0], MCPSkill) + client.read_resource.assert_awaited_once_with(AnyUrl("skill://index.json")) + + @pytest.mark.parametrize("refresh_failure", ["digest", "transport"]) + async def test_cache_refresh_handles_verification_and_transport_failures( + self, refresh_failure: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + clock = {"now": 1000.0} + monkeypatch.setattr("time.monotonic", lambda: clock["now"]) + url = "skill://archives/packaged-skill.zip" + archive = _make_zip({"SKILL.md": ARCHIVE_SKILL_MD.encode()}) + index = _make_archive_index("packaged-skill", url, digest=f"sha256:{hashlib.sha256(archive).hexdigest()}") + client = _archive_client(index, url, archive, "application/zip") + source = CachingSkillsSource(MCPSkillsSource(client=client), refresh_interval=timedelta(seconds=60)) + + first = await source.get_skills(_SOURCE_CTX) + assert len(first) == 1 + assert await source.get_skills(_SOURCE_CTX) is first + client.read_resource.assert_any_await(AnyUrl(url)) + assert client.read_resource.await_count == 2 + + tampered = _make_zip({ + "SKILL.md": ARCHIVE_SKILL_MD.replace("Instructions", "Substituted instructions").encode() + }) + client.read_resource.side_effect = [ + _make_text_result(index), + ConnectionError("connection lost") + if refresh_failure == "transport" + else _make_blob_result(tampered, uri=url, mime_type="application/zip"), + ] + clock["now"] += 60 + if refresh_failure == "transport": + with pytest.raises(ConnectionError, match="connection lost"): + await source.get_skills(_SOURCE_CTX) + assert list(source._cached_skills.values()) == [first] + client.read_resource.side_effect = [_make_text_result(index), _make_blob_result(archive, uri=url)] + recovered = await source.get_skills(_SOURCE_CTX) + assert len(recovered) == 1 + assert recovered is not first + else: + refreshed = await source.get_skills(_SOURCE_CTX) + assert refreshed == [] + assert await source.get_skills(_SOURCE_CTX) is refreshed + assert client.read_resource.await_count == 4 + + # --------------------------------------------------------------------------- # Archive extractor unit tests # --------------------------------------------------------------------------- From d99aa4dd8c2cccb39eaf005512a368b9ff06371f Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 16 Sep 2026 02:46:09 +0000 Subject: [PATCH 096/108] Python: Fix ambiguous MCP configuration name matching (#8386) * Python: Require unambiguous MCP configuration names Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f64c925-132e-4d46-865d-d5c05e218074 * Fix MCP tool snapshot replacement on refresh * Fix MCP refresh test typing and formatting --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1f64c925-132e-4d46-865d-d5c05e218074 --- python/packages/core/AGENTS.md | 3 +- python/packages/core/agent_framework/_mcp.py | 89 ++++- python/packages/core/tests/core/test_mcp.py | 345 ++++++++++++++++++- 3 files changed, 425 insertions(+), 12 deletions(-) diff --git a/python/packages/core/AGENTS.md b/python/packages/core/AGENTS.md index f29d2a1d3f2..7311bc3942d 100644 --- a/python/packages/core/AGENTS.md +++ b/python/packages/core/AGENTS.md @@ -163,8 +163,9 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID - **`MCPTool`** - Base wrapper that owns the MCP `ClientSession` and exposes the remote server's tools as `FunctionTool`s. - **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses. - **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. **The declared half comes from the server's advertised schema**, and runtime kwargs (`FunctionInvocationContext.kwargs`, seeded from `function_invocation_kwargs`) are merged with the model-supplied arguments upstream in `_call_tool_with_runtime_kwargs`, so provenance is gone by the time the filter runs. A runtime kwarg is therefore forwarded whenever the server declares a property of that name, without the model mentioning it — the server, not the caller, decides which runtime kwarg names it receives. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. `_MCP_FRAMEWORK_DENYLIST` is a narrow safety net covering only non-serializable framework objects a server *declares* in its schema (those are dropped); it does not generalize to arbitrary caller-chosen names, and explicit extras always win. The reserved `_meta` key is never forwarded as an argument; trusted caller/runtime `_meta` is validated as MCP request metadata, model-supplied `_meta` is discarded in generated MCP functions, and metadata precedence is caller/runtime < OpenTelemetry < tools/list metadata. -- **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing. +- **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. Configured allow/approval names must identify at most one raw remote name across loaded tools and prompts: a raw name that overlaps another tool's prefixed alias raises `ToolExecutionException`. Discovery validates all pages before publishing new functions; a failed reload retains the previous functions and metadata. The allowlist is also revalidated when exposing functions so runtime changes cannot select an ambiguous name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing. - **Progressive MCP disclosure** (`use_progressive_disclosure`, `always_load`) - When enabled on any `MCPTool` subclass, the initial model-facing surface is loader tools (`list_mcp_tools` / `load_tool` / `unload_tool`, prefixed by `tool_name_prefix` when configured) plus allowed tools selected by `always_load` and tools loaded earlier on the same `MCPTool` instance. `list_mcp_tools` only reports tools that pass `allowed_tools`; filtered tools are not listed or loadable. Loader tool names are reserved in progressive mode: remote MCP tools whose local generated name collides with a loader name are omitted from the initial/listed surface, and explicit `load_tool` calls return a model-visible message pointing callers to `tool_name_prefix` or excluding the colliding tool. `load_tool` accepts one tool name or a list of tool names and uses `FunctionInvocationContext.add_tools(...)` so the selected generated MCP `FunctionTool`s become available on the next function-calling iteration while keeping existing approval mode, argument filtering, header-provider runtime kwargs, result parsing, OTel, and task behavior. `unload_tool` accepts one dynamically loaded tool name or a list of names and removes them from the live tool list and persisted progressive surface, but it does not remove tools configured in `always_load`. Invalid `always_load` entries are ignored like unmatched `allowed_tools` entries. +- **Tool discovery refresh** - Validate the complete current tool snapshot alongside retained non-tool functions, not stale tool wrappers. Successful refreshes atomically replace the previous tool set and metadata, including empty snapshots, while preserving prompts, custom functions, and unchanged tool wrappers (including caller customizations). Removed or replaced tool identities lose their persisted progressive-loaded state; failed refreshes preserve the previous state. - **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through — but note this constrains the *model*, not the *server*, which still widens the effective allowlist through its schema. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used. - **MCP HTTP header request scoping** - `static_headers` supplies fixed, origin-scoped headers without serializing concurrent calls; `header_provider` resolves dynamic per-call headers under the instance call lock. Fixed and dynamic headers form the session's complete effective identity (case-insensitive names, case-sensitive values), with dynamic values overriding fixed values of the same name. Agent runs reconcile a connected tool before copying its functions; generated tool and prompt calls perform the same check at invocation. Connected run preparation defers when a provider needs model-supplied values that are not available until invocation, where resolution remains strict. A different identity waits for in-flight discovery, reconnects framework-created sessions without loading, then refreshes session-derived discovery under the same lock. Because a caller-supplied session's established identity is unknown and the wrapper cannot reconnect it, dynamic header resolution on that wrapper is rejected even while disconnected. Ambient requests retain the bound set. With a shared `http_client`, processing stays scoped to the originating `MCPStreamableHTTPTool`, and every injected header is stripped from cross-origin redirects. The session exit stack removes its request hook after transport shutdown, including failed initialization or discovery, and closes framework-created HTTP clients. Failed discovery resets the connection and discovery flags and rolls back partial function/metadata additions. Framework-created sessions are discarded; constructor-supplied sessions remain caller-owned and reusable across cleanup, close, and reset. - **MCP lifecycle caller cancellation** - The lifecycle owner skips cancelled queued connect requests and waits for the caller to acknowledge successful setup. If the caller cancels before accepting a newly established connection, the owner tears it down before processing the next request. Cancelling a close waiter does not interrupt teardown, and cancelling a redundant connect does not discard a previously established session. diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 0dd82180792..2073141ad94 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -87,6 +87,9 @@ class MCPSpecificApproval(TypedDict, total=False): """Represents the specific approval mode for an MCP tool. When using this mode, the user must specify which tools always or never require approval. + Names match raw remote names or unambiguous prefixed names when the raw name already + matches its normalized form. Normalized-only aliases do not match. Discovery raises + ``ToolExecutionException`` if a configured name matches multiple raw remote names. Attributes: always_require_approval: A sequence of tool names that always require approval. @@ -97,7 +100,6 @@ class MCPSpecificApproval(TypedDict, total=False): never_require_approval: Collection[str] | None - MCPToolResultContentMode = Literal[ "structured_first", "content_first", @@ -121,6 +123,7 @@ class MCPSpecificApproval(TypedDict, total=False): """ _MCP_REMOTE_NAME_KEY = "_mcp_remote_name" +_MCP_IS_TOOL_KEY = "_mcp_is_tool" _MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name" _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY = "_mcp_tool_result_host_payload" _MCP_PROGRESSIVE_LIST_TOOL_NAME = "list_mcp_tools" @@ -909,6 +912,7 @@ def __init__( A non-empty collection exposes only the raw remote tools whose names appear in it. For compatibility, the prefixed local function name is also accepted when the raw remote name already matches its normalized form; normalized aliases do not authorize a different raw remote tool. + A configured name matching multiple raw remote names raises ``ToolExecutionException``. An empty collection (``[]``) exposes no tools — if you simply want to disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is useful as a runtime guard or when you want to load tool metadata for @@ -986,8 +990,7 @@ def __init__( } if tool_result_content not in allowed_modes: raise ValueError( - f"tool_result_content must be one of {sorted(allowed_modes)}, " - f"got {tool_result_content!r}." + f"tool_result_content must be one of {sorted(allowed_modes)}, got {tool_result_content!r}." ) self.name = name self.description = description or "" @@ -1373,6 +1376,7 @@ def functions(self) -> list[FunctionTool]: def _filtered_functions(self) -> list[FunctionTool]: """Return loaded MCP functions after applying ``allowed_tools``.""" + self._validate_config_names(self._functions) if self.allowed_tools is None: return self._functions allowed_names = set(self.allowed_tools) @@ -1382,6 +1386,38 @@ def _filtered_functions(self) -> list[FunctionTool]: filtered_functions.append(func) return filtered_functions + def _validate_config_names(self, functions: Sequence[FunctionTool]) -> None: + """Reject configured names that identify more than one raw remote name.""" + configured_names = set(self.allowed_tools or ()) + if isinstance(self.approval_mode, dict): + configured_names.update(self.approval_mode.get("always_require_approval") or ()) + configured_names.update(self.approval_mode.get("never_require_approval") or ()) + if not configured_names: + return + + remote_by_config_name: dict[str, str] = {} + for func in functions: + additional_properties = func.additional_properties or {} + normalized_name = additional_properties.get(_MCP_NORMALIZED_NAME_KEY) + remote_name = additional_properties.get(_MCP_REMOTE_NAME_KEY) + if not isinstance(normalized_name, str) or not isinstance(remote_name, str): + continue + for name in _mcp_config_candidate_names( + local_name=func.name, + normalized_name=normalized_name, + remote_name=remote_name, + ): + if name not in configured_names: + continue + existing_remote = remote_by_config_name.get(name) + if existing_remote is not None and existing_remote != remote_name: + raise ToolExecutionException( + f"MCP configuration name {name!r} is ambiguous: it matches raw remote names " + f"{existing_remote!r} and {remote_name!r}. " + "Use an unambiguous name or a different tool_name_prefix." + ) + remote_by_config_name[name] = remote_name + def _function_matches_names(self, func: FunctionTool, names: set[str]) -> bool: """Return whether a generated MCP function matches a configured name set.""" if not names: @@ -2341,7 +2377,8 @@ async def load_prompts(self) -> None: them into FunctionTool instances. Handles pagination automatically. Raises: - ToolExecutionException: If the MCP server is not connected. + ToolExecutionException: If the MCP server is not connected or a configured + allow/approval name matches multiple raw remote names. """ async with self._function_load_lock: await self._load_prompts_locked() @@ -2356,6 +2393,7 @@ async def _load_prompts_locked(self) -> None: # Track existing function names to prevent duplicates existing_names = {func.name for func in self._functions} + new_functions: list[FunctionTool] = [] params: types.PaginatedRequestParams | None = None while True: @@ -2419,7 +2457,7 @@ async def _load_prompts_locked(self) -> None: _MCP_NORMALIZED_NAME_KEY: normalized_name, }, ) - self._functions.append(func) + new_functions.append(func) existing_names.add(local_name) # Check if there are more pages @@ -2427,6 +2465,9 @@ async def _load_prompts_locked(self) -> None: break params = types.PaginatedRequestParams(cursor=prompt_list.nextCursor) + self._validate_config_names([*self._functions, *new_functions]) + self._functions.extend(new_functions) + async def load_tools(self) -> None: """Load tools from the MCP server. @@ -2434,7 +2475,9 @@ async def load_tools(self) -> None: them into FunctionTool instances. Handles pagination automatically. Raises: - ToolExecutionException: If the MCP server is not connected. + ToolExecutionException: If the MCP server is not connected, multiple tools + map to the same local name, or a configured allow/approval name matches + multiple raw remote names. """ async with self._function_load_lock: await self._load_tools_locked() @@ -2447,9 +2490,15 @@ async def _load_tools_locked(self) -> None: logger.debug("Skipping MCP tool loading because the server did not advertise tools support.") return - # Track existing function names to prevent duplicates + # Previous tools are reusable; seed duplicate detection only with retained non-tool functions. + existing_tools: dict[str, FunctionTool] = {} existing_remote_by_local: dict[str, str] = {} + new_functions: list[FunctionTool] = [] + reused_tool_names: set[str] = set() for func in self._functions: + if (func.additional_properties or {}).get(_MCP_IS_TOOL_KEY): + existing_tools[func.name] = func + continue remote_name = (func.additional_properties or {}).get(_MCP_REMOTE_NAME_KEY) if isinstance(remote_name, str): existing_remote_by_local[func.name] = remote_name @@ -2531,6 +2580,14 @@ async def _load_tools_locked(self) -> None: existing_remote_by_local[local_name] = tool.name + existing_tool = existing_tools.get(local_name) + if ( + existing_tool is not None + and (existing_tool.additional_properties or {}).get(_MCP_REMOTE_NAME_KEY) == tool.name + ): + reused_tool_names.add(local_name) + continue + approval_mode = self._determine_approval_mode( *_mcp_config_candidate_names( local_name=local_name, @@ -2549,18 +2606,28 @@ async def _load_tools_locked(self) -> None: additional_properties={ _MCP_REMOTE_NAME_KEY: tool.name, _MCP_NORMALIZED_NAME_KEY: normalized_name, + _MCP_IS_TOOL_KEY: True, }, ) - self._functions.append(func) + new_functions.append(func) # Check if there are more pages if not tool_list.nextCursor: break params = types.PaginatedRequestParams(cursor=tool_list.nextCursor) + current_functions = [ + func + for func in self._functions + if not (func.additional_properties or {}).get(_MCP_IS_TOOL_KEY) or func.name in reused_tool_names + ] + current_functions.extend(new_functions) + self._validate_config_names(current_functions) + self._functions[:] = current_functions self._tool_call_meta_by_name = tool_call_meta_by_name self._tool_task_support_by_name = tool_task_support_by_name self._tool_param_names_by_name = tool_param_names_by_name + self._progressive_loaded_tool_names.difference_update(existing_tools.keys() - reused_tool_names) async def _cancel_pending_reload_tasks(self) -> None: """Cancel session-bound discovery reloads and wait for them to finish.""" @@ -3477,6 +3544,8 @@ def __init__( allowed_tools: Optional allow-list of MCP tool names to expose as functions. ``None`` (the default) exposes every tool advertised by the MCP server. A non-empty collection exposes only the tools whose names appear in it. + Names match raw remote names or unambiguous prefixed names when normalization + leaves the raw name unchanged. Ambiguous allow/approval names raise ``ToolExecutionException``. An empty collection (``[]``) exposes no tools — if you simply want to disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is useful as a runtime guard or when you want to load tool metadata for @@ -3685,6 +3754,8 @@ def __init__( allowed_tools: Optional allow-list of MCP tool names to expose as functions. ``None`` (the default) exposes every tool advertised by the MCP server. A non-empty collection exposes only the tools whose names appear in it. + Names match raw remote names or unambiguous prefixed names when normalization + leaves the raw name unchanged. Ambiguous allow/approval names raise ``ToolExecutionException``. An empty collection (``[]``) exposes no tools — if you simply want to disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is useful as a runtime guard or when you want to load tool metadata for @@ -4279,6 +4350,8 @@ def __init__( allowed_tools: Optional allow-list of MCP tool names to expose as functions. ``None`` (the default) exposes every tool advertised by the MCP server. A non-empty collection exposes only the tools whose names appear in it. + Names match raw remote names or unambiguous prefixed names when normalization + leaves the raw name unchanged. Ambiguous allow/approval names raise ``ToolExecutionException``. An empty collection (``[]``) exposes no tools — if you simply want to disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is useful as a runtime guard or when you want to load tool metadata for diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index 8a5ccf18a30..c8f547086f4 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -38,6 +38,7 @@ from agent_framework._mcp import ( _MCP_HEADER_OWNER_EXTENSION, _MCP_TOOL_RESULT_HOST_PAYLOAD_KEY, + MCPSpecificApproval, MCPTool, _build_prefixed_mcp_name, _describe_error, @@ -171,13 +172,14 @@ def test_mcp_transport_subclasses_accept_tool_name_prefix() -> None: ) -async def test_load_tools_with_tool_name_prefix_preserves_matching_configuration(): +@pytest.mark.parametrize("configured_name", ["search_docs", "docs_search_docs"]) +async def test_load_tools_with_tool_name_prefix_preserves_matching_configuration(configured_name: str): """Prefixed MCP tool names should still honor unprefixed allow/approval configuration.""" tool = MCPTool( # type: ignore[abstract] name="docs", tool_name_prefix="docs", - allowed_tools=["search_docs"], - approval_mode={"always_require_approval": ["search_docs"]}, + allowed_tools=[configured_name], + approval_mode={"always_require_approval": [configured_name]}, ) mock_session = AsyncMock() @@ -202,6 +204,343 @@ async def test_load_tools_with_tool_name_prefix_preserves_matching_configuration assert tool.functions[0].approval_mode == "always_require" +@pytest.mark.parametrize( + "allowed_tools,approval_mode", + [ + (["docs_search"], None), + (None, {"always_require_approval": ["docs_search"]}), + (None, {"never_require_approval": ["docs_search"]}), + ], + ids=["allowed", "always_require", "never_require"], +) +@pytest.mark.parametrize("remote_names", [("search", "docs_search"), ("docs_search", "search")]) +@pytest.mark.parametrize("paginated", [False, True]) +@pytest.mark.parametrize("progressive", [False, True]) +async def test_load_tools_rejects_ambiguous_policy_names( + allowed_tools: list[str] | None, + approval_mode: MCPSpecificApproval | None, + remote_names: tuple[str, str], + paginated: bool, + progressive: bool, +) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", ExperimentalWarning) + tool = MCPTool( # type: ignore[abstract] + name="docs", + tool_name_prefix="docs", + allowed_tools=allowed_tools, + approval_mode=approval_mode, + use_progressive_disclosure=progressive, + ) + advertised_tools = [ + types.Tool(name=name, inputSchema={"type": "object", "properties": {}}) for name in remote_names + ] + + async def list_tools(params: types.PaginatedRequestParams | None = None) -> types.ListToolsResult: + assert tool._functions == [] + if paginated: + if params is None: + return types.ListToolsResult(tools=advertised_tools[:1], nextCursor="second") + assert params.cursor == "second" + return types.ListToolsResult(tools=advertised_tools[1:]) + return types.ListToolsResult(tools=advertised_tools) + + tool.session = AsyncMock() + tool.session.list_tools = AsyncMock(side_effect=list_tools) + + with pytest.raises(ToolExecutionException, match="configuration name 'docs_search' is ambiguous"): + await tool.load_tools() + + assert tool._functions == [] + assert tool._tool_param_names_by_name == {} + assert [function.name for function in tool.functions] == ( + ["docs_list_mcp_tools", "docs_load_tool", "docs_unload_tool"] if progressive else [] + ) + + +@pytest.mark.parametrize( + "allowed_tools,approval_mode", + [ + (["docs_search"], None), + (None, {"always_require_approval": ["docs_search"]}), + (None, {"never_require_approval": ["docs_search"]}), + ], + ids=["allowed", "always_require", "never_require"], +) +@pytest.mark.parametrize("remote_names", [("search", "docs_search"), ("docs_search", "search")]) +async def test_ambiguous_policy_reload_preserves_previous_discovery( + allowed_tools: list[str] | None, + approval_mode: MCPSpecificApproval | None, + remote_names: tuple[str, str], + caplog: pytest.LogCaptureFixture, +) -> None: + tool = MCPTool( # type: ignore[abstract] + name="docs", tool_name_prefix="docs", allowed_tools=allowed_tools, approval_mode=approval_mode + ) + tool.session = AsyncMock() + original = types.Tool( + name=remote_names[0], + inputSchema={"type": "object", "properties": {"query": {"type": "string"}}}, + _meta={"original": True}, + ) + tool.session.list_tools = AsyncMock(return_value=types.ListToolsResult(tools=[original])) + await tool.load_tools() + original_functions = list(tool._functions) + original_meta = dict(tool._tool_call_meta_by_name) + original_params = dict(tool._tool_param_names_by_name) + original_tasks = dict(tool._tool_task_support_by_name) + + async def list_tools(params: types.PaginatedRequestParams | None = None) -> types.ListToolsResult: + assert tool._functions == original_functions + if params is None: + return types.ListToolsResult( + tools=[original, types.Tool(name="extra", inputSchema={"type": "object"})], + nextCursor="second", + ) + return types.ListToolsResult(tools=[types.Tool(name=remote_names[1], inputSchema={"type": "object"})]) + + tool.session.list_tools = AsyncMock(side_effect=list_tools) + with caplog.at_level(logging.WARNING, logger=logger.name): + await tool.message_handler(types.ServerNotification(types.ToolListChangedNotification())) + await asyncio.gather(*tool._pending_reload_tasks) + + assert "configuration name 'docs_search' is ambiguous" in caplog.text + assert tool._functions == original_functions + assert tool.functions == original_functions + assert tool._tool_call_meta_by_name == original_meta + assert tool._tool_param_names_by_name == original_params + assert tool._tool_task_support_by_name == original_tasks + + +@pytest.mark.parametrize( + "allowed_tools,approval_mode,expected_approval", + [ + (["docs_search"], None, "never_require"), + (None, {"always_require_approval": ["docs_search"]}, "always_require"), + (None, {"never_require_approval": ["docs_search"]}, "never_require"), + ], + ids=["allowed", "always_require", "never_require"], +) +@pytest.mark.parametrize("remote_names", [("search", "docs_search"), ("docs_search", "search")]) +async def test_tool_refresh_accepts_unambiguous_rename( + allowed_tools: list[str] | None, + approval_mode: MCPSpecificApproval | None, + expected_approval: str, + remote_names: tuple[str, str], +) -> None: + tool = MCPTool( # type: ignore[abstract] + name="docs", tool_name_prefix="docs", allowed_tools=allowed_tools, approval_mode=approval_mode + ) + tool.session = AsyncMock() + tool.session.list_tools = AsyncMock( + side_effect=[ + types.ListToolsResult(tools=[types.Tool(name=name, inputSchema={"type": "object"})]) + for name in remote_names + ] + ) + await tool.load_tools() + await tool.message_handler(types.ServerNotification(types.ToolListChangedNotification())) + await asyncio.gather(*tool._pending_reload_tasks) + + assert [function.name for function in tool.functions] == [f"docs_{remote_names[1]}"] + assert tool.functions[0].approval_mode == expected_approval + tool.session.call_tool = AsyncMock( + return_value=types.CallToolResult(content=[types.TextContent(type="text", text="ok")]) + ) + await tool.functions[0].invoke(arguments={}) + assert tool.session.call_tool.call_args.args[0] == remote_names[1] + + +@pytest.mark.parametrize("empty_snapshot", [False, True]) +async def test_tool_refresh_replaces_snapshot_and_preserves_other_functions(empty_snapshot: bool) -> None: + tool = MCPTool(name="docs", tool_name_prefix="docs") # type: ignore[abstract] + tool.session = AsyncMock() + keep = types.Tool( + name="keep", + inputSchema={"type": "object", "properties": {"query": {"type": "string"}}}, + _meta={"version": 1}, + ) + removed = types.Tool( + name="removed", + inputSchema={"type": "object", "properties": {}}, + _meta={"version": 1}, + execution=types.ToolExecution(taskSupport="required"), + ) + tool.session.list_tools = AsyncMock(return_value=types.ListToolsResult(tools=[keep, removed])) + await tool.load_tools() + kept_function = tool._functions[0] + parser = Mock(return_value="custom result") + kept_function.result_parser = parser + tool.session.list_prompts = AsyncMock(return_value=types.ListPromptsResult(prompts=[types.Prompt(name="summary")])) + await tool.load_prompts() + prompt_function = tool._functions[-1] + custom_function = FunctionTool(name="custom", func=lambda: "custom") + tool._functions.append(custom_function) + original_list = tool._functions + original_functions = list(original_list) + original_meta = dict(tool._tool_call_meta_by_name) + keep.meta = {"version": 2} + + async def list_tools(params: types.PaginatedRequestParams | None = None) -> types.ListToolsResult: + assert tool._functions == original_functions + assert tool._tool_call_meta_by_name == original_meta + if params is None: + return types.ListToolsResult(tools=[], nextCursor="second") + assert params.cursor == "second" + return types.ListToolsResult(tools=[] if empty_snapshot else [keep]) + + tool.session.list_tools = AsyncMock(side_effect=list_tools) + await tool.load_tools() + + assert tool._functions is original_list + assert {function.name for function in tool._functions} == ( + {"docs_summary", "custom"} if empty_snapshot else {"docs_keep", "docs_summary", "custom"} + ) + assert next(function for function in tool._functions if function.name == "docs_summary") is prompt_function + assert next(function for function in tool._functions if function.name == "custom") is custom_function + if not empty_snapshot: + assert next(function for function in tool._functions if function.name == "docs_keep") is kept_function + assert kept_function.result_parser is parser + assert tool._tool_call_meta_by_name == ({} if empty_snapshot else {"keep": {"version": 2}}) + assert tool._tool_param_names_by_name == ({} if empty_snapshot else {"keep": {"query"}}) + assert tool._tool_task_support_by_name == {} + + +async def test_tool_refresh_preserves_prompt_when_server_advertises_same_raw_name() -> None: + tool = MCPTool(name="docs") # type: ignore[abstract] + tool.session = AsyncMock() + tool.session.list_prompts = AsyncMock(return_value=types.ListPromptsResult(prompts=[types.Prompt(name="summary")])) + await tool.load_prompts() + prompt_function = tool._functions[0] + tool.session.list_tools = AsyncMock( + side_effect=[ + types.ListToolsResult(tools=[types.Tool(name="summary", inputSchema={"type": "object"})]), + types.ListToolsResult(tools=[]), + ] + ) + await tool.load_tools() + await tool.load_tools() + + assert tool._functions == [prompt_function] + + +@pytest.mark.parametrize("replacement_name", [None, "search-docs"]) +async def test_tool_refresh_forgets_removed_progressive_tools(replacement_name: str | None) -> None: + tool = await _load_progressive_test_server( + tool_name_prefix="docs", + tools=[types.Tool(name=name, inputSchema={"type": "object"}) for name in ("search/docs", "keep")], + ) + loader = tool.functions[1] + context = FunctionInvocationContext(function=loader, arguments={}, tools=list(tool.functions)) + await loader.invoke(arguments={"tool": ["docs_search-docs", "keep"]}, context=context) + assert tool._progressive_loaded_tool_names == {"docs_search-docs", "docs_keep"} + kept_function = next(function for function in tool._functions if function.name == "docs_keep") + assert tool.session is not None + with patch.object( + tool.session, + "list_tools", + return_value=types.ListToolsResult( + tools=[ + types.Tool(name=name, inputSchema={"type": "object"}) + for name in (["keep", replacement_name] if replacement_name else ["keep"]) + ] + ), + ): + await tool.load_tools() + + assert tool._progressive_loaded_tool_names == {"docs_keep"} + assert tool.functions[-1] is kept_function + assert [function.name for function in tool.functions] == [ + "docs_list_mcp_tools", + "docs_load_tool", + "docs_unload_tool", + "docs_keep", + ] + assert [function.name for function in tool._functions] == ( + ["docs_keep", "docs_search-docs"] if replacement_name else ["docs_keep"] + ) + + +@pytest.mark.parametrize( + "allowed_tools,expected_names", + [ + (None, ["docs_search", "docs_docs_search"]), + ([], []), + (["search"], ["docs_search"]), + (["docs_docs_search"], ["docs_docs_search"]), + ], +) +async def test_overlapping_names_accept_unambiguous_policy( + allowed_tools: list[str] | None, expected_names: list[str] +) -> None: + tool = MCPTool( # type: ignore[abstract] + name="docs", + tool_name_prefix="docs", + allowed_tools=allowed_tools, + approval_mode={ + "always_require_approval": ["search"], + "never_require_approval": ["search", "docs_docs_search"], + }, + ) + tool.session = AsyncMock() + tool.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[ + types.Tool(name=name, inputSchema={"type": "object", "properties": {}}) + for name in ("search", "docs_search") + ] + ) + ) + await tool.load_tools() + + assert [function.name for function in tool.functions] == expected_names + assert [function.approval_mode for function in tool._functions] == ["always_require", "never_require"] + + tool.allowed_tools = ["docs_search"] + with pytest.raises(ToolExecutionException, match="configuration name 'docs_search' is ambiguous"): + _ = tool.functions + + +@pytest.mark.parametrize("load_order", ["prompts", "tools_then_prompts", "prompts_then_tools"]) +async def test_prompts_reject_ambiguous_policy_names(load_order: str) -> None: + tool = MCPTool( # type: ignore[abstract] + name="docs", + tool_name_prefix="docs", + allowed_tools=["docs_search"], + ) + tool.session = AsyncMock() + tool.session.list_prompts = AsyncMock( + side_effect=[ + types.ListPromptsResult(prompts=[types.Prompt(name="search")], nextCursor="second"), + types.ListPromptsResult(prompts=[types.Prompt(name="docs_search")]), + ] + ) + if load_order == "prompts": + with pytest.raises(ToolExecutionException, match="configuration name 'docs_search' is ambiguous"): + await tool.load_prompts() + assert tool._functions == [] + return + + tool.session.list_tools = AsyncMock( + return_value=types.ListToolsResult( + tools=[types.Tool(name="search", inputSchema={"type": "object", "properties": {}})] + ) + ) + tool.session.list_prompts = AsyncMock( + return_value=types.ListPromptsResult(prompts=[types.Prompt(name="docs_search")]) + ) + first_load, second_load = ( + (tool.load_tools, tool.load_prompts) + if load_order == "tools_then_prompts" + else (tool.load_prompts, tool.load_tools) + ) + await first_load() + original_functions = list(tool._functions) + with pytest.raises(ToolExecutionException, match="configuration name 'docs_search' is ambiguous"): + await second_load() + assert tool._functions == original_functions + + async def test_allowed_tools_does_not_authorize_normalized_remote_name_collision() -> None: """A normalized/local allowlist match must not authorize a different raw remote tool.""" tool = MCPTool(name="test_server", allowed_tools=["delete-file"]) # type: ignore[abstract] From 7a230433a766458912d2ea76ef5f5c663bc35a55 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 16 Sep 2026 02:46:37 +0000 Subject: [PATCH 097/108] Python: Handle provider-invalidated responses (#8396) * Python: Handle provider-invalidated function calls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Simplify invalidated call cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Rename response invalidation exception Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Raise Anthropic Core dependency floor Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Apply Anthropic formatting Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Python: Address response invalidation review Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...1-handle-provider-invalidated-responses.md | 71 +++ .../specs/004-python-function-calling-loop.md | 16 + python/CODING_STANDARD.md | 2 + .../agent_framework_anthropic/_chat_client.py | 53 ++- python/packages/anthropic/pyproject.toml | 2 +- .../anthropic/tests/test_anthropic_client.py | 277 +++++++++++- .../packages/core/agent_framework/__init__.py | 2 + .../core/agent_framework/__init__.pyi | 2 + .../packages/core/agent_framework/_tools.py | 93 +++- .../core/agent_framework/exceptions.py | 6 + .../core/tests/core/test_exceptions.py | 19 +- .../core/test_function_invocation_logic.py | 425 ++++++++++++++++++ 12 files changed, 951 insertions(+), 17 deletions(-) create mode 100644 docs/decisions/0041-handle-provider-invalidated-responses.md diff --git a/docs/decisions/0041-handle-provider-invalidated-responses.md b/docs/decisions/0041-handle-provider-invalidated-responses.md new file mode 100644 index 00000000000..fdefd5a6762 --- /dev/null +++ b/docs/decisions/0041-handle-provider-invalidated-responses.md @@ -0,0 +1,71 @@ +--- +status: proposed +contact: eavanvalkenburg +date: 2026-09-15 +deciders: eavanvalkenburg +--- + +# Execute usable function calls unless the provider invalidates the response + +## Context and Problem Statement + +Chat providers do not use terminal response reasons uniformly. A response can stop because of an output limit while +still containing complete local function arguments that are valid for execution and accepted by the service on +continuation. Other provider protocols explicitly invalidate partial response output when a tool block is truncated, a +refusal replaces partial output, a stream fails, or required terminal events are absent. + +The function-calling loop needs a default that preserves usable calls without executing calls that a provider has +explicitly cancelled. + +## Decision Drivers + +- Preserve provider-neutral execution for complete, schema-usable local calls. +- Prevent local approvals and side effects for calls a provider protocol invalidates. +- Keep streaming deltas visible without buffering every provider response. +- Avoid coupling unrelated providers through a shared finish-reason interpretation layer. +- Keep local call invalidation distinct from caller cancellation. + +## Considered Options + +### Require one generic finish reason before execution + +- Good: provides one simple rule in the core loop. +- Bad: rejects complete calls from providers whose terminal reason describes the enclosing response rather than the + usability of its function arguments. +- Bad: makes custom clients depend on a provider-neutral meaning that their protocols may not have. + +### Execute every parseable function call + +- Good: preserves the current provider-neutral behavior. +- Bad: can execute partial output that a provider explicitly invalidated, even when accumulated JSON happens to parse. + +### Execute usable calls by default and let providers signal invalidation + +- Good: preserves the core loop's argument-driven policy. +- Good: lets an adapter enforce authoritative provider-specific terminal rules. +- Good: needs no cross-provider resolver or broad streaming buffer. +- Bad: each provider with invalidation semantics must implement and test its own protocol state. + +## Decision Outcome + +Chosen option: "Execute usable calls by default and let providers signal invalidation", because function argument +usability is the portable default while invalidation is provider-specific protocol knowledge. + +`FunctionInvocationLayer` remains independent of `finish_reason`: a newly completed actionable call proceeds when +argument preparation and schema validation succeed. A provider that knows partial response output was invalidated +raises `ResponseInvalidatedException`; any local function calls from that response must not execute. The layer +abandons that current iteration, clears request budget state, restores the last valid continuation, avoids successful +response persistence and local function side effects, and re-raises the same exception. + +Anthropic applies the signal only to local actionable `tool_use` blocks. A valid stream has closed local blocks, a +terminal `stop_reason` of `tool_use`, and `message_stop`. Non-tool terminal reasons, an open block at `message_stop`, +EOF without `message_stop`, or a non-cancellation stream error after a local call starts invalidate those calls. +Hosted/server-only calls and `pause_turn` retain their existing behavior. Streaming call deltas may already have been +yielded; direct consumers must discard them when invalidation is raised. + +### Consequences + +- Complete calls may execute under an enclosing `length` finish reason. +- Anthropic local calls cannot execute after protocol evidence invalidates or fails to finalize them. +- Direct streaming consumers receive an explicit discard signal without added buffering latency. +- Other provider adapters remain unchanged unless their own protocol defines equivalent invalidation semantics. diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index bf7d1ed72a3..be0c4d96ae2 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -350,6 +350,19 @@ that manually replay messages own the equivalent rule: do not resend an approval ### Function calls and results +- A newly completed, actionable local `function_call` is eligible for execution when argument preparation and schema + validation succeed. The enclosing response `finish_reason` is not a generic authorization gate: providers may + report another reason, including `length`, while still returning a complete and service-usable call. +- A provider adapter that knows its protocol invalidated or cancelled partial response output raises + `ResponseInvalidatedException`; any local function calls already exposed from that response must not execute. The + function invocation layer then abandons only that current model/tool iteration, clears its request budget state, + restores the last valid service continuation, and re-raises + the same exception. It emits no new approval request or function result, invokes no function middleware or tool + body, makes no second model request or dangling-call settlement request, and does not success-persist that service + response. Approval decisions resolved before the invalidated provider call retain their existing semantics. + Streaming providers may yield local function-call deltas before discovering invalidation; direct stream consumers + must treat the exception as an explicit instruction to discard those calls and never execute them. Caller + cancellation remains cancellation rather than becoming this provider signal. - Every actionable local `function_call` produces exactly one terminal `function_result`, unless execution pauses for a new user-input request or the run is aborted by `MiddlewareFailure`. - An ordinary exception raised by function middleware or a tool body becomes one terminal error `function_result` @@ -511,6 +524,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Scenario | Required invariant | Primary regression test | |---|---|---| | Single non-streaming call | Call, result, and final assistant message are returned in order. | `packages/core/tests/core/test_function_invocation_logic.py::test_base_client_with_function_calling` | +| Usable call under a non-tool finish reason | A schema-usable call executes once and the normal loop continues in both response modes even when the enclosing response reports `finish_reason="length"`. | `packages/core/tests/core/test_function_invocation_logic.py::test_function_call_with_length_finish_reason_executes_and_continues`, `test_streamed_function_call_with_length_finish_reason_executes_and_continues` | | String input | Flexible string input follows the same loop behavior. | `test_base_client_with_function_calling_string_input` | | Multiple sequential rounds | Each round retains one call/result pair. | `test_base_client_with_function_calling_resets` | | Streaming call | Call chunks, one result update, and final text are emitted in order. | `test_base_client_with_streaming_function_calling` | @@ -620,6 +634,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Middleware failure batch cancellation | A fatal signal fails the whole parallel batch: in-flight sibling tool invocations are cancelled and awaited before the failure propagates. Cancellation is cooperative — an async sibling stops at its next suspension point; a synchronous tool body already executing in a worker thread cannot be interrupted and may complete its side effects, but its result is discarded and never reaches the transcript, the model, or history, and failure propagation is not delayed behind it. | `TestMiddlewareFailure::test_failure_cancels_concurrent_sibling_tool`, `test_failure_with_sync_sibling_discards_late_result` | | Middleware failure on a service-managed conversation | The continuation state is already persisted when the batch fails, so before propagating, the loop settles the hosted thread: one error `function_result` per dangling call, sent with `tool_choice="none"` in one extra request; the persisted continuation advances to the settlement response (required for response-ID continuations, a no-op for conversation-object ids) and the settlement response is otherwise discarded; a settlement failure never masks the abort. Without a service-managed conversation no extra request is made. | `TestMiddlewareFailure::test_failure_settles_dangling_calls_on_service_conversation`, `test_failure_settles_service_conversation_streaming`, `test_failure_settlement_advances_response_id_continuation`, `test_failure_without_service_conversation_makes_no_settlement_request` | | Middleware failure during approved-tool replay | A fatal abort while the approval-resolution phase replays an approved tool escapes loudly (never absorbed into a rejection result), the tool's original — already service-persisted — call is settled the same way, and the continuation advances; both response modes. | `TestMiddlewareFailure::test_failure_during_approved_replay_settles_and_escapes`, `test_failure_during_approved_replay_streaming` | +| Provider-invalidated partial response output | `ResponseInvalidatedException` propagates unchanged in both response modes after clearing request budget state and restoring the last valid continuation. Partial streamed call deltas may remain visible, but no new approval, function middleware, tool body, result, follow-up model call, or settlement occurs. Anthropic requires every local call block to close, terminal `stop_reason="tool_use"`, and `message_stop`; non-tool terminal reasons, open blocks, missing `message_stop`, and non-cancellation stream errors after local call start invalidate the call. Hosted/server-only calls and caller cancellation remain unaffected. | `packages/core/tests/core/test_function_invocation_logic.py::test_response_invalidation_short_circuits_current_iteration`, `test_invalidated_final_no_tool_response_preserves_prior_continuation`, `packages/anthropic/tests/test_anthropic_client.py::test_non_streaming_local_tool_call_with_invalidating_stop_reason_raises`, `test_valid_streaming_local_tool_call_executes_and_continues`, `test_streaming_local_tool_call_invalid_terminal_sequences_raise`, `test_streaming_provider_error_after_local_call_is_wrapped_as_invalidation`, `test_streaming_cancellation_after_local_call_is_not_wrapped`, `test_streaming_hosted_tool_pause_turn_is_not_invalidated` | | Maximum iterations | No orphan calls; a final no-tool response or deterministic fallback is returned. | `test_max_iterations_limit`, `test_max_iterations_no_orphaned_function_calls`, `test_max_iterations_makes_final_toolchoice_none_call`, `test_max_iterations_blank_final_fallback_synthesizes_message`, streaming equivalents | | Maximum function calls | Parallel overshoot is bounded after the batch; every executed result group counts even without a `function_result`; blank final responses get fallback content. | `test_max_function_calls_limits_parallel_invocations`, `test_max_function_calls_single_calls_per_iteration`, `test_user_input_request_multiple_contents_propagate`, `test_approval_resume_user_input_counts_toward_function_call_budget`, `test_max_function_calls_blank_final_fallback_synthesizes_message`, streaming equivalent | | Provider tool content after an active limit | Locally actionable calls and local approval requests returned despite `tool_choice="none"` are removed in both response modes. Provider-executed informational call/result pairs, hosted approval requests, and metadata-only streaming updates remain visible; fallback text never replaces retained transcript content. | `test_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_preserves_metadata_after_tool_content_is_dropped`, `test_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_function_invocation_limit_preserves_hosted_approval_request`, `test_streaming_function_invocation_limit_preserves_hosted_approval_request` | @@ -634,6 +649,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Pending hosted history replay | Stateless hosted approval requests remain replayable until a response is recorded, then both controls become inert. | `packages/openai/tests/openai/test_openai_chat_client.py::test_stateless_history_preserves_pending_hosted_approval_request_until_response` | | Non-history provider plus session | Local history is still auto-injected for approval resume. | `packages/core/tests/core/test_agents.py::test_non_history_context_provider_still_injects_inmemory` | | Hosted per-service-call persistence | A host-managed transcript remains available throughout a local function-call loop without being persisted into the framework session and replayed on the next hosted request. | `packages/foundry_hosting/tests/test_responses.py::TestAgentSessionPersistence::test_per_service_call_persistence_preserves_function_loop_history` | +| Invalidated per-service-call persistence | An invalidated service response, its partial calls, and any nonexistent results are not success-persisted, and invalid content does not advance session continuation state. | `packages/core/tests/core/test_function_invocation_logic.py::test_invalidated_response_is_not_persisted` | | Streaming message injection with per-service-call persistence | A streaming response rebuilt from updates mirrors the inner conversation id exactly, including clearing it, and keeps its internal marker, so the next iteration appends only the latest message rather than replaying the whole turn on top of provider-held history, and never persists a conversation id from an earlier injected service call. | `packages/core/tests/core/test_middleware_with_chat.py::TestChatMiddleware::test_message_injection_middleware_streaming_preserves_inner_continuation_state`, `test_message_injection_middleware_streaming_keeps_service_conversation_id_external`, `test_message_injection_middleware_streaming_clears_conversation_id_when_final_call_has_none`, `test_message_injection_middleware_conversation_id_matches_across_streaming_modes`, `packages/core/tests/core/test_harness_agent.py::test_streaming_harness_tool_call_does_not_duplicate_transcript` | | Service-side approval decision | Stored hosted request is skipped; the current approved or rejected hosted response is sent, while local approval controls are omitted from provider input. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage`, `test_prepare_messages_drops_local_approval_controls` | | OpenAI approval serialization | Hosted approval id and decision serialize to `mcp_approval_response`; local approvals remain in-process. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` | diff --git a/python/CODING_STANDARD.md b/python/CODING_STANDARD.md index 6756c860f96..145fef81fca 100644 --- a/python/CODING_STANDARD.md +++ b/python/CODING_STANDARD.md @@ -240,6 +240,7 @@ AgentFrameworkException # Base for all AF exceptions │ └── AgentContentFilterException # Agent content filter triggered │ ├── ChatClientException # Chat client lifecycle and communication failures +│ ├── ResponseInvalidatedException # Provider invalidated partial response output │ ├── ChatClientInvalidAuthException # Chat client auth failures │ ├── ChatClientInvalidRequestException # Invalid request to chat client │ ├── ChatClientInvalidResponseException # Invalid/unexpected response from chat client @@ -284,6 +285,7 @@ AgentFrameworkException # Base for all AF exceptions | Object in wrong state (e.g., client not initialized) | `RuntimeError` | | External service returns 401/403 | `IntegrationInvalidAuthException` (or `ChatClient`/`Agent` variant) | | External service returns unexpected response | `IntegrationInvalidResponseException` (or variant) | +| Chat provider invalidates partial response output containing local function calls | `ResponseInvalidatedException` | | Content filter blocks a request | `IntegrationContentFilterException` (or variant) | | Request validation fails before sending to service | `IntegrationInvalidRequestException` (or variant) | | Agent not found in registry | `AgentInvalidRequestException` | diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index aeb4822411e..b3b0458457c 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence @@ -38,6 +39,7 @@ ChatClientException, ChatClientInvalidAuthException, ChatClientInvalidRequestException, + ResponseInvalidatedException, ) from agent_framework.observability import ChatTelemetryLayer from anthropic import APIError as AnthropicAPIError @@ -599,15 +601,54 @@ async def _stream() -> AsyncIterable[ChatResponseUpdate]: # each message_delta carries the running total), so thread a per-stream # accumulator to _process_stream_event to emit increments instead. emitted_usage: dict[str, int] = {} + local_function_call_started = False + open_local_function_call_blocks: set[int] = set() + latest_stop_reason: str | None = None + message_stop_received = False mark_feature_used(FeatureIndex.ANTHROPIC) try: async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True): parsed_chunk = self._process_stream_event(chunk, emitted_usage, request_state=request_state) + if chunk.type == "content_block_start" and parsed_chunk is not None: + if any( + content.type == "function_call" and not content.informational_only + for content in parsed_chunk.contents + ): + local_function_call_started = True + open_local_function_call_blocks.add(chunk.index) + elif chunk.type == "content_block_stop": + open_local_function_call_blocks.discard(chunk.index) + elif chunk.type == "message_delta" and chunk.delta.stop_reason is not None: + latest_stop_reason = chunk.delta.stop_reason + elif chunk.type == "message_stop": + message_stop_received = True + if local_function_call_started and ( + open_local_function_call_blocks or latest_stop_reason != "tool_use" + ): + raise ResponseInvalidatedException( + "Anthropic invalidated partial response output; " + "local function calls must not execute." + ) if parsed_chunk: yield parsed_chunk - except AgentFrameworkException: + if local_function_call_started and not message_stop_received: + raise ResponseInvalidatedException( + "Anthropic invalidated partial response output after the response ended without " + "message_stop; local function calls must not execute." + ) + except asyncio.CancelledError: + raise + except ResponseInvalidatedException: raise except Exception as ex: + if local_function_call_started: + raise ResponseInvalidatedException( + "Anthropic invalidated partial response output after the response stream failed; " + "local function calls must not execute.", + inner_exception=ex, + ) from ex + if isinstance(ex, AgentFrameworkException): + raise raise _wrap_anthropic_error(ex) from ex return self._build_response_stream(_stream(), response_format=options.get("response_format")) @@ -1157,12 +1198,20 @@ def _process_message( Returns: A ChatResponse object containing the processed response. """ + contents = self._parse_contents_from_anthropic(message.content, request_state=request_state) + if message.stop_reason != "tool_use" and any( + content.type == "function_call" and not content.informational_only for content in contents + ): + raise ResponseInvalidatedException( + "Anthropic invalidated partial response output; local function calls must not execute." + ) + return ChatResponse( response_id=message.id, messages=[ Message( role="assistant", - contents=self._parse_contents_from_anthropic(message.content, request_state=request_state), + contents=contents, raw_representation=message, ) ], diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 182be3e99b5..3045222d7ef 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.15.0,<2", + "agent-framework-core>=1.19.0,<2", "anthropic>=0.80.0,<0.117.0", ] diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 36a65228abd..24597877291 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -20,6 +20,7 @@ FunctionTool, InlineSkill, Message, + ResponseInvalidatedException, ResponseStream, SkillFrontmatter, SkillsProvider, @@ -2083,9 +2084,10 @@ def _message_start_event(response_id: str) -> MagicMock: return event -def _tool_start_event(*, block_type: str, call_id: str, name: str) -> MagicMock: +def _tool_start_event(*, block_type: str, call_id: str, name: str, index: int = 0) -> MagicMock: event = MagicMock() event.type = "content_block_start" + event.index = index event.content_block.type = block_type event.content_block.id = call_id event.content_block.name = name @@ -2093,6 +2095,19 @@ def _tool_start_event(*, block_type: str, call_id: str, name: str) -> MagicMock: return event +def _content_block_stop_event(index: int = 0) -> MagicMock: + event = MagicMock() + event.type = "content_block_stop" + event.index = index + return event + + +def _message_stop_event() -> MagicMock: + event = MagicMock() + event.type = "message_stop" + return event + + def _argument_delta_event(partial_json: str) -> MagicMock: event = MagicMock() event.type = "content_block_delta" @@ -2109,6 +2124,262 @@ def _message_delta_event(stop_reason: str) -> MagicMock: return event +def _local_tool_message(stop_reason: str) -> MagicMock: + message = MagicMock(spec=BetaMessage) + message.id = "msg_local_tool" + message.model = "claude-test" + message.content = [ + BetaToolUseBlock( + type="tool_use", + id="local-call", + name="local_tool", + input={"value": "a"}, + ) + ] + message.usage = None + message.stop_reason = stop_reason + return message + + +@pytest.mark.parametrize("stop_reason", ["max_tokens", "refusal"]) +async def test_non_streaming_local_tool_call_with_invalidating_stop_reason_raises( + mock_anthropic_client: MagicMock, + stop_reason: str, +) -> None: + """Anthropic terminal reasons other than tool_use invalidate local calls even when arguments parse.""" + executions = 0 + + @tool(name="local_tool", approval_mode="never_require") + def local_tool(value: str) -> str: + nonlocal executions + executions += 1 + return value + + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.return_value = _local_tool_message(stop_reason) + + with pytest.raises(ResponseInvalidatedException, match="Anthropic invalidated"): + await client.get_response( + [Message(role="user", contents=["run"])], + options={"tools": [local_tool], "max_tokens": 64}, + ) + + assert executions == 0 + assert mock_anthropic_client.beta.messages.create.call_count == 1 + + +def test_non_streaming_hosted_tool_pause_turn_is_not_invalidated( + mock_anthropic_client: MagicMock, +) -> None: + """Hosted server-tool content keeps pause_turn semantics and is not a local invalidation.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = MagicMock(spec=BetaMessage) + message.id = "msg_hosted" + message.model = "claude-test" + hosted_block = MagicMock() + hosted_block.type = "server_tool_use" + hosted_block.id = "hosted-call" + hosted_block.name = "local_tool" + hosted_block.input = {"value": "a"} + message.content = [hosted_block] + message.usage = None + message.stop_reason = "pause_turn" + + response = client._process_message(message, {}) + + assert response.finish_reason == "stop" + assert response.messages[0].contents[0].type == "function_call" + assert response.messages[0].contents[0].informational_only is True + + +async def test_valid_streaming_local_tool_call_executes_and_continues( + mock_anthropic_client: MagicMock, +) -> None: + """Closed local tool_use streams with message_stop retain the normal function loop.""" + executions: list[str] = [] + + @tool(name="local_tool", approval_mode="never_require") + def local_tool(value: str) -> str: + executions.append(value) + return f"found {value}" + + async def local_events() -> Any: + yield _message_start_event("tool-response") + yield _tool_start_event(block_type="tool_use", call_id="local-call", name="local_tool") + yield _argument_delta_event('{"value": "a"}') + yield _content_block_stop_event() + yield _message_delta_event("tool_use") + yield _message_stop_event() + + async def final_events() -> Any: + yield _message_start_event("final-response") + text_start = MagicMock() + text_start.type = "content_block_start" + text_start.index = 0 + text_start.content_block.type = "text" + text_start.content_block.text = "done" + text_start.content_block.citations = None + yield text_start + yield _content_block_stop_event() + yield _message_delta_event("end_turn") + yield _message_stop_event() + + request_count = 0 + + async def create(**kwargs: Any) -> Any: + nonlocal request_count + del kwargs + request_count += 1 + return local_events() if request_count == 1 else final_events() + + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.side_effect = create + stream = client.get_response( + [Message(role="user", contents=["run"])], + options={"tools": [local_tool], "max_tokens": 64}, + stream=True, + ) + updates = [update async for update in stream] + response = await stream.get_final_response() + + assert executions == ["a"] + assert request_count == 2 + assert any(content.type == "function_result" for update in updates for content in update.contents) + assert response.text == "done" + + +@pytest.mark.parametrize( + ("close_block", "stop_reason", "include_message_stop"), + [ + (True, "max_tokens", True), + (True, "refusal", True), + (False, "tool_use", True), + (True, "tool_use", False), + ], + ids=["max_tokens", "refusal", "open_block", "missing_message_stop"], +) +async def test_streaming_local_tool_call_invalid_terminal_sequences_raise( + mock_anthropic_client: MagicMock, + close_block: bool, + stop_reason: str, + include_message_stop: bool, +) -> None: + """Local streamed calls require closed blocks, tool_use, and message_stop.""" + + async def events() -> Any: + yield _message_start_event("invalid-response") + start = _tool_start_event(block_type="tool_use", call_id="local-call", name="local_tool") + start.content_block.input = {"value": "a"} + yield start + if close_block: + yield _content_block_stop_event() + yield _message_delta_event(stop_reason) + if include_message_stop: + yield _message_stop_event() + + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.return_value = events() + stream = client._inner_get_response( # type: ignore[attr-defined] + messages=[Message(role="user", contents=["run"])], + options={"max_tokens": 64}, + stream=True, + ) + assert isinstance(stream, ResponseStream) + updates: list[ChatResponseUpdate] = [] + + with pytest.raises(ResponseInvalidatedException, match="Anthropic invalidated"): + async for update in stream: + updates.append(update) + + assert any(content.type == "function_call" for update in updates for content in update.contents) + + +async def test_streaming_provider_error_after_local_call_is_wrapped_as_invalidation( + mock_anthropic_client: MagicMock, +) -> None: + """A provider failure after local call content preserves the original error as the inner exception.""" + original = RuntimeError("provider stream failed") + + async def events() -> Any: + yield _message_start_event("invalid-response") + yield _tool_start_event(block_type="tool_use", call_id="local-call", name="local_tool") + raise original + + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.return_value = events() + stream = client._inner_get_response( # type: ignore[attr-defined] + messages=[Message(role="user", contents=["run"])], + options={"max_tokens": 64}, + stream=True, + ) + assert isinstance(stream, ResponseStream) + + with pytest.raises(ResponseInvalidatedException) as exc_info: + async for _ in stream: + pass + + assert exc_info.value.args[1] is original + assert exc_info.value.__cause__ is original + + +async def test_streaming_cancellation_after_local_call_is_not_wrapped( + mock_anthropic_client: MagicMock, +) -> None: + """Caller cancellation remains asyncio.CancelledError after local call content.""" + + async def events() -> Any: + yield _message_start_event("cancelled-response") + yield _tool_start_event(block_type="tool_use", call_id="local-call", name="local_tool") + raise asyncio.CancelledError + + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.return_value = events() + stream = client._inner_get_response( # type: ignore[attr-defined] + messages=[Message(role="user", contents=["run"])], + options={"max_tokens": 64}, + stream=True, + ) + assert isinstance(stream, ResponseStream) + + with pytest.raises(asyncio.CancelledError): + async for _ in stream: + pass + + +async def test_streaming_hosted_tool_pause_turn_is_not_invalidated( + mock_anthropic_client: MagicMock, +) -> None: + """Hosted-only calls keep pause_turn behavior even when their name matches a local tool.""" + + @tool(name="local_tool", approval_mode="never_require") + def local_tool(value: str) -> str: + return value + + async def events() -> Any: + yield _message_start_event("hosted-response") + yield _tool_start_event(block_type="server_tool_use", call_id="hosted-call", name="local_tool") + yield _content_block_stop_event() + yield _message_delta_event("pause_turn") + yield _message_stop_event() + + client = create_test_anthropic_client(mock_anthropic_client) + mock_anthropic_client.beta.messages.create.return_value = events() + stream = client._inner_get_response( # type: ignore[attr-defined] + messages=[Message(role="user", contents=["run"])], + options={"tools": [local_tool], "max_tokens": 64}, + stream=True, + ) + assert isinstance(stream, ResponseStream) + + updates = [update async for update in stream] + response = await stream.get_final_response() + + hosted_calls = [content for update in updates for content in update.contents if content.type == "function_call"] + assert len(hosted_calls) == 1 + assert hosted_calls[0].informational_only is True + assert response.finish_reason == "stop" + + async def test_concurrent_streams_isolate_tool_state_when_peer_fails( mock_anthropic_client: MagicMock, ) -> None: @@ -2136,7 +2407,9 @@ async def primary_events() -> Any: yield _argument_delta_event('{"command":') await asyncio.wait_for(hosted_failed.wait(), timeout=1) yield _argument_delta_event('"pwd"}') + yield _content_block_stop_event() yield _message_delta_event("tool_use") + yield _message_stop_event() async def hosted_events() -> Any: await asyncio.wait_for(primary_started.wait(), timeout=1) @@ -2226,7 +2499,9 @@ async def approval_events(response_id: str, command: str) -> Any: yield _tool_start_event(block_type="tool_use", call_id="shared-approval-call", name="bash") yield _argument_delta_event('{"command":') yield _argument_delta_event(f'"{command}"}}') + yield _content_block_stop_event() yield _message_delta_event("tool_use") + yield _message_stop_event() async def create(**kwargs: Any) -> Any: nonlocal request_count diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 691c35e9110..ea9e4577a22 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -33,6 +33,7 @@ from .exceptions import ( AgentFrameworkException, MiddlewareException, + ResponseInvalidatedException, UserInputRequiredException, WorkflowCheckpointException, WorkflowConvergenceException, @@ -565,6 +566,7 @@ "Param", "RawAgent", "ReleaseCandidateFeature", + "ResponseInvalidatedException", "ResponseStream", "Role", "RoleLiteral", diff --git a/python/packages/core/agent_framework/__init__.pyi b/python/packages/core/agent_framework/__init__.pyi index b6ad94bdf11..350a458a389 100644 --- a/python/packages/core/agent_framework/__init__.pyi +++ b/python/packages/core/agent_framework/__init__.pyi @@ -335,6 +335,7 @@ from ._workflows._workflow_executor import SubWorkflowRequestMessage, SubWorkflo from .exceptions import ( AgentFrameworkException, MiddlewareException, + ResponseInvalidatedException, UserInputRequiredException, WorkflowCheckpointException, WorkflowConvergenceException, @@ -520,6 +521,7 @@ __all__ = [ "Param", "RawAgent", "ReleaseCandidateFeature", + "ResponseInvalidatedException", "ResponseStream", "Role", "RoleLiteral", diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 805c3df5849..64f8d0f9557 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -46,7 +46,7 @@ from pydantic import BaseModel, Field, ValidationError, create_model from ._serialization import SerializationMixin -from .exceptions import ToolException, UserInputRequiredException +from .exceptions import ResponseInvalidatedException, ToolException, UserInputRequiredException from .observability import ( OPERATION_DURATION_BUCKET_BOUNDARIES, OtelAttr, @@ -3378,6 +3378,57 @@ def _clear_budget_state_from_session(invocation_session: AgentSession | None) -> invocation_session.state.pop(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, None) +def _response_invalidation_cleanup( + invocation_session: AgentSession | None, + budget_state: dict[str, Any], + stream_error: list[ResponseInvalidatedException] | None = None, +) -> Callable[[ResponseInvalidatedException], None]: + """Capture the last valid continuation and return invalidation cleanup.""" + service_session_id = ( + copy.deepcopy(invocation_session.service_session_id) if invocation_session is not None else None + ) + + def cleanup(error: ResponseInvalidatedException) -> None: + if stream_error is not None: + stream_error[:] = [error] + budget_state.clear() + if invocation_session is None: + return + invocation_session.state.pop(_FUNCTION_INVOCATION_BUDGET_STATE_KEY, None) + invocation_session.service_session_id = service_session_id + + return cleanup + + +_InvalidationResultT = TypeVar("_InvalidationResultT") + + +async def _await_provider_call( + operation: Callable[..., Awaitable[_InvalidationResultT]], + on_invalidated: Callable[[ResponseInvalidatedException], None], + **kwargs: Any, +) -> _InvalidationResultT: + """Await one provider operation and clean up invalidated local calls.""" + try: + return await operation(**kwargs) + except ResponseInvalidatedException as error: + on_invalidated(error) + raise + + +async def _iterate_provider_stream( + stream: AsyncIterable[_InvalidationResultT], + on_invalidated: Callable[[ResponseInvalidatedException], None], +) -> AsyncIterable[_InvalidationResultT]: + """Yield one provider stream and clean up invalidated local calls.""" + try: + async for item in stream: + yield item + except ResponseInvalidatedException as error: + on_invalidated(error) + raise + + def _apply_batch_limit_decision( action: Literal["continue", "return", "stop"], options: dict[str, Any], @@ -3965,9 +4016,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non # Phase 2: alternate model turns and local execution until a terminal response or safety limit is reached. for attempt_idx in range(attempt_start, max_iterations): budget_state["attempt_count"] = attempt_idx + 1 + on_invalidated = _response_invalidation_cleanup(invocation_session, budget_state) response = cast( ChatResponse[Any], - await super_get_response( + await _await_provider_call( + super_get_response, + on_invalidated, messages=prepared_messages, stream=False, options=options, @@ -4040,9 +4094,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non max_iterations, ) options["tool_choice"] = "none" + on_invalidated = _response_invalidation_cleanup(invocation_session, budget_state) response = cast( ChatResponse[Any], - await super_get_response( + await _await_provider_call( + super_get_response, + on_invalidated, messages=prepared_messages, stream=False, options=options, @@ -4078,6 +4135,7 @@ async def _stream_response_with_function_invocation( budget_state: dict[str, Any], max_errors: int, middleware_pipeline: FunctionMiddlewarePipeline | None = None, + invalidation_error: list[ResponseInvalidatedException], ) -> AsyncIterable[ChatResponseUpdate]: """Run the streaming function invocation loop.""" from ._middleware import MiddlewareFailure @@ -4147,9 +4205,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non # Phase 2: stream each model turn, finalize it, execute its calls, then advance the transcript. for attempt_idx in range(attempt_start, max_iterations): budget_state["attempt_count"] = attempt_idx + 1 + on_invalidated = _response_invalidation_cleanup(invocation_session, budget_state, invalidation_error) inner_stream = cast( "ResponseStream[ChatResponseUpdate, ChatResponse[Any]]", - super_get_response( + await _await_provider_call( + super_get_response, + on_invalidated, messages=prepared_messages, stream=True, options=options, @@ -4158,13 +4219,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non client_kwargs=request_kwargs, ), ) - await inner_stream drop_unexecutable_calls = options.get("tool_choice") == "none" and budget_state.get("truncated") streamed_identities_by_call_id: dict[str, tuple[str, str]] = {} streamed_names_by_call_id: dict[str, str] = {} last_streamed_identity: tuple[str, str] | None = None warned_empty_call_ids: set[str] = set() - async for update in inner_stream: + async for update in _iterate_provider_stream(inner_stream, on_invalidated): for content in update.contents: if content.type != "function_call": continue @@ -4221,7 +4281,7 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non continue yield update - response = await inner_stream.get_final_response() + response = await _await_provider_call(inner_stream.get_final_response, on_invalidated) fallback_added = False if options.get("tool_choice") == "none" and budget_state.get("truncated"): fallback_added = _ensure_function_invocation_limit_fallback_response(response) @@ -4296,9 +4356,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non max_iterations, ) options["tool_choice"] = "none" + on_invalidated = _response_invalidation_cleanup(invocation_session, budget_state, invalidation_error) final_inner_stream = cast( "ResponseStream[ChatResponseUpdate, ChatResponse[Any]]", - super_get_response( + await _await_provider_call( + super_get_response, + on_invalidated, messages=prepared_messages, stream=True, options=options, @@ -4307,13 +4370,12 @@ async def settle_approval_replay_calls(function_calls: Sequence[Content]) -> Non client_kwargs=request_kwargs, ), ) - await final_inner_stream - async for update in final_inner_stream: + async for update in _iterate_provider_stream(final_inner_stream, on_invalidated): update = _drop_unexecutable_tool_contents_from_update(update) if update is None: continue yield update - final_response = await final_inner_stream.get_final_response() + final_response = await _await_provider_call(final_inner_stream.get_final_response, on_invalidated) fallback_added = _ensure_function_invocation_limit_fallback_response(final_response) self._update_function_invocation_continuation_state( request_kwargs, @@ -4496,6 +4558,12 @@ def get_response( ) response_format = mutable_options.get("response_format") + invalidation_error: list[ResponseInvalidatedException] = [] + + def finalize_stream(updates: Sequence[ChatResponseUpdate]) -> ChatResponse[Any]: + if invalidation_error: + raise invalidation_error[0] + return ChatResponse.from_updates(updates, output_format_type=response_format) return ResponseStream( self._stream_response_with_function_invocation( @@ -4510,8 +4578,9 @@ def get_response( budget_state=budget_state, max_errors=max_errors, middleware_pipeline=function_middleware_pipeline, + invalidation_error=invalidation_error, ), - finalizer=partial(ChatResponse.from_updates, output_format_type=response_format), + finalizer=finalize_stream, ) diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index b96aab6b916..03f950e331a 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -82,6 +82,12 @@ class ChatClientException(AgentFrameworkException): pass +class ResponseInvalidatedException(ChatClientException): + """A provider invalidated partial response output, so its local function calls must not execute.""" + + pass + + class ChatClientInvalidAuthException(ChatClientException): """An authentication error occurred in a chat client.""" diff --git a/python/packages/core/tests/core/test_exceptions.py b/python/packages/core/tests/core/test_exceptions.py index bf164cdbe24..1605268cc56 100644 --- a/python/packages/core/tests/core/test_exceptions.py +++ b/python/packages/core/tests/core/test_exceptions.py @@ -2,7 +2,8 @@ """Tests for AgentFrameworkException inner_exception handling.""" -from agent_framework import AgentFrameworkException +from agent_framework import AgentFrameworkException, ResponseInvalidatedException +from agent_framework.exceptions import ChatClientException, ChatClientInvalidResponseException def test_exception_with_inner_exception(): @@ -25,3 +26,19 @@ def test_exception_inner_exception_none_explicit(): exc = AgentFrameworkException("test message", inner_exception=None) assert exc.args == ("test message",) assert len(exc.args) == 1 + + +def test_response_invalidated_exception_is_public_and_picklable() -> None: + """The partial-response invalidation signal is a public chat-client exception.""" + import pickle + + inner = RuntimeError("provider stream failed") + exc = ResponseInvalidatedException("partial response output was invalidated", inner_exception=inner) + restored = pickle.loads(pickle.dumps(exc)) + + assert isinstance(exc, ChatClientException) + assert not isinstance(exc, ChatClientInvalidResponseException) + assert isinstance(restored, ResponseInvalidatedException) + assert restored.args[0] == "partial response output was invalidated" + assert isinstance(restored.args[1], RuntimeError) + assert str(restored.args[1]) == "provider stream failed" diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index d829aaa4c31..f09dab17485 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -20,6 +20,7 @@ ChatResponseUpdate, Content, Message, + ResponseInvalidatedException, ResponseStream, SupportsChatGetResponse, chat_middleware, @@ -1001,6 +1002,430 @@ def ai_func(arg1: str) -> str: assert response.messages[2].text == "done" +async def test_function_call_with_length_finish_reason_executes_and_continues( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Usable function arguments remain actionable regardless of the enclosing finish reason.""" + executions: list[str] = [] + + @tool(name="lookup", approval_mode="never_require") + def lookup(value: str) -> str: + executions.append(value) + return f"found {value}" + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[Content.from_function_call(call_id="call-1", name="lookup", arguments={"value": "a"})], + ), + finish_reason="length", + ), + ChatResponse(messages=Message(role="assistant", contents=["done"]), finish_reason="stop"), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["look up a"])], + options={"tools": [lookup]}, + ) + + assert executions == ["a"] + assert chat_client_base.call_count == 2 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + assert [message.role for message in response.messages] == ["assistant", "tool", "assistant"] + assert response.messages[1].contents[0].result == "found a" + assert response.text == "done" + + +async def test_streamed_function_call_with_length_finish_reason_executes_and_continues( + chat_client_base: SupportsChatGetResponse, +) -> None: + """Streaming uses argument validity, not the enclosing finish reason, to decide execution.""" + executions: list[str] = [] + + @tool(name="lookup", approval_mode="never_require") + def lookup(value: str) -> str: + executions.append(value) + return f"found {value}" + + chat_client_base.streaming_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + [ + ChatResponseUpdate( + role="assistant", + contents=[Content.from_function_call(call_id="call-1", name="lookup", arguments={"value": "a"})], + finish_reason="length", + ) + ], + [ChatResponseUpdate(role="assistant", contents=[Content.from_text("done")], finish_reason="stop")], + ] + + stream = chat_client_base.get_response( + [Message(role="user", contents=["look up a"])], + options={"tools": [lookup]}, + stream=True, + ) + updates = [update async for update in stream] + response = await stream.get_final_response() + + assert executions == ["a"] + assert chat_client_base.call_count == 2 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + assert [content.type for update in updates for content in update.contents] == [ + "function_call", + "function_result", + "text", + ] + assert response.text == "done" + + +@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) +async def test_response_invalidation_short_circuits_current_iteration( + chat_client_base: SupportsChatGetResponse, + streaming: bool, +) -> None: + """Provider invalidation propagates without creating any local function side effect.""" + from agent_framework._sessions import AgentSession + from agent_framework._tools import _FUNCTION_INVOCATION_BUDGET_STATE_KEY + + middleware_calls = 0 + tool_calls = 0 + provider_calls = 0 + invalidated = ResponseInvalidatedException("provider invalidated partial response output") + + class TrackingMiddleware(FunctionMiddleware): + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + nonlocal middleware_calls + middleware_calls += 1 + await call_next() + + @tool(name="guarded", approval_mode="always_require") + def guarded(value: str) -> str: + nonlocal tool_calls + tool_calls += 1 + return value + + session = AgentSession() + session.service_session_id = "existing-session" + budget_state: dict[str, Any] = {"attempt_count": 0, "total_function_calls": 2} + session.state[_FUNCTION_INVOCATION_BUDGET_STATE_KEY] = budget_state + yielded: list[ChatResponseUpdate] = [] + + if streaming: + + def invalid_stream(**kwargs: Any) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + del kwargs + + async def updates() -> AsyncIterable[ChatResponseUpdate]: + nonlocal provider_calls + provider_calls += 1 + yield ChatResponseUpdate( + role="assistant", + contents=[ + Content.from_function_call( + call_id="invalid-call", + name="guarded", + arguments={"value": "x"}, + ) + ], + ) + raise invalidated + + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + + chat_client_base._get_streaming_response = invalid_stream # type: ignore[attr-defined, method-assign] # ty: ignore[unresolved-attribute] + response_stream = chat_client_base.get_response( + [Message(role="user", contents=["run"])], + options={"tools": [guarded]}, + stream=True, + client_kwargs={ + "middleware": [TrackingMiddleware()], + "session": session, + _FUNCTION_INVOCATION_BUDGET_STATE_KEY: budget_state, + }, + ) + with pytest.raises(ResponseInvalidatedException) as exc_info: + async for update in response_stream: + yielded.append(update) + else: + + async def invalid_response(**kwargs: Any) -> ChatResponse: + nonlocal provider_calls + del kwargs + provider_calls += 1 + raise invalidated + + chat_client_base._get_non_streaming_response = invalid_response # type: ignore[attr-defined, method-assign] # ty: ignore[unresolved-attribute] + with pytest.raises(ResponseInvalidatedException) as exc_info: + await chat_client_base.get_response( + [Message(role="user", contents=["run"])], + options={"tools": [guarded]}, + client_kwargs={ + "middleware": [TrackingMiddleware()], + "session": session, + _FUNCTION_INVOCATION_BUDGET_STATE_KEY: budget_state, + }, + ) + + assert exc_info.value is invalidated + assert provider_calls == 1 + assert middleware_calls == 0 + assert tool_calls == 0 + assert budget_state == {} + assert _FUNCTION_INVOCATION_BUDGET_STATE_KEY not in session.state + assert session.service_session_id == "existing-session" + assert not any( + content.type in {"function_approval_request", "function_result"} + for update in yielded + for content in update.contents + ) + if streaming: + assert [content.type for update in yielded for content in update.contents] == ["function_call"] + + +@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) +async def test_response_invalidation_restores_structured_continuation_snapshot( + chat_client_base: SupportsChatGetResponse, + streaming: bool, +) -> None: + """Cleanup restores a copy when a provider mutates structured continuation state in place.""" + from agent_framework._sessions import AgentSession + + original_service_session_id = {"conversation_id": "valid", "metadata": {"generation": 1}} + session = AgentSession(service_session_id=original_service_session_id) + invalidated = ResponseInvalidatedException("provider invalidated partial response output") + + def mutate_continuation() -> None: + service_session_id = session.service_session_id + assert isinstance(service_session_id, dict) + service_session_id["conversation_id"] = "invalid" + metadata = service_session_id["metadata"] + assert isinstance(metadata, dict) + metadata["generation"] = 2 + + if streaming: + + def invalid_stream(**kwargs: Any) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + del kwargs + + async def updates() -> AsyncIterable[ChatResponseUpdate]: + mutate_continuation() + raise invalidated + yield # pragma: no cover + + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + + chat_client_base._get_streaming_response = invalid_stream # type: ignore[attr-defined, method-assign] # ty: ignore[unresolved-attribute] + stream = chat_client_base.get_response( + [Message(role="user", contents=["run"])], + options={"tools": []}, + stream=True, + client_kwargs={"session": session}, + ) + with pytest.raises(ResponseInvalidatedException): + async for _ in stream: + pass + else: + + async def invalid_response(**kwargs: Any) -> ChatResponse: + del kwargs + mutate_continuation() + raise invalidated + + chat_client_base._get_non_streaming_response = invalid_response # type: ignore[attr-defined, method-assign] # ty: ignore[unresolved-attribute] + with pytest.raises(ResponseInvalidatedException): + await chat_client_base.get_response( + [Message(role="user", contents=["run"])], + options={"tools": []}, + client_kwargs={"session": session}, + ) + + assert session.service_session_id == {"conversation_id": "valid", "metadata": {"generation": 1}} + assert session.service_session_id is not original_service_session_id + + +@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) +async def test_invalidated_response_is_not_persisted( + chat_client_base: SupportsChatGetResponse, + streaming: bool, +) -> None: + """Per-service-call history never success-persists invalidated calls or partial results.""" + from agent_framework import InMemoryHistoryProvider + from agent_framework._sessions import AgentSession + + provider = InMemoryHistoryProvider() + session = AgentSession() + seed = Message(role="assistant", contents=["existing history"]) + session.state[provider.source_id] = {"messages": [seed]} + invalidated = ResponseInvalidatedException("provider invalidated partial response output") + tool_calls = 0 + + @tool(name="local_tool", approval_mode="never_require") + def local_tool() -> str: + nonlocal tool_calls + tool_calls += 1 + return "result" + + if streaming: + + class InvalidRawUpdate: + conversation_id = "invalid-session" + + def invalid_stream(**kwargs: Any) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + del kwargs + + async def updates() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate( + role="assistant", + contents=[Content.from_function_call(call_id="invalid-call", name="local_tool", arguments={})], + raw_representation=InvalidRawUpdate(), + ) + raise invalidated + + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + + chat_client_base._get_streaming_response = invalid_stream # type: ignore[attr-defined, method-assign] # ty: ignore[unresolved-attribute] + else: + + async def invalid_response(**kwargs: Any) -> ChatResponse: + del kwargs + raise invalidated + + chat_client_base._get_non_streaming_response = invalid_response # type: ignore[attr-defined, method-assign] # ty: ignore[unresolved-attribute] + + agent = Agent( + client=chat_client_base, + tools=[local_tool], + context_providers=[provider], + require_per_service_call_history_persistence=True, + ) + + if streaming: + stream = agent.run("run", session=session, stream=True) + with pytest.raises(ResponseInvalidatedException) as exc_info: + async for _ in stream: + pass + with pytest.raises(ResponseInvalidatedException) as final_exc_info: + await stream.get_final_response() + assert final_exc_info.value is invalidated + else: + with pytest.raises(ResponseInvalidatedException) as exc_info: + await agent.run("run", session=session) + + assert exc_info.value is invalidated + assert tool_calls == 0 + assert session.state[provider.source_id]["messages"] == [seed] + assert session.service_session_id is None + + +@pytest.mark.parametrize("streaming", [False, True], ids=["non_streaming", "streaming"]) +async def test_invalidated_final_no_tool_response_preserves_prior_continuation( + chat_client_base: SupportsChatGetResponse, + streaming: bool, +) -> None: + """Providers can invalidate unexpected local calls returned after tools were disabled.""" + from agent_framework._sessions import AgentSession + from agent_framework._tools import _FUNCTION_INVOCATION_BUDGET_STATE_KEY + + tool_calls = 0 + provider_calls = 0 + invalidated = ResponseInvalidatedException("provider invalidated partial response output") + + @tool(name="local_tool", approval_mode="never_require") + def local_tool() -> str: + nonlocal tool_calls + tool_calls += 1 + return "result" + + function_call = Content.from_function_call(call_id="valid-call", name="local_tool", arguments={}) + session = AgentSession() + session.service_session_id = "starting-continuation" + budget_state: dict[str, Any] = {"attempt_count": 0} + session.state[_FUNCTION_INVOCATION_BUDGET_STATE_KEY] = budget_state + chat_client_base.function_invocation_configuration["max_iterations"] = 1 # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + if streaming: + + def scripted_stream( + *, + options: dict[str, Any], + **kwargs: Any, + ) -> ResponseStream[ChatResponseUpdate, ChatResponse]: + nonlocal provider_calls + del kwargs + provider_calls += 1 + + async def updates() -> AsyncIterable[ChatResponseUpdate]: + if provider_calls == 1: + yield ChatResponseUpdate( + role="assistant", + contents=[function_call], + finish_reason="tool_calls", + conversation_id="valid-continuation", + ) + return + assert options["tool_choice"] == "none" + yield ChatResponseUpdate( + role="assistant", + contents=[Content.from_function_call(call_id="invalid-call", name="local_tool", arguments={})], + conversation_id="invalid-continuation", + ) + raise invalidated + + return ResponseStream(updates(), finalizer=ChatResponse.from_updates) + + chat_client_base._get_streaming_response = scripted_stream # type: ignore[attr-defined, method-assign] # ty: ignore[unresolved-attribute] + stream = chat_client_base.get_response( + [Message(role="user", contents=["run"])], + options={"tools": [local_tool]}, + stream=True, + client_kwargs={ + "session": session, + _FUNCTION_INVOCATION_BUDGET_STATE_KEY: budget_state, + }, + ) + with pytest.raises(ResponseInvalidatedException) as exc_info: + async for _ in stream: + pass + else: + + async def scripted_response( + *, + options: dict[str, Any], + **kwargs: Any, + ) -> ChatResponse: + nonlocal provider_calls + del kwargs + provider_calls += 1 + if provider_calls == 1: + return ChatResponse( + messages=Message(role="assistant", contents=[function_call]), + finish_reason="tool_calls", + conversation_id="valid-continuation", + ) + assert options["tool_choice"] == "none" + raise invalidated + + chat_client_base._get_non_streaming_response = scripted_response # type: ignore[attr-defined, method-assign] # ty: ignore[unresolved-attribute] + with pytest.raises(ResponseInvalidatedException) as exc_info: + await chat_client_base.get_response( + [Message(role="user", contents=["run"])], + options={"tools": [local_tool]}, + client_kwargs={ + "session": session, + _FUNCTION_INVOCATION_BUDGET_STATE_KEY: budget_state, + }, + ) + + assert exc_info.value is invalidated + assert provider_calls == 2 + assert tool_calls == 1 + assert budget_state == {} + assert _FUNCTION_INVOCATION_BUDGET_STATE_KEY not in session.state + assert session.service_session_id == "valid-continuation" + + async def test_base_client_with_function_calling_string_input(chat_client_base: SupportsChatGetResponse): exec_counter = 0 From 557a061f16b16db70b4a73d5792ae7059ec48ea5 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 16 Sep 2026 02:47:07 +0000 Subject: [PATCH 098/108] Python: Restrict argument labels against owned input integrity (#8394) --- .../packages/core/agent_framework/security.py | 26 ++-- python/packages/core/tests/test_security.py | 134 +++++++++++++++++- .../security/FIDES_DEVELOPER_GUIDE.md | 23 +-- 3 files changed, 162 insertions(+), 21 deletions(-) diff --git a/python/packages/core/agent_framework/security.py b/python/packages/core/agent_framework/security.py index 12e0a597e00..b7d87a5f43c 100644 --- a/python/packages/core/agent_framework/security.py +++ b/python/packages/core/agent_framework/security.py @@ -1268,14 +1268,15 @@ class LabelTrackingFunctionMiddleware(FunctionMiddleware, _SecurityScopeBinding) +----------+------------------------------------------+----------------------------+ | Tier 2 | Tool's source_integrity declaration | No embedded labels | +----------+------------------------------------------+----------------------------+ - | Tier 3 | Join (combine_labels) of input arg labels| No embedded labels AND | - | | | no source_integrity | + | Tier 3 | Owned input labels or configured default | No embedded labels AND | + | | restricted by argument labels | no source_integrity | +----------+------------------------------------------+----------------------------+ Tools can declare their source_integrity in additional_properties: - source_integrity="trusted": Tool produces trusted data (e.g., internal computation) - source_integrity="untrusted": Tool fetches external/untrusted data - - (not set): Falls back to tier 3 (input label join), or UNTRUSTED if no inputs + - (not set): Inherits integrity from resolved, owned variable references, or uses + default_integrity (UNTRUSTED by default). Argument labels may only restrict this baseline. This middleware: 1. Extracts labels from tool input arguments (tier 3 input) @@ -1604,8 +1605,9 @@ def _get_input_labels(self, context: FunctionInvocationContext) -> list[ContentL Recursively inspects the arguments passed to a tool to find any VariableReferenceContent objects or labeled data, and collects their labels. - These labels are used as the tier-3 fallback (lowest priority) when - neither embedded labels nor a source_integrity declaration are present. + These labels propagate confidentiality and restrict the tier-3 integrity + baseline when no source_integrity declaration is present. They cannot + establish trust; that baseline comes from owned references or the configured default. Args: context: The function invocation context containing arguments. @@ -1800,7 +1802,7 @@ async def process( # Step 3: Build tiered fallback_label # This label is used for result items that have NO embedded labels. - # Priority: source_integrity declaration (tier 2) > input labels join (tier 3) + # Priority: source_integrity declaration (tier 2) > owned input baseline (tier 3) if declared_source_integrity is not None: fallback_label = ContentLabel( integrity=declared_source_integrity, @@ -1808,7 +1810,13 @@ async def process( metadata={**fallback_metadata, "source": "source_integrity"}, ) elif argument_labels: - combined = combine_labels(*argument_labels) + # Argument-supplied labels can restrict, but never establish, integrity. + baseline = ( + combine_labels(*resolved_labels) + if resolved_labels + else ContentLabel(integrity=self.default_integrity) + ) + combined = combine_labels(baseline, *input_labels) fallback_label = ContentLabel( integrity=combined.integrity, confidentiality=result_confidentiality, @@ -1970,8 +1978,8 @@ def _process_result_with_embedded_labels( only restrict the invocation fallback. A framework-owned producer can identity-stamp a complete authoritative label after applying local policy. Items without embedded labels use ``fallback_label``, which is either the - tool's ``source_integrity`` declaration (tier 2) or the join of input - argument labels (tier 3). + tool's ``source_integrity`` declaration (tier 2) or the owned-reference/default + integrity baseline restricted by argument labels (tier 3). Each item's own label is attached to its ``additional_properties`` during processing, preserving per-item granularity. diff --git a/python/packages/core/tests/test_security.py b/python/packages/core/tests/test_security.py index 6813e172eae..7f371b61a1d 100644 --- a/python/packages/core/tests/test_security.py +++ b/python/packages/core/tests/test_security.py @@ -4961,7 +4961,7 @@ class TestTieredLabelPropagation: Tier 1 (Highest): Per-item embedded labels in tool result Tier 2: Tool's source_integrity declaration - Tier 3 (Lowest): Join of input argument labels + Tier 3 (Lowest): Owned input labels or configured default, restricted by argument labels """ @pytest.fixture @@ -4969,6 +4969,132 @@ def middleware(self): """Create middleware instance.""" return LabelTrackingFunctionMiddleware() + @pytest.mark.parametrize("label_key", ["security_label", "label"]) + @pytest.mark.parametrize("argument_form", ["dict", "model", "kwargs"]) + @pytest.mark.parametrize("auto_hide", [True, False]) + @pytest.mark.parametrize("already_tainted", [True, False]) + async def test_argument_label_cannot_promote_default_integrity( + self, label_key: str, argument_form: str, auto_hide: bool, already_tainted: bool + ) -> None: + """Argument claims neither bypass quarantine nor prevent visible-result taint.""" + tracker = LabelTrackingFunctionMiddleware(auto_hide_untrusted=auto_hide) + if already_tainted: + tracker._update_context_label(ContentLabel(integrity=IntegrityLabel.UNTRUSTED)) + + class Args(BaseModel): + data: dict[str, Any] + + async def read_data(data: dict[str, Any]) -> str: + return "external text" + + function = FunctionTool(fn=read_data, name="read_data", description="Read data", args_schema=Args) + data = {"items": [{label_key: {"integrity": "trusted", "confidentiality": "public"}}]} + context = FunctionInvocationContext( + function=function, + arguments=Args(data=data) if argument_form == "model" else {"data": data}, + ) + if argument_form == "kwargs": + context.arguments = {} + context.kwargs = {"data": data} + + async def next_fn() -> None: + context.result = [Content.from_text("external text")] + + await tracker.process(context, next_fn) + + assert context.metadata["result_label"].integrity == IntegrityLabel.UNTRUSTED + assert isinstance(context.result, list) + item = context.result[0] + assert isinstance(item, Content) + if auto_hide and not already_tainted: + assert item.additional_properties.get("_variable_reference") is True + assert item.text is not None + reference = json.loads(item.text) + assert reference["type"] == "variable_reference" + _, stored_label = tracker.get_variable_store().retrieve(reference["variable_id"]) + assert stored_label.integrity == IntegrityLabel.UNTRUSTED + assert tracker.get_context_label().integrity == IntegrityLabel.TRUSTED + else: + assert item.text == "external text" + assert tracker.get_context_label().integrity == IntegrityLabel.UNTRUSTED + policy = PolicyEnforcementFunctionMiddleware(block_on_violation=True) + sink = FunctionInvocationContext(function=function, arguments={"data": {}}) + with pytest.raises(MiddlewareTermination): + await FunctionMiddlewarePipeline(tracker, policy).execute( + sink, lambda _context: pytest.fail("Protected tool must not execute in untrusted context") + ) + assert policy.get_audit_log()[-1]["type"] == "untrusted_context" + + @pytest.mark.parametrize("default_integrity", list(IntegrityLabel)) + @pytest.mark.parametrize("stored_integrity", [None, *IntegrityLabel]) + @pytest.mark.parametrize("source_integrity", [None, *IntegrityLabel]) + @pytest.mark.parametrize("claimed_integrity", [None, *IntegrityLabel]) + async def test_argument_integrity_only_restricts_authoritative_fallback( + self, + default_integrity: IntegrityLabel, + stored_integrity: IntegrityLabel | None, + source_integrity: IntegrityLabel | None, + claimed_integrity: IntegrityLabel | None, + ) -> None: + """Preserve source precedence, owned-reference inheritance, and confidentiality.""" + tracker = LabelTrackingFunctionMiddleware(default_integrity=default_integrity) + + class Args(BaseModel): + data: dict[str, Any] + + async def transform(data: dict[str, Any]) -> str: + return "transformed" + + function = FunctionTool( + fn=transform, + name="transform", + description="Transform data", + args_schema=Args, + additional_properties={"source_integrity": source_integrity.value} if source_integrity is not None else {}, + ) + data: dict[str, Any] = {"value": "input"} + if claimed_integrity is not None: + data["security_label"] = { + "integrity": claimed_integrity.value, + "confidentiality": "private", + "metadata": _principal_metadata("unowned-user"), + } + if stored_integrity is not None: + variable_id = tracker.get_variable_store().store( + "input", + ContentLabel( + integrity=stored_integrity, + confidentiality=ConfidentialityLabel.USER_IDENTITY, + metadata=_principal_metadata("user-a"), + ), + ) + data["value"] = f"[{variable_id}]" + context = FunctionInvocationContext(function=function, arguments=Args(data=data)) + + async def next_fn() -> None: + assert isinstance(context.arguments, dict) + assert context.arguments["data"]["value"] == "input" + context.result = [Content.from_text("transformed")] + + await tracker.process(context, next_fn) + + expected_integrity = stored_integrity if stored_integrity is not None else default_integrity + if claimed_integrity == IntegrityLabel.UNTRUSTED: + expected_integrity = IntegrityLabel.UNTRUSTED + if source_integrity is not None: + expected_integrity = source_integrity + label = context.metadata["result_label"] + assert label.integrity == expected_integrity + if stored_integrity is not None: + assert label.confidentiality == ConfidentialityLabel.USER_IDENTITY + assert label.metadata[_PRINCIPALS_KEY] == _principal_metadata("user-a")[_PRINCIPALS_KEY] + else: + assert label.confidentiality == ( + ConfidentialityLabel.PRIVATE if claimed_integrity is not None else ConfidentialityLabel.PUBLIC + ) + assert _PRINCIPALS_KEY not in label.metadata + assert tracker.get_context_label().confidentiality == label.confidentiality + @pytest.mark.asyncio async def test_source_integrity_overrides_input_labels(self, middleware): """Test that source_integrity (tier 2) overrides input labels (tier 3). @@ -5131,10 +5257,10 @@ async def next_fn(): @pytest.mark.asyncio async def test_no_source_integrity_falls_back_to_input_labels(self, middleware): - """Test that without source_integrity, input labels (tier 3) determine the result. + """Test that without source_integrity, untrusted input labels restrict the result. When a tool has no source_integrity declaration and the result has no - embedded labels, the join of input argument labels is used. + embedded labels, input argument labels restrict the tier-3 baseline. """ class Args(BaseModel): @@ -5162,7 +5288,7 @@ async def next_fn(): await middleware.process(context, next_fn) - # No source_integrity (tier 2 absent), so tier 3: join of input labels + # No source_integrity (tier 2 absent), so tier 3: restricted input baseline # Input has untrusted label → result is untrusted # Result should be hidden since it's untrusted assert isinstance(context.result, list) diff --git a/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md b/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md index 5313d2f6b9c..fad09b01e88 100644 --- a/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md +++ b/python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md @@ -161,14 +161,21 @@ subset of the destination set. |----------|--------|-----------| | **Tier 1** | Per-item embedded labels (`additional_properties.security_label`) | Restrict the locally established fallback | | **Tier 2** | Tool's `source_integrity` declaration | No embedded labels, but tool declares `source_integrity` | -| **Tier 3** (Lowest) | Join of input argument labels (`combine_labels`) | No embedded labels AND no `source_integrity` declared | -| **Default** | `UNTRUSTED` | No labels from any tier | +| **Tier 3** (Lowest) | Owned-reference integrity or configured default, restricted by argument labels | No `source_integrity` declared | +| **Default** | `default_integrity` (`UNTRUSTED` by default) | No source declaration or resolved, owned variable references | **Tiered Label Propagation:** - **Tier 1: Embedded labels** are restriction-only by default: they can downgrade integrity or raise confidentiality, but cannot upgrade a fallback or supply principal authority - **Tier 2: `source_integrity`** is the locally trusted fallback for the tool's output; use `"trusted"` only after the local connector enforces its trust policy -- **Tier 3: Input labels join** — `combine_labels(*input_labels)` from arguments (VariableReferenceContent, labeled data) -- **Default**: `UNTRUSTED` when no labels exist from any tier +- **Tier 3: Owned input baseline** — inherit integrity from labels retrieved while resolving variable references owned by the current security scope. If there are no such references, use `default_integrity`. Labels supplied in arguments may make that baseline less trusted, but cannot make it more trusted. +- **Default**: `UNTRUSTED` unless the application configures another `default_integrity` + +A `security_label` or legacy `label` dictionary in tool arguments is application data, +not an authoritative trust declaration, even inside a validated model or nested dictionary. +Its integrity claim cannot promote the result above the owned-reference/default baseline. +Argument confidentiality restrictions still propagate, while principal authority comes from +owned labels or local configuration. An explicit `source_integrity` declaration retains +precedence over argument integrity claims. Framework-owned parsers and wrappers may stamp a complete label after enforcing local policy. Application and remote metadata remains restriction-only. @@ -194,9 +201,9 @@ If items don't have embedded labels, the tool can declare a fallback via `source When declared, `source_integrity` establishes the local integrity fallback. Embedded labels can make the result less trusted, but cannot make it more trusted: - `source_integrity="trusted"`: Tool produces trusted data (internal computations) - `source_integrity="untrusted"`: Tool fetches untrusted data -- (not set): Falls back to tier 3 (join of input labels) or **UNTRUSTED** default +- (not set): Falls back to tier 3 (owned-reference integrity or the configured default, restricted by argument labels) -**Note:** For action tools (sinks like `send_email`), `source_integrity` doesn't apply since they don't produce data. Their result inherits labels from inputs (tier 3). +**Note:** Action tools (sinks like `send_email`) can omit `source_integrity`. Their result follows tier 3: owned-reference inheritance or the configured default, with argument restrictions. **Context Label Tracking:** - Context label starts as **TRUSTED + PUBLIC** on first call @@ -337,7 +344,7 @@ async def fetch_emails(count: int = 5) -> list[Content]: If an item doesn't have an embedded label, the fallback is determined by: 1. **Tool-level `source_integrity`** in `additional_properties` (if declared) -2. **UNTRUSTED** (default - secure by default) +2. **Owned-reference integrity** or the configured **`default_integrity`** (`UNTRUSTED` by default), restricted by argument labels ```python # Tool with fallback for items without embedded labels @@ -1089,7 +1096,7 @@ sink. Argument labels do not rewrite result labels. 1. **Use SecureAgentConfig as a context provider**: Add `context_providers=[config]` for automatic security setup — no manual middleware, tools, or instruction wiring 2. **Use `list[Content]` with `Content.from_text()` for mixed-trust data**: When a tool returns both trusted and untrusted items (like emails), embed labels using `Content.from_text(text, additional_properties={"security_label": {...}})` -3. **Don't use source_integrity for action tools**: Tools like `send_email` or `delete_file` are sinks, not data sources - their results inherit labels from inputs +3. **Action tools can omit source_integrity**: Tools like `send_email` or `delete_file` are sinks; their results use the owned-reference/default baseline, restricted by argument labels 4. **Always use middleware stack**: Enable both label tracking and policy enforcement 5. **Enable automatic hiding**: Keep `auto_hide_untrusted=True` (default) for automatic protection 6. **Do not manually wire security tools/instructions when using context providers**: `SecureAgentConfig` injects them for you From 9f62ddbeee0416d80feee56e30c385a3125f7ec3 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 16 Sep 2026 02:48:17 +0000 Subject: [PATCH 099/108] Python: add public API compatibility checks (#8271) * Python: add public API compatibility workflow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * Python: address API compatibility review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * Python: align API checks with merge refs and stages Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * CI: validate API compatibility workflow edits Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * CI: separate breaking intent from approval Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 --------- Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 --- .../scripts/check_python_api_compatibility.py | 250 ++++++++++++++++++ .../tests/test_python_api_compatibility.py | 243 +++++++++++++++++ .github/workflows/github-automation-tests.yml | 36 +++ .../workflows/python-api-compatibility.yml | 78 ++++++ CONTRIBUTING.md | 26 ++ python/pyproject.toml | 1 + python/uv.lock | 34 ++- 7 files changed, 665 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/check_python_api_compatibility.py create mode 100644 .github/tests/test_python_api_compatibility.py create mode 100644 .github/workflows/python-api-compatibility.yml diff --git a/.github/scripts/check_python_api_compatibility.py b/.github/scripts/check_python_api_compatibility.py new file mode 100644 index 00000000000..bfc7967b9cf --- /dev/null +++ b/.github/scripts/check_python_api_compatibility.py @@ -0,0 +1,250 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Check Python API compatibility against a pull request's base commit.""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import tempfile +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from importlib.metadata import version +from pathlib import Path + +from griffe import ( + Alias, + AliasResolutionError, + ExplanationStyle, + Object, + find_breaking_changes, + load, +) + +_UNRELEASED_DECORATORS = frozenset( + { + "agent_framework._feature_stage.experimental", + "agent_framework._feature_stage.release_candidate", + } +) +_GRIFFE_VERSION = version("griffe") +_PACKAGE_STATUS_ROW = re.compile( + r"^\| `(?P[^`]+)` \| `(?Ppython/packages/[^`]+)` \| `(?P[^`]+)` \|$" +) + + +@dataclass(frozen=True, order=True) +class ModuleSpec: + """A top-level import package and its owning distribution.""" + + module: str + search_path: Path + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout + + +@contextmanager +def _base_source(repo: Path, base_sha: str) -> Iterator[Path]: + archive = subprocess.run( + ["git", "archive", base_sha, "python"], + cwd=repo, + check=True, + capture_output=True, + ).stdout + with tempfile.TemporaryDirectory(prefix="python-api-base-") as directory: + subprocess.run( + ["tar", "-xf", "-", "-C", directory], + input=archive, + check=True, + ) + yield Path(directory) + + +def _package_states(repo: Path, base_sha: str) -> dict[Path, str]: + status_document = _git(repo, "show", f"{base_sha}:python/PACKAGE_STATUS.md") + states: dict[Path, str] = {} + + for line in status_document.splitlines(): + match = _PACKAGE_STATUS_ROW.match(line) + if match: + states[Path(match["path"])] = match["state"] + + if not states: + raise RuntimeError( + "No package lifecycle states were found in python/PACKAGE_STATUS.md" + ) + + return states + + +def _module_specs( + repo: Path, base_sha: str, package_states: dict[Path, str] +) -> list[ModuleSpec]: + tracked_files = _git( + repo, "ls-tree", "-r", "--name-only", base_sha, "--", "python/packages" + ) + specs: set[ModuleSpec] = set() + + for file_name in tracked_files.splitlines(): + parts = Path(file_name).parts + if ( + len(parts) != 5 + or parts[:2] != ("python", "packages") + or parts[4] != "__init__.py" + or parts[3] in {"build", "tests"} + ): + continue + + package_path = Path(*parts[:3]) + if package_path.name == "lab": + continue + if package_path not in package_states: + raise RuntimeError( + f"{package_path} is missing from python/PACKAGE_STATUS.md" + ) + if package_states[package_path] != "released": + continue + + specs.add(ModuleSpec(module=parts[3], search_path=package_path)) + + return sorted(specs) + + +def _resolved(obj: Object | Alias) -> Object | None: + try: + return obj.final_target if isinstance(obj, Alias) else obj + except AliasResolutionError: + return None + + +def _unreleased_owner(obj: Object | Alias) -> str | None: + current = _resolved(obj) + while current is not None: + decorators = getattr(current, "decorators", ()) + if any( + decorator.callable_path in _UNRELEASED_DECORATORS + for decorator in decorators + ): + return str(current.path) + current = current.parent + return None + + +def _write_summary( + *, + checked: int, + breakages: int, + acknowledged: bool, +) -> None: + summary_path = os.getenv("GITHUB_STEP_SUMMARY") + if not summary_path: + return + + lines = [ + "## Python public API compatibility", + "", + f"Compared {checked} released import packages with the PR base using Griffe {_GRIFFE_VERSION}.", + "", + f"- Published API breakages: {breakages}", + ] + if breakages and acknowledged: + lines.extend( + [ + "", + "Published API breakages were acknowledged by the `breaking change` label.", + ] + ) + elif breakages: + lines.extend( + [ + "", + "Restore compatibility or acknowledge the intentional break with the `breaking change` label.", + ] + ) + + with Path(summary_path).open("a") as summary: + summary.write("\n".join(lines) + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--base-sha", required=True, help="Trusted pull request base commit" + ) + parser.add_argument( + "--repo", type=Path, default=Path.cwd(), help="Pull request checkout" + ) + parser.add_argument("--acknowledge-breaking-changes", action="store_true") + args = parser.parse_args() + + repo = args.repo.resolve() + os.chdir(repo) + package_states = _package_states(repo, args.base_sha) + specs = _module_specs(repo, args.base_sha, package_states) + breakage_count = 0 + + with _base_source(repo, args.base_sha) as base_source: + for spec in specs: + print(f"::group::{spec.module}") + old_package = load( + spec.module, + search_paths=[base_source / spec.search_path], + allow_inspection=False, + resolve_aliases=True, + ) + try: + new_package = load( + spec.module, + search_paths=[spec.search_path], + allow_inspection=False, + resolve_aliases=True, + ) + except ModuleNotFoundError: + breakage_count += 1 + print( + f"::warning title={spec.module}::" + "Released public import package was removed" + ) + print("::endgroup::") + continue + + for breakage in find_breaking_changes(old_package, new_package): + try: + old_breakage_obj = old_package.modules_collection.get_member( + breakage.obj.path + ) + except KeyError: + old_breakage_obj = breakage.obj + unreleased_owner = _unreleased_owner(old_breakage_obj) + + if unreleased_owner is None: + breakage_count += 1 + print(breakage.explain(style=ExplanationStyle.GITHUB)) + + print("::endgroup::") + + _write_summary( + checked=len(specs), + breakages=breakage_count, + acknowledged=args.acknowledge_breaking_changes, + ) + + if breakage_count and not args.acknowledge_breaking_changes: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/tests/test_python_api_compatibility.py b/.github/tests/test_python_api_compatibility.py new file mode 100644 index 00000000000..48df83867c6 --- /dev/null +++ b/.github/tests/test_python_api_compatibility.py @@ -0,0 +1,243 @@ +# Copyright (c) Microsoft. All rights reserved. + +# ruff:file-ignore[implicit-namespace-package, undocumented-public-class, undocumented-public-method] + +from __future__ import annotations + +import importlib.util +import os +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +SCRIPT_PATH = ( + Path(__file__).parents[1] / "scripts" / "check_python_api_compatibility.py" +) +SPEC = importlib.util.spec_from_file_location( + "check_python_api_compatibility", SCRIPT_PATH +) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load {SCRIPT_PATH}") +api_checker = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = api_checker +SPEC.loader.exec_module(api_checker) + + +class ApiCompatibilityTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.repo = Path(self.temp_dir.name) + self.run_git("init", "-q") + self.run_git("config", "user.email", "test@example.com") + self.run_git("config", "user.name", "Test") + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def run_git(self, *args: str) -> str: + return subprocess.run( + ["git", *args], + cwd=self.repo, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + def write_status(self, packages: list[tuple[str, str, str]]) -> None: + rows = [ + "# Python Package Status", + "", + "| Package | Path | State |", + "| --- | --- | --- |", + ] + rows.extend( + f"| `{name}` | `python/packages/{path}` | `{state}` |" + for name, path, state in packages + ) + status_path = self.repo / "python" / "PACKAGE_STATUS.md" + status_path.parent.mkdir(parents=True, exist_ok=True) + status_path.write_text("\n".join(rows) + "\n") + + def write_module(self, package: str, module: str, files: dict[str, str]) -> Path: + module_path = self.repo / "python" / "packages" / package / module + module_path.mkdir(parents=True, exist_ok=True) + for relative_path, content in files.items(): + (module_path / relative_path).write_text(content) + return module_path + + def commit(self) -> str: + self.run_git("add", ".") + self.run_git("commit", "-qm", "baseline") + return self.run_git("rev-parse", "HEAD") + + def run_checker( + self, base_sha: str, *, acknowledged: bool = False + ) -> subprocess.CompletedProcess[str]: + summary_path = self.repo / "summary.md" + command = [ + sys.executable, + str(SCRIPT_PATH), + "--base-sha", + base_sha, + "--repo", + str(self.repo), + ] + if acknowledged: + command.append("--acknowledge-breaking-changes") + env = {**os.environ, "GITHUB_STEP_SUMMARY": str(summary_path)} + return subprocess.run( + command, capture_output=True, text=True, env=env, check=False + ) + + def test_discovers_only_released_non_lab_modules(self) -> None: + self.write_status( + [ + ("agent-framework-stable", "stable", "released"), + ("agent-framework-beta", "beta", "beta"), + ("agent-framework-lab", "lab", "released"), + ] + ) + self.write_module("stable", "stable_api", {"__init__.py": ""}) + self.write_module("beta", "beta_api", {"__init__.py": ""}) + self.write_module("lab", "lab_api", {"__init__.py": ""}) + base_sha = self.commit() + + states = api_checker._package_states(self.repo, base_sha) + specs = api_checker._module_specs(self.repo, base_sha, states) + + self.assertEqual([spec.module for spec in specs], ["stable_api"]) + + def test_filters_base_unreleased_apis_but_not_head_only_decorators(self) -> None: + self.write_status([("agent-framework-core", "core", "released")]) + self.write_module( + "core", + "agent_framework", + { + "__init__.py": ( + "from ._api import (\n" + " ExperimentalBase,\n" + " ExperimentalChild,\n" + " ReleaseCandidateBase,\n" + " ReleaseCandidateChild,\n" + " experimental_function,\n" + " release_candidate_function,\n" + " stable_experimental,\n" + " stable_release_candidate,\n" + ")\n" + "__all__ = [\n" + ' "ExperimentalBase",\n' + ' "ExperimentalChild",\n' + ' "ReleaseCandidateBase",\n' + ' "ReleaseCandidateChild",\n' + ' "experimental_function",\n' + ' "release_candidate_function",\n' + ' "stable_experimental",\n' + ' "stable_release_candidate",\n' + "]\n" + ), + "_feature_stage.py": ( + "def experimental(*, feature_id):\n" + " return lambda obj: obj\n\n" + "def release_candidate(*, feature_id):\n" + " return lambda obj: obj\n" + ), + "_api.py": ( + "from ._feature_stage import experimental, release_candidate\n\n" + '@experimental(feature_id="TEST")\n' + "class ExperimentalBase:\n" + " def inherited(self, value): pass\n\n" + "class ExperimentalChild(ExperimentalBase): pass\n\n" + '@experimental(feature_id="TEST")\n' + "def experimental_function(value): pass\n\n" + '@release_candidate(feature_id="TEST")\n' + "class ReleaseCandidateBase:\n" + " def inherited(self, value): pass\n\n" + "class ReleaseCandidateChild(ReleaseCandidateBase): pass\n\n" + '@release_candidate(feature_id="TEST")\n' + "def release_candidate_function(value): pass\n\n" + "def stable_experimental(value): pass\n\n" + "def stable_release_candidate(value): pass\n" + ), + }, + ) + base_sha = self.commit() + api_path = self.repo / "python/packages/core/agent_framework/_api.py" + api_path.write_text( + "from ._feature_stage import experimental, release_candidate\n\n" + '@experimental(feature_id="TEST")\n' + "class ExperimentalBase:\n" + " def inherited(self): pass\n\n" + "class ExperimentalChild(ExperimentalBase): pass\n\n" + '@experimental(feature_id="TEST")\n' + "def experimental_function(): pass\n\n" + '@release_candidate(feature_id="TEST")\n' + "class ReleaseCandidateBase:\n" + " def inherited(self): pass\n\n" + "class ReleaseCandidateChild(ReleaseCandidateBase): pass\n\n" + '@release_candidate(feature_id="TEST")\n' + "def release_candidate_function(): pass\n\n" + '@experimental(feature_id="TEST")\n' + "def stable_experimental(): pass\n\n" + '@release_candidate(feature_id="TEST")\n' + "def stable_release_candidate(): pass\n" + ) + + result = self.run_checker(base_sha) + + self.assertEqual(result.returncode, 1) + warnings = [ + line for line in result.stdout.splitlines() if line.startswith("::warning ") + ] + self.assertEqual(len(warnings), 2) + self.assertTrue( + any("stable_experimental(value)" in warning for warning in warnings) + ) + self.assertTrue( + any("stable_release_candidate(value)" in warning for warning in warnings) + ) + + def test_removed_top_level_module_is_reported_and_acknowledgeable(self) -> None: + self.write_status([("agent-framework-stable", "stable", "released")]) + module_path = self.write_module( + "stable", + "stable_api", + {"__init__.py": "def public_function(value): pass\n"}, + ) + base_sha = self.commit() + shutil.rmtree(module_path) + + result = self.run_checker(base_sha) + acknowledged_result = self.run_checker(base_sha, acknowledged=True) + + self.assertEqual(result.returncode, 1) + self.assertIn( + "::warning title=stable_api::Released public import package was removed", + result.stdout, + ) + self.assertIn( + "- Published API breakages: 1", (self.repo / "summary.md").read_text() + ) + self.assertEqual(acknowledged_result.returncode, 0) + + def test_compatible_api_is_successful_and_writes_summary(self) -> None: + self.write_status([("agent-framework-stable", "stable", "released")]) + self.write_module( + "stable", + "stable_api", + {"__init__.py": "def public_function(value): pass\n"}, + ) + base_sha = self.commit() + + result = self.run_checker(base_sha) + + self.assertEqual(result.returncode, 0) + self.assertIn( + "- Published API breakages: 0", (self.repo / "summary.md").read_text() + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/github-automation-tests.yml b/.github/workflows/github-automation-tests.yml index e03b1da575f..a05f7bb8fcf 100644 --- a/.github/workflows/github-automation-tests.yml +++ b/.github/workflows/github-automation-tests.yml @@ -6,8 +6,11 @@ on: - ".github/actions/**" - ".github/scripts/**" - ".github/tests/**" + - ".github/workflows/python-api-compatibility.yml" - ".github/workflows/python-test-coverage.yml" - ".github/workflows/github-automation-tests.yml" + - "python/pyproject.toml" + - "python/uv.lock" push: branches: - main @@ -15,8 +18,11 @@ on: - ".github/actions/**" - ".github/scripts/**" - ".github/tests/**" + - ".github/workflows/python-api-compatibility.yml" - ".github/workflows/python-test-coverage.yml" - ".github/workflows/github-automation-tests.yml" + - "python/pyproject.toml" + - "python/uv.lock" permissions: contents: read @@ -35,8 +41,38 @@ jobs: with: python-version: "3.11" + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version: "0.12.9" + - name: Run JavaScript tests run: node --test .github/tests/*.js - name: Run Python tests run: python .github/tests/test_python_check_coverage.py + + - name: Run Python API compatibility tests + working-directory: ${{ runner.temp }} + env: + UV_NO_CONFIG: "1" + run: | + griffe_requirement="$( + python3 - "$GITHUB_WORKSPACE/python/pyproject.toml" <<'PY' + import sys + import tomllib + + with open(sys.argv[1], "rb") as pyproject_file: + dev_dependencies = tomllib.load(pyproject_file)["dependency-groups"]["dev"] + requirements = [ + dependency for dependency in dev_dependencies if dependency.startswith("griffe") + ] + if len(requirements) != 1: + raise SystemExit("Expected exactly one Griffe development dependency") + print(requirements[0]) + PY + )" + + uv --no-config run \ + --no-project \ + --with "$griffe_requirement" \ + python "$GITHUB_WORKSPACE/.github/tests/test_python_api_compatibility.py" diff --git a/.github/workflows/python-api-compatibility.yml b/.github/workflows/python-api-compatibility.yml new file mode 100644 index 00000000000..735945b99ad --- /dev/null +++ b/.github/workflows/python-api-compatibility.yml @@ -0,0 +1,78 @@ +name: Python - API Compatibility + +on: + pull_request_target: + branches: ["main"] + types: [opened, synchronize, reopened, labeled, unlabeled] + paths: + - "python/**" + - ".github/scripts/check_python_api_compatibility.py" + - ".github/workflows/python-api-compatibility.yml" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + api-compatibility: + name: Public API Compatibility + runs-on: ubuntu-latest + continue-on-error: true + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + fetch-depth: 0 + persist-credentials: false + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + enable-cache: false + version: "0.12.9" + + - name: Compare public API with the PR base + working-directory: ${{ runner.temp }} + env: + BREAKING_CHANGE_ACKNOWLEDGED: ${{ contains(github.event.pull_request.labels.*.name, 'breaking change') }} + UV_NO_CONFIG: "1" + shell: bash + run: | + BASE_SHA="$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD^1)" + git -C "$GITHUB_WORKSPACE" show "$BASE_SHA:.github/scripts/check_python_api_compatibility.py" \ + > "$RUNNER_TEMP/check_python_api_compatibility.py" + git -C "$GITHUB_WORKSPACE" show "$BASE_SHA:python/pyproject.toml" \ + > "$RUNNER_TEMP/python-pyproject.toml" + + griffe_requirement="$( + python3 - "$RUNNER_TEMP/python-pyproject.toml" <<'PY' + import sys + import tomllib + + with open(sys.argv[1], "rb") as pyproject_file: + dev_dependencies = tomllib.load(pyproject_file)["dependency-groups"]["dev"] + requirements = [ + dependency for dependency in dev_dependencies if dependency.startswith("griffe") + ] + if len(requirements) != 1: + raise SystemExit("Expected exactly one Griffe development dependency") + print(requirements[0]) + PY + )" + + args=( + --base-sha "$BASE_SHA" + --repo "$GITHUB_WORKSPACE" + ) + if [[ "$BREAKING_CHANGE_ACKNOWLEDGED" == "true" ]]; then + args+=(--acknowledge-breaking-changes) + fi + + uv --no-config run \ + --no-project \ + --with "$griffe_requirement" \ + python "$RUNNER_TEMP/check_python_api_compatibility.py" "${args[@]}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a041b727d58..ff920a62bcd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,32 @@ Contributions must maintain API signature and behavioral compatibility. Contribu that include breaking changes will be rejected. Please file an issue to discuss your idea or change if you believe that a breaking change is warranted. +#### Python Public API Compatibility + +Python pull requests run a non-blocking [Griffe](https://mkdocstrings.github.io/griffe/) +check that compares the pull request's public API with its base commit. The workflow only +runs when Python files change, and reports potential breaking changes as annotations and +in the job summary. The experimental `agent-framework-lab` package is excluded. The +workflow runs from the trusted base branch, checks out GitHub's synthetic merge commit +with read-only permissions, and statically parses source without importing it. This keeps +the comparison current when a pull request branch is behind `main`. + +Only APIs from packages marked `released` in `python/PACKAGE_STATUS.md` are checked. +Prerelease packages and APIs marked with `@experimental` or `@release_candidate`—including +members of a staged class—are excluded. Package state and feature-stage markers are read +from the base commit, so changing either in the same pull request cannot suppress a +compatibility finding. The Griffe version is pinned with the other Python development +dependencies in `python/pyproject.toml`; the workflow reads that pin from the trusted base +commit. + +If a breaking change is intentional, add the `breaking change` label to the pull request +or add `[BREAKING]` to its title. Existing title/label automation keeps those signals +synchronized. The label declares that the detected break is intentional; normal repository +review and merge policies determine whether the change is approved. The compatibility +workflow still reports acknowledged changes but succeeds. Without the label, the comparison +step fails; the job is configured as non-blocking so it cannot prevent a merge while the +workflow is being evaluated. + #### Automated API Compatibility Validation The .NET projects use [Package Validation](https://learn.microsoft.com/dotnet/fundamentals/package-validation/overview) diff --git a/python/pyproject.toml b/python/pyproject.toml index 72d7318281d..15f63b0db14 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -49,6 +49,7 @@ dev = [ "rich>=13.7.1,<16.0.0", "tomli==2.4.1", "prek==0.5.2", + "griffe==2.2.0", ] test = [ "azure-monitor-opentelemetry", diff --git a/python/uv.lock b/python/uv.lock index 53e9c4254bd..5b144f1dfe0 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -126,6 +126,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "flit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "griffe", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pandas", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -158,6 +159,7 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ { name = "flit", specifier = "==4.0.2" }, + { name = "griffe", specifier = "==2.2.0" }, { name = "mypy", specifier = "==2.3.1" }, { name = "opentelemetry-sdk" }, { name = "pandas", specifier = ">=2,<4" }, @@ -2441,13 +2443,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, ] +[[package]] +name = "griffe" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffecli", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "griffelib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/a0/927cf9416f527f623f4ccd9220337fca0cd85303b7e82adeab1e53669cd3/griffe-2.2.0.tar.gz", hash = "sha256:9c0dd9a7feda9e169d783507d777c6fb2f2fc2919b139747a5b4f5357517a611", size = 246229, upload-time = "2026-08-16T14:04:57.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/51/c9f9cb144e2bd21cbcb6ebd7978ecb5b9f5a375e78313744158338c8039c/griffe-2.2.0-py3-none-any.whl", hash = "sha256:db20672b31c3de2a1af5a18a3b3d8c186ccc2425e10290486b4f0364d25929e9", size = 5070, upload-time = "2026-08-16T14:04:51.971Z" }, +] + +[[package]] +name = "griffecli" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "griffelib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/75/3cf2fb1ae21fb55a3b917b45d24426a620ee916f4bdbd03f350c69d3d892/griffecli-2.2.0.tar.gz", hash = "sha256:b9e763131218eb19887ed52de20fe944252d811bfb4a3e339874908a588f0044", size = 58066, upload-time = "2026-08-16T14:04:55.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/83/04652213d82afeecc8e8a31231eeb87cc484dcc055fd96ecb0bfd9860405/griffecli-2.2.0-py3-none-any.whl", hash = "sha256:8ebb7ae1dcc2617f5d39f00e4c238dd6b5cd0b13157b0ce22c1ea4ca8b4c5d1d", size = 11480, upload-time = "2026-08-16T14:04:53.14Z" }, +] + [[package]] name = "griffelib" -version = "2.1.0" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/b4/a767e91c606deefc447a96eaf59edd77397960b1d677dffd833ee8449831/griffelib-2.2.0.tar.gz", hash = "sha256:e1bc36fe9cd21d4b6b659b456346755e4cfdc5676c0a5214083126ee12612b3c", size = 227048, upload-time = "2026-08-16T14:04:58.383Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/f65ac785d4ac90dcf7c831ac6256f5dd4a19780f4e1575b2c0d6eeebe319/griffelib-2.2.0-py3-none-any.whl", hash = "sha256:d71c3bc2bbed9f958488634fe788b843a9f705d6d2838ca32cd6c25eeb64dfc4", size = 166779, upload-time = "2026-08-16T14:04:54.365Z" }, ] [[package]] From 66d74f38724c8a26429764925acae3e7aee1ca8d Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 16 Sep 2026 02:49:02 +0000 Subject: [PATCH 100/108] Python: Deprecate MCP sampling callback (#8401) * Python: Deprecate MCP sampling callback * Python: Warn when configuring MCP sampling * Python: Keep sampling tests type-safe --- python/packages/core/agent_framework/_mcp.py | 37 +++++- python/packages/core/tests/core/test_mcp.py | 125 +++++++++++++++---- 2 files changed, 137 insertions(+), 25 deletions(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 2073141ad94..82071d6991d 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -21,6 +21,11 @@ from inspect import isawaitable from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict, cast +if sys.version_info >= (3, 13): + from warnings import deprecated # pragma: no cover +else: + from typing_extensions import deprecated # pragma: no cover + from opentelemetry import propagate from opentelemetry import trace as otel_trace from pydantic_core import to_jsonable_python @@ -481,6 +486,10 @@ async def delete(self, *args: Any, **kwargs: Any) -> Any: # session connection (the counter resets on reconnect). _DEFAULT_SAMPLING_MAX_TOKENS = 4096 _DEFAULT_SAMPLING_MAX_REQUESTS = 25 +_MCP_SAMPLING_DEPRECATION_MESSAGE = ( + "MCP sampling is deprecated as of MCP specification version 2026-07-28 and will be removed no later than " + "2027-07-28. MCP servers should call LLM provider APIs directly." +) # A user-supplied gate invoked before each server-initiated sampling request is # forwarded to the chat client. It receives the raw ``CreateMessageRequestParams`` @@ -1023,6 +1032,14 @@ def __init__( self.sampling_approval_callback = sampling_approval_callback self.sampling_max_tokens = sampling_max_tokens self.sampling_max_requests = sampling_max_requests + self._sampling_deprecation_warned = False + if ( + client is not None + or sampling_approval_callback is not None + or sampling_max_tokens != _DEFAULT_SAMPLING_MAX_TOKENS + or sampling_max_requests != _DEFAULT_SAMPLING_MAX_REQUESTS + ): + self._warn_sampling_deprecated(stacklevel=4) self._sampling_request_count = 0 self._functions: list[FunctionTool] = [] self.use_progressive_disclosure = use_progressive_disclosure @@ -1976,7 +1993,7 @@ async def _connect_on_owner( ), message_handler=self.message_handler, logging_callback=self.logging_callback, - sampling_callback=self.sampling_callback, + sampling_callback=self.sampling_callback, # pyright: ignore[reportDeprecated] sampling_capabilities=sampling_capabilities, ) ) @@ -2116,6 +2133,18 @@ def _capped_sampling_max_tokens(self, requested: int) -> int: return cap return requested + def _warn_sampling_deprecated(self, *, stacklevel: int) -> None: + """Emit the MCP sampling deprecation warning once for this tool.""" + if self._sampling_deprecation_warned: + return + warnings.warn( + _MCP_SAMPLING_DEPRECATION_MESSAGE, + DeprecationWarning, + stacklevel=stacklevel, + ) + self._sampling_deprecation_warned = True + + @deprecated(_MCP_SAMPLING_DEPRECATION_MESSAGE, category=None) async def sampling_callback( self, context: RequestContext[ClientSession, Any], @@ -2123,6 +2152,10 @@ async def sampling_callback( ) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: """Callback function for sampling. + .. deprecated:: 2026-07-28 + MCP sampling will be removed no later than 2027-07-28. MCP servers should + call LLM provider APIs directly. + This function is called when the MCP server sends a ``sampling/createMessage`` request. It enforces safety guardrails and, if the request is approved, uses the configured chat client to generate a response. @@ -2152,6 +2185,8 @@ async def sampling_callback( """ from mcp import types + self._warn_sampling_deprecated(stacklevel=3) + if not self.client: return types.ErrorData( code=types.INTERNAL_ERROR, diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index c8f547086f4..90eb00620b6 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -3790,6 +3790,18 @@ def _approve(_params: object) -> bool: return True +async def _invoke_sampling_callback( + tool: MCPTool, + params: Any, +) -> types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData: + """Invoke the intentionally deprecated sampling callback in compatibility tests.""" + callback = getattr(tool, "sampling_callback") # noqa: B009 + return cast( + "types.CreateMessageResult | types.CreateMessageResultWithTools | types.ErrorData", + await callback(Mock(), params), + ) + + def _make_sampling_response(text: str = "response", model: str = "test-model") -> Mock: mock_response = Mock() mock_response.messages = [Message(role="assistant", contents=[Content.from_text(text)])] @@ -3805,13 +3817,78 @@ async def test_mcp_tool_sampling_callback_no_client(): params = Mock() params.messages = [] - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.ErrorData) assert result.code == types.INTERNAL_ERROR assert "No chat client available" in result.message +async def test_mcp_tool_sampling_defaults_stay_silent_until_callback_is_used(): + """Default setup stays silent until a server sends a sampling request.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + tool = MCPStdioTool(name="test_tool", command="python") + + callback = getattr(MCPTool, "sampling_callback") # noqa: B009 + assert "2027-07-28" in getattr(callback, "__deprecated__", "") + + params = Mock() + params.messages = [] + + with pytest.warns(DeprecationWarning, match="MCP sampling.*2027-07-28"): + result = await _invoke_sampling_callback(tool, params) + + assert isinstance(result, types.ErrorData) + + +async def test_mcp_tool_sampling_configuration_warns_once(): + """Each sampling option warns at setup, without warning again on callback use.""" + with pytest.warns(DeprecationWarning, match="MCP sampling.*2027-07-28") as warning_info: + tool = MCPStdioTool( + name="test_tool", + command="python", + client=AsyncMock(), + sampling_approval_callback=_approve, + sampling_max_tokens=None, + sampling_max_requests=None, + ) + + assert len(warning_info) == 1 + + params = Mock() + params.messages = [] + params.maxTokens = 128 + params.systemPrompt = None + params.tools = None + params.temperature = None + params.stopSequences = None + params.toolChoice = None + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + await _invoke_sampling_callback(tool, params) + + assert not caught + + +@pytest.mark.parametrize( + "sampling_option", + [ + {"client": AsyncMock()}, + {"sampling_approval_callback": _approve}, + {"sampling_max_tokens": None}, + {"sampling_max_requests": None}, + ], +) +def test_mcp_tool_each_sampling_option_warns(sampling_option: dict[str, Any]): + """Each non-default sampling option enables the setup warning.""" + with pytest.warns(DeprecationWarning, match="MCP sampling.*2027-07-28") as warning_info: + MCPStdioTool(name="test_tool", command="python", **sampling_option) + + assert len(warning_info) == 1 + + async def test_mcp_tool_sampling_callback_denies_by_default(): """Sampling is denied when no approval callback is configured (safe default).""" tool = MCPStdioTool(name="test_tool", command="python") @@ -3822,7 +3899,7 @@ async def test_mcp_tool_sampling_callback_denies_by_default(): params.messages = [] params.maxTokens = 128 - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.ErrorData) assert result.code == types.INVALID_REQUEST @@ -3841,7 +3918,7 @@ async def test_mcp_tool_sampling_callback_denied_by_callback(): params.messages = [] params.maxTokens = 128 - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.ErrorData) assert result.code == types.INVALID_REQUEST @@ -3863,7 +3940,7 @@ def boom(_params: object) -> bool: params.messages = [] params.maxTokens = 128 - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.ErrorData) assert result.code == types.INVALID_REQUEST @@ -3890,7 +3967,7 @@ async def approve(_params: object) -> bool: params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) assert isinstance(result.content, types.TextContent) @@ -3919,7 +3996,7 @@ async def test_mcp_tool_sampling_callback_clamps_max_tokens(): params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) options = mock_chat_client.get_response.call_args.kwargs.get("options") or {} @@ -3947,7 +4024,7 @@ async def test_mcp_tool_sampling_callback_does_not_clamp_under_cap(): params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) options = mock_chat_client.get_response.call_args.kwargs.get("options") or {} @@ -3977,9 +4054,9 @@ def make_params() -> Mock: params.toolChoice = None return params - first = await tool.sampling_callback(Mock(), make_params()) - second = await tool.sampling_callback(Mock(), make_params()) - third = await tool.sampling_callback(Mock(), make_params()) + first = await _invoke_sampling_callback(tool, make_params()) + second = await _invoke_sampling_callback(tool, make_params()) + third = await _invoke_sampling_callback(tool, make_params()) assert isinstance(first, types.CreateMessageResult) assert isinstance(second, types.CreateMessageResult) @@ -3990,7 +4067,7 @@ def make_params() -> Mock: # The counter resets on a session reset. tool._reset_session_state() - fourth = await tool.sampling_callback(Mock(), make_params()) + fourth = await _invoke_sampling_callback(tool, make_params()) assert isinstance(fourth, types.CreateMessageResult) @@ -4018,7 +4095,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception(): params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.ErrorData) assert result.code == types.INTERNAL_ERROR @@ -4064,7 +4141,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content(): params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.ErrorData) assert result.code == types.INTERNAL_ERROR @@ -4089,7 +4166,7 @@ async def test_mcp_tool_sampling_callback_no_response_and_successful_message_cre params.toolChoice = None tool.client.get_response.return_value = None - no_response = await tool.sampling_callback(Mock(), params) + no_response = await _invoke_sampling_callback(tool, params) assert isinstance(no_response, types.ErrorData) assert no_response.message == "Failed to get chat message content." @@ -4099,7 +4176,7 @@ async def test_mcp_tool_sampling_callback_no_response_and_successful_message_cre model="test-model", ) - success = await tool.sampling_callback(Mock(), params) + success = await _invoke_sampling_callback(tool, params) assert isinstance(success, types.CreateMessageResult) assert success.role == "assistant" @@ -4152,7 +4229,7 @@ async def test_mcp_tool_sampling_callback_returns_tool_use_results(): ] params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResultWithTools) assert result.role == "assistant" @@ -4203,7 +4280,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt(): params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) call_kwargs = mock_chat_client.get_response.call_args @@ -4244,7 +4321,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools(): params.tools = [mcp_tool] params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) call_kwargs = mock_chat_client.get_response.call_args @@ -4284,7 +4361,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice(): params.tools = None params.toolChoice = types.ToolChoice(mode="required") - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) call_kwargs = mock_chat_client.get_response.call_args @@ -4319,7 +4396,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt(): params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) call_kwargs = mock_chat_client.get_response.call_args @@ -4354,7 +4431,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list(): params.tools = [] params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) call_kwargs = mock_chat_client.get_response.call_args @@ -4389,7 +4466,7 @@ async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options( params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) call_kwargs = mock_chat_client.get_response.call_args @@ -4430,7 +4507,7 @@ async def test_mcp_tool_sampling_callback_omits_temperature_when_none(): params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) call_kwargs = mock_chat_client.get_response.call_args @@ -4467,7 +4544,7 @@ async def test_mcp_tool_sampling_callback_always_passes_max_tokens(): params.tools = None params.toolChoice = None - result = await tool.sampling_callback(Mock(), params) + result = await _invoke_sampling_callback(tool, params) assert isinstance(result, types.CreateMessageResult) call_kwargs = mock_chat_client.get_response.call_args From 85fdd7fe95b8c56a31a8550dac2ae06b1ec50e9c Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:41:31 +0000 Subject: [PATCH 101/108] Python: Add stable names to orchestration workflows (#8384) * Add stable names to orchestration workflows * Add orchestration workflow name tests * Assert stable names in checkpoint tests --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../_concurrent.py | 5 +++++ .../_group_chat.py | 5 +++++ .../_handoff.py | 5 +++-- .../_magentic.py | 5 +++++ .../_sequential.py | 5 +++++ .../orchestrations/tests/test_concurrent.py | 10 +++++++++ .../orchestrations/tests/test_group_chat.py | 19 ++++++++++++++++ .../orchestrations/tests/test_handoff.py | 22 +++++++++++++++++++ .../orchestrations/tests/test_magentic.py | 19 ++++++++++++++++ .../orchestrations/tests/test_sequential.py | 8 +++++++ 10 files changed, 101 insertions(+), 2 deletions(-) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index 5d9e20bdaed..c798f36f736 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -30,6 +30,7 @@ from ._workflow_builder import OrchestrationWorkflowBuilder as WorkflowBuilder logger = logging.getLogger(__name__) +DEFAULT_WORKFLOW_NAME = "Concurrent" """Concurrent builder for agent-only fan-out/fan-in workflows. @@ -213,6 +214,7 @@ def summarize(results: list[AgentExecutorResponse]) -> str: def __init__( self, *, + name: str | None = None, participants: Sequence[SupportsAgentRun | Executor], checkpoint_storage: CheckpointStorage | None = None, output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET), @@ -221,6 +223,7 @@ def __init__( """Initialize the ConcurrentBuilder. Args: + name: Optional workflow identifier. Defaults to ``"Concurrent"``. participants: Sequence of agent or executor instances to run in parallel. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. output_from: Optional participant names or instances whose ``yield_output`` calls @@ -230,6 +233,7 @@ def __init__( surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant not selected by ``output_from``. Unlisted participant outputs are hidden. """ + self._name = name or DEFAULT_WORKFLOW_NAME self._participants: list[SupportsAgentRun | Executor] = [] self._aggregator: Executor | None = None self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage @@ -421,6 +425,7 @@ def build(self) -> Workflow: extra_output_executors=[aggregator], ) builder = WorkflowBuilder( + name=self._name, start_executor=dispatcher, checkpoint_storage=self._checkpoint_storage, output_from=designated, diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index 9e364989e5a..eb12cfbac8a 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -69,6 +69,7 @@ from typing_extensions import override # pragma: no cover logger = logging.getLogger(__name__) +DEFAULT_WORKFLOW_NAME = "GroupChat" @dataclass(frozen=True) @@ -629,6 +630,7 @@ class GroupChatBuilder: def __init__( self, *, + name: str | None = None, participants: Sequence[SupportsAgentRun | Executor] | None = None, participant_factories: Sequence[Callable[[], SupportsAgentRun | Executor]] | None = None, # Orchestrator config (exactly one required) @@ -646,6 +648,7 @@ def __init__( """Initialize the GroupChatBuilder. Args: + name: Optional workflow identifier. Defaults to ``"GroupChat"``. participants: Optional sequence of agent or executor instances for the group chat. participant_factories: Optional sequence of callables returning agent or executor instances. orchestrator_agent: An instance of Agent or a callable that produces one to manage the group chat. @@ -665,6 +668,7 @@ def __init__( surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant not selected by ``output_from``. Unlisted participant outputs are hidden. """ + self._name = name or DEFAULT_WORKFLOW_NAME self._participants: dict[str, SupportsAgentRun | Executor] = {} self._participant_factories: list[Callable[[], SupportsAgentRun | Executor]] = [] @@ -1039,6 +1043,7 @@ def build(self) -> Workflow: extra_output_executors=[orchestrator], ) workflow_builder = WorkflowBuilder( + name=self._name, start_executor=orchestrator, checkpoint_storage=self._checkpoint_storage, output_from=designated, diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py index 9403a4ec2f9..36bb9e087fc 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_handoff.py @@ -71,6 +71,7 @@ logger = logging.getLogger(__name__) +DEFAULT_WORKFLOW_NAME = "Handoff" # region Handoff events @@ -614,7 +615,7 @@ def __init__( Args: name: Optional workflow identifier used in logging and debugging. - If not provided, a default name will be generated. + Defaults to ``"Handoff"``. participants: Optional list of ``Agent`` instances that will participate in the handoff workflow. You can also call `.participants([...])` later. Each participant must have a unique identifier (`.name` is preferred if set, otherwise `.id` is used). @@ -630,7 +631,7 @@ def __init__( surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant not selected by ``output_from``. Unlisted participant outputs are hidden. """ - self._name = name + self._name = name or DEFAULT_WORKFLOW_NAME self._description = description # Participant related members diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py index 3c04126ce71..0b2598f8852 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_magentic.py @@ -55,6 +55,7 @@ logger = logging.getLogger(__name__) +DEFAULT_WORKFLOW_NAME = "Magentic" # Consistent author name for messages produced by the Magentic manager/orchestrator MAGENTIC_MANAGER_NAME = "magentic_manager" @@ -1422,6 +1423,7 @@ def __init__( max_reset_count: int | None = None, max_round_count: int | None = None, # Existing params + name: str | None = None, enable_plan_review: bool = False, checkpoint_storage: CheckpointStorage | None = None, output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET), @@ -1430,6 +1432,7 @@ def __init__( """Initialize the Magentic workflow builder. Args: + name: Optional workflow identifier. Defaults to ``"Magentic"``. participants: Sequence of agent or executor instances for the workflow. manager: Pre-configured manager instance (subclass of MagenticManagerBase). manager_factory: Callable that returns a new MagenticManagerBase instance. @@ -1455,6 +1458,7 @@ def __init__( surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant not selected by ``output_from``. Unlisted participant outputs are hidden. """ + self._name = name or DEFAULT_WORKFLOW_NAME self._participants: dict[str, SupportsAgentRun | Executor] = {} # Manager related members @@ -1794,6 +1798,7 @@ def build(self) -> Workflow: extra_output_executors=[orchestrator], ) workflow_builder = WorkflowBuilder( + name=self._name, start_executor=orchestrator, checkpoint_storage=self._checkpoint_storage, output_from=designated, diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index fbb98179501..8c3eeb064f3 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -44,6 +44,7 @@ from ._workflow_builder import OrchestrationWorkflowBuilder as WorkflowBuilder logger = logging.getLogger(__name__) +DEFAULT_WORKFLOW_NAME = "Sequential" class _InputToConversation(Executor): @@ -98,6 +99,7 @@ class SequentialBuilder: def __init__( self, *, + name: str | None = None, participants: Sequence[SupportsAgentRun | Executor], checkpoint_storage: CheckpointStorage | None = None, chain_only_agent_responses: bool = False, @@ -107,6 +109,7 @@ def __init__( """Initialize the SequentialBuilder. Args: + name: Optional workflow identifier. Defaults to ``"Sequential"``. participants: Sequence of agent or executor instances to run sequentially. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. chain_only_agent_responses: If True, only agent responses are chained between agents. @@ -118,6 +121,7 @@ def __init__( surface as workflow ``intermediate`` events. Pass ``"all_other"`` to select every participant not selected by ``output_from``. Unlisted participant outputs are hidden. """ + self._name = name or DEFAULT_WORKFLOW_NAME self._participants: list[SupportsAgentRun | Executor] = [] self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage self._chain_only_agent_responses: bool = chain_only_agent_responses @@ -258,6 +262,7 @@ def build(self) -> Workflow: default_output_from=[participants[-1]], ) builder = WorkflowBuilder( + name=self._name, start_executor=input_conv, checkpoint_storage=self._checkpoint_storage, output_from=designated, diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index d489a5c9b33..24ded70b899 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -55,6 +55,15 @@ def test_concurrent_builder_rejects_duplicate_executors() -> None: ConcurrentBuilder(participants=[a, b]) +def test_concurrent_builder_uses_stable_default_and_custom_name() -> None: + participants = [_FakeAgentExec("agentA", "A"), _FakeAgentExec("agentB", "B")] + + assert ConcurrentBuilder(participants=participants).build().name == "Concurrent" + assert ( + ConcurrentBuilder(name="custom-concurrent", participants=participants).build().name == "custom-concurrent" + ) + + async def test_concurrent_default_aggregator_emits_assistants_only() -> None: """Default aggregator yields a single AgentResponse with one assistant message per participant. @@ -210,6 +219,7 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: ) wf = ConcurrentBuilder(participants=list(participants), checkpoint_storage=storage).build() + assert wf.name == "Concurrent" baseline_output: AgentResponse | None = None async for ev in wf.run("checkpoint concurrent", stream=True): diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 725666ac6cb..961ba2ed2c7 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -254,6 +254,23 @@ async def test_group_chat_builder_basic_flow() -> None: assert updates[0].author_name == "manager" +def test_group_chat_builder_uses_stable_default_and_custom_name() -> None: + participant = StubAgent("agent", "response") + + default_workflow = GroupChatBuilder( + participants=[participant], + selection_func=make_sequence_selector(), + ).build() + custom_workflow = GroupChatBuilder( + name="custom-group-chat", + participants=[participant], + selection_func=make_sequence_selector(), + ).build() + + assert default_workflow.name == "GroupChat" + assert custom_workflow.name == "custom-group-chat" + + async def test_group_chat_as_agent_accepts_conversation() -> None: selector = make_sequence_selector() alpha = StubAgent("alpha", "ack from alpha") @@ -495,6 +512,7 @@ def selector(state: GroupChatState) -> str: selection_func=selector, ).build() + assert workflow.name == "GroupChat" updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): if event.type == "output" and isinstance(event.data, AgentResponseUpdate): @@ -660,6 +678,7 @@ def selector(state: GroupChatState) -> str: checkpoint_storage=storage, selection_func=selector, ).build() + assert workflow.name == "GroupChat" updates: list[AgentResponseUpdate] = [] async for event in workflow.run("test task", stream=True): diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index 97b2a42e4fe..414fa89a5bc 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -261,6 +261,28 @@ async def test_handoff(): assert request.source_executor_id == escalation.name +def test_handoff_builder_uses_stable_default_and_custom_name() -> None: + participant = MockHandoffAgent(name="agent") + + default_workflow = ( + HandoffBuilder(participants=[participant], termination_condition=lambda _: True) + .with_start_agent(participant) + .build() + ) + custom_workflow = ( + HandoffBuilder( + name="custom-handoff", + participants=[participant], + termination_condition=lambda _: True, + ) + .with_start_agent(participant) + .build() + ) + + assert default_workflow.name == "Handoff" + assert custom_workflow.name == "custom-handoff" + + def _latest_request_info_event(events: list[WorkflowEvent]) -> WorkflowEvent[Any]: request_events = [event for event in events if event.type == "request_info"] assert request_events diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index a759c354400..5e085799acf 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -201,6 +201,24 @@ def test_magentic_builder_marks_feature_with_custom_manager() -> None: assert int(token.split(".", 1)[1], 16) & (1 << FeatureIndex.ORCHESTRATION_MAGENTIC) +def test_magentic_builder_uses_stable_default_and_custom_name() -> None: + default_manager = FakeManager() + custom_manager = FakeManager() + + default_workflow = MagenticBuilder( + participants=[DummyExec(name=default_manager.next_speaker_name)], + manager=default_manager, + ).build() + custom_workflow = MagenticBuilder( + name="custom-magentic", + participants=[DummyExec(name=custom_manager.next_speaker_name)], + manager=custom_manager, + ).build() + + assert default_workflow.name == "Magentic" + assert custom_workflow.name == "custom-magentic" + + async def test_magentic_builder_returns_workflow_and_runs() -> None: manager = FakeManager() agent = StubAgent(manager.next_speaker_name, "first draft") @@ -429,6 +447,7 @@ async def test_magentic_checkpoint_resume_round_trip(): manager=manager1, ).build() + assert wf.name == "Magentic" task_text = "checkpoint task" req_event: WorkflowEvent | None = None async for ev in wf.run(task_text, stream=True): diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 92dc7d409da..222eda57ef6 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -114,6 +114,13 @@ def test_sequential_builder_rejects_empty_participants() -> None: SequentialBuilder(participants=[]) +def test_sequential_builder_uses_stable_default_and_custom_name() -> None: + participant = _EchoAgent(name="echo") + + assert SequentialBuilder(participants=[participant]).build().name == "Sequential" + assert SequentialBuilder(name="custom-sequential", participants=[participant]).build().name == "custom-sequential" + + def test_sequential_builder_validation_rejects_invalid_executor() -> None: """Test that adding an invalid executor to the builder raises an error.""" with pytest.raises(TypeCompatibilityError): @@ -212,6 +219,7 @@ async def test_sequential_checkpoint_resume_round_trip() -> None: initial_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf = SequentialBuilder(participants=list(initial_agents), checkpoint_storage=storage).build() + assert wf.name == "Sequential" baseline_updates: list[AgentResponseUpdate] = [] async for ev in wf.run("checkpoint sequential", stream=True): From 0ad2e4460cefc405e509379b9c35cf5544d764c0 Mon Sep 17 00:00:00 2001 From: Manjunath Janardhan <35326118+manjunathshiva@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:42:57 +0000 Subject: [PATCH 102/108] Python: fix: make concurrent FileCheckpointStorage saves not race on a shared temp path (#7757) * Python: fix: make concurrent FileCheckpointStorage saves not race on a shared temp path FileCheckpointStorage.save() wrote to and then renamed a fixed ".json.tmp" path, so concurrent saves of the same checkpoint ID raced over the shared temp file. Whichever save renamed first removed the temp file still being written by another save, which then failed with FileNotFoundError / PermissionError in os.replace. Create a unique temp file per save in the destination directory (so os.replace remains atomic), serialize same-ID writes with a per-ID lock, and retry the atomic move briefly to absorb the transient Windows background-handle PermissionError that surfaces even for fully serialized replaces. Fixes #7748 * Python: fix: lock file writes at destination path, shield workers from cancellation Address two post-merge review concerns on FileCheckpointStorage.save(): - The per-event-loop, per-instance asyncio.Lock registry could not serialize two FileCheckpointStorage instances pointed at the same directory, nor a single instance driven from two event loops. Replace it with a process-wide, per-canonical-path threading.Lock registry (lazily populated, bounded by distinct destinations actually written). Because asyncio.to_thread runs the actual file I/O on a worker thread, the threading lock serializes correctly across coroutines, loops, and instances and spans the entire open + write + os.replace window. - Caller-side cancellation previously released the write lock early: a CancelledError delivered inside 'await asyncio.to_thread(...)' exited the 'async with' and ran _release_save_lock_ref while the OS thread kept writing. The next save for the same checkpoint_id could then reach os.replace concurrently, reintroducing the PermissionError race and possibly landing stale data over a newer write. Shield the worker so cancellation propagates only after the in-flight write completes. Also replace the refcount-aware registry test with: - registry-bounded test for the new invariant (one lock per destination) - cross-instance concurrent save test (two storages, same directory) - gated os.replace cancel-race test (deterministically parks A's worker mid-publish, confirms save B cannot reach os.replace until A completes) Fixes #7748 * Python: take checkpoint destination ownership before submitting the write Implements the revised A shape chosen in review: process-wide coordination keyed by canonical destination, asynchronous waiters, and registry entries released after the final queued or running operation. Three review findings shared one cause -- ownership was established inside the executor thread rather than before submission. Acquiring the destination lock inside the worker meant every same-path save occupied an `asyncio.to_thread` worker while merely waiting, so a burst could fill the default pool and stall unrelated work including checkpoint loads, and could deadlock once the write that had to finish first was queued behind those waiters. Ownership now comes from a queue keyed by the canonical path and is taken before the write is submitted, so only the active write holds a worker. Waiters suspend on `asyncio.wrap_future` over a `concurrent.futures.Future`, which is not bound to a loop, so one chain also orders writers enqueued from different event loops -- something a per-path lock could not do. The registry was a dict that never removed an entry. Because `WorkflowCheckpoint` generates a fresh UUID by default, every save retained one entry for the life of the process. Entries are now reference-counted by queued-or-running operations and dropped by the last release. Cancellation is handled explicitly, both ways the review allowed. A save cancelled while still waiting for the destination has nothing in the executor, so it is simply removed -- and it still hands ownership on, or every later save for that path would wait forever. A save cancelled mid-write drains its worker before propagating, because releasing first would let the next save start while this replace is still in flight. That drain absorbs re-delivered cancellations: once a task has a cancellation pending its next await raises immediately, so a single shielded await returns with the worker still running and the write can still land after ownership is released. Ownership is released as the write's last act on the worker thread, with the coroutine's `finally` as a backstop for the case where no write was submitted. Releasing only from the coroutine regressed on the design it replaced: a `threading.Lock` was released by the worker thread, which outlives the loop that submitted it, whereas a coroutine that never resumes -- its loop closed with the task still pending -- would leave the destination owned for the life of the process and hang every later save to it. The two releasers are idempotent, so exactly one of them accounts for each ticket. Tests are deterministic and cover each case: registry release for repeated, distinct-ID and concurrent saves; queued saves not occupying executor threads, gated by a two-worker executor with three saves in flight; serialization across two real event loops; cancellation before submission writing nothing and not stalling the follower; repeated cancellation still draining; a write failing while draining being reported rather than lost; a failed save releasing its destination and leaving the path usable; recovery of a destination whose loop was closed mid-write; and the defensive release path. * Python: tie checkpoint write ownership to the write, not to asyncio state Follow-up review on #7757 found two ways a later save could still overtake an earlier one. Both had the same cause: asyncio objects were standing in for the state of work that lives outside asyncio, and cancellation can mark those done while the work continues. `asyncio.wrap_future` chains cancellation into the future it wraps. Cancelling the middle of three queued saves therefore cancelled the hand-off signal the first save still had to resolve, and the unconditional release then let the third reach `os.replace` beside the first -- after which the first write could land last and overwrite the newer checkpoint. Waiting now goes through `_wait_for_signal`, which feeds a fresh per-wait future from a done-callback and leaves the shared signal untouched no matter what happens to the waiter. A save cancelled while queued no longer resolves its own signal either; it defers the hand-off until its predecessor has actually finished, so the queue stays ordered while still guaranteeing nothing waits behind it forever. Draining on the task wrapping `asyncio.to_thread` was the second: a cancelled task reports `done()` while its function is still inside `os.replace`, so loop shutdown released ownership with the write in flight. The drain now waits on the signal the worker thread resolves, which cancellation cannot mark done. Fixing those exposed two more failures of the same kind, both found by probing rather than by review. `ensure_future(asyncio.to_thread(...))` makes the write a Task, and shutdown cancels every task -- possibly before it reaches the executor. Nothing then released the destination, and the drain waited for a signal that could never be resolved, so the save never completed at all. The write is now submitted with `run_in_executor`, which submits synchronously and returns a plain Future that `asyncio.all_tasks()` does not include, plus a done-callback that releases on success, failure and cancellation alike. Deferring a cancelled ticket's hand-off onto its predecessor made release recursive: resolving one signal ran the next ticket's callback synchronously, so a run of cancelled queued saves longer than the recursion limit raised `RecursionError` on the worker thread partway through and left the destination owned for good. Releases are now collected per thread and drained in a loop, so the depth is flat however long the run. Two smaller points from the same review. A write that fails while draining is recorded on the ticket rather than read back from the worker future, because a cancelled task hides its own exception and the cancelled caller never sees it. And the drain registers one done-callback pointing at whichever waiter is current, rather than one per wait, so a caller cancelling in a loop no longer grows that list in step with it. Tests: cancelling the middle of three queued saves cannot let the third overtake; a cancelled worker task does not release while the thread writes; shutdown before the write starts neither hangs nor strands the destination, asserted structurally on the write not being a task so the regression fails cleanly instead of hanging CI; a deferred-release run of 2000 links does not recurse; submission against a dead executor releases; and resolving a signal after its loop closed does not raise. * Python: release a queued checkpoint save whose event loop is abandoned Follow-up review on #7757. A submitted write is released by its worker thread, which outlives the loop that submitted it, but a ticket still waiting for its predecessor has no worker to fall back on. If that waiter's loop closes, `call_soon_threadsafe` raises `RuntimeError`, the suppression swallowed it, and the coroutine never resumed -- so nothing resolved the ticket's hand-off signal and every later save for that destination waited forever. Reproduced: the entry stays at `pending=1` and a later save from a fresh loop times out. The predecessor's callback now owns that case. `_wait_for_signal` takes an `on_abandoned` hook, invoked when the waiter's loop has closed, and the queued wait passes its own release -- performed on the thread that resolved the predecessor rather than on a loop that will never run again. The drain keeps suppressing, and the two guards now differ deliberately: by the time anything drains, the write has been submitted, so the worker thread and the submitted future's callback both release without needing that loop. The comment says so, since the asymmetry is otherwise unexplained. Two windows this cannot close, both stated in the comment rather than implied: a loop that closes after `call_soon_threadsafe` succeeded but before the callback runs, and one abandoned without being closed at all. Reaching either needs a loop closed with tasks still pending, which asyncio already reports as an error. Graceful shutdown is unaffected -- `asyncio.run` cancels pending tasks first, which drives a queued save through its cancellation path and defers the hand-off onto its predecessor. That path now has its own test, since it is the guarantee callers actually depend on. * Python: cover the checkpoint write error paths this PR introduced Three branches added by this PR had no test. Coverage of `_checkpoint.py` was reporting 97% with the gaps sitting exactly on the error handling: * the bounded `os.replace` retry giving up after five attempts. The suite only ever reached the surrounding lines by accident -- the concurrency tests trip a real `PermissionError` on Windows now and then, which is environment-dependent and never happens on Linux CI. The new test pins both halves that matter: the retry is bounded, and giving up still releases the destination rather than wedging every later save to it. * the temp-file cleanup swallowing an `OSError`. It runs in a `finally` while a write exception is propagating, so letting one out would substitute a misleading "cannot remove temp file" for the real disk failure. Reverting the handler makes the new test fail with exactly that substitution. * the fast path in `_await_signal_through_cancellation` for a signal that has already resolved. Exercised directly, since arranging the race through `save()` is not deterministic. No source change. Every line this PR adds is now covered; the three that remain uncovered in the file all predate it. * Python: correct two test docstrings that still described the superseded design Copilot review on #7757. Both described machinery this PR replaced, which would point the next reader at the wrong design. The cross-instance test said the fix "switched to a process-wide, per-destination threading.Lock keyed by canonical path". Ownership comes from a process-wide queue keyed by the canonical destination path; the lock is what it replaced. The cross-loop test was worse: it said the hand-off signal is one "any loop can await through `asyncio.wrap_future`". That is the mechanism this PR deliberately does not use -- it chains a waiter's cancellation into the shared signal an earlier writer still has to resolve, which is the defect the review caught earlier on this PR. It now names `_wait_for_signal` and says why `wrap_future` is excluded. Checked the file's other references: the remaining `threading.Lock` and `wrap_future` mentions are either locks used by the tests themselves or past-tense descriptions of what was replaced, which are accurate as written. * Python: hold the abandoned-loop tests' pending tasks instead of letting GC decide `test_file_checkpoint_storage_abandonment_mid_chain_keeps_the_queue_ordered` crashed a Windows CI worker. Not an assertion failure -- the worker process died, with no Python traceback. The cause is an unreferenced pending task. The test did `abandoned_loop.create_task(storage.save(...))` without keeping the result, so the task was collectable the moment the call returned, and the loop it belongs to is deliberately closed while the task is still pending. That left the task's finalizer -- which touches the closed loop -- to run at whatever point the garbage collector chose, including during worker teardown. That also explains the shape of the failure. It is not a version boundary: 3.11, 3.12 and 3.14 failed on Windows while 3.10 and 3.13 passed, and every Linux job passed. Non-monotonic scatter like that is a timing flake, and GC timing is the thing that varies. It did not reproduce locally in ten runs on 3.12 -- the test alone, the file under xdist, the workflow directory, and the full core suite under xdist six times. Three sibling tests had the same unreferenced-task pattern, all of them in tests that abandon or shut down a loop on purpose, so all four now hold their tasks for the duration of the test. The sibling at `test_file_checkpoint_storage_abandoned_loop_while_queued_releases_the_destination` already did this correctly, which is what made the inconsistency visible. Holding the reference does not weaken what these tests prove: the abandonment being tested is of the loop, not of the task object, and the task stays pending either way. It only moves the finalizer to a deterministic point. * Python: stop the shutdown test racing the filesystem CI failed with `found 0 tasks` on Linux 3.13. Not the code -- the test's timing assumption. It created the save task, yielded once with `await asyncio.sleep(0)`, then asserted exactly one pending task to prove the write is not itself a task. But a single yield drains every ready callback, so on a fast tmpfs the executor write could finish and resolve the shield inside that same batch; the save completed and `asyncio.all_tasks()` came back empty. The write is now held at its first syscall, so the save is deterministically suspended when the count is taken. Three things about the gate matter: * It only gates this save's own temp file. `checkpoint_module.os` is the real `os` module, so the patch is process-wide, and blocking every `os.open` would stall pytest's own I/O and coverage writes on the same event and deadlock the run. * The assertions are split. An empty list now reports that the gate did not hold, which is a problem with the test; two tasks reports the write being a task, which is the regression. One message could not honestly explain both, and `0` was indistinguishable from the flake it replaced. * A failing assertion releases the worker before propagating, so the real error surfaces immediately instead of after the gate's timeout. Verified rather than assumed: reverting the source to `ensure_future(asyncio.to_thread(...))` still fails the test, now with `found 2 tasks` and the pending `to_thread()` task in the output. Stable over ten runs of the test and five shuffled xdist runs of the whole file. --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework/_workflows/_checkpoint.py | 390 ++++- .../core/tests/workflow/test_checkpoint.py | 1498 +++++++++++++++++ 2 files changed, 1882 insertions(+), 6 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index fe1045b2a6f..67b98f2bdb6 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -3,13 +3,16 @@ from __future__ import annotations import asyncio +import contextlib import copy import json import logging import os import threading +import time import uuid -from collections.abc import Mapping +from collections.abc import Callable, Mapping +from concurrent.futures import Future from dataclasses import dataclass, field, fields from datetime import datetime, timezone from pathlib import Path @@ -249,6 +252,251 @@ async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID] return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_name == workflow_name] +# Process-wide serialization of writes per destination file. +# +# asyncio.Lock is loop-bound, so a per-(loop, checkpoint-id) registry cannot +# serialize two FileCheckpointStorage instances pointed at the same directory, nor one +# instance driven from two event loops. Ownership is therefore handed out by a +# process-wide queue keyed by the canonical destination path, and taken *before* the +# write is submitted to a worker thread rather than inside it. +# +# Hand-off runs over ``concurrent.futures.Future`` signals, which are not bound to a +# loop, so a single chain orders writers regardless of which loop enqueued them, and +# waiters suspend on the event loop instead of occupying an ``asyncio.to_thread`` +# worker. Blocking on a ``threading.Lock`` inside the worker instead -- the original +# design -- let a burst of same-path saves fill the default executor and stall unrelated +# ``to_thread`` work, including checkpoint loads, and could deadlock once the write that +# had to finish first was queued behind those waiters. +# +# Those signals are awaited through ``_wait_for_signal``, never ``asyncio.wrap_future``. +# Cancellation is the reason: ``wrap_future`` chains it into the future it wraps, so one +# cancelled waiter would cancel the signal an earlier writer still has to resolve, and +# the ``asyncio`` task wrapping a submitted write reports ``done()`` while its function +# is still running on the executor thread. Both let a later save start its ``os.replace`` +# beside an earlier one, after which the earlier write can land last and overwrite the +# newer checkpoint. +# +# Entries are reference-counted by queued-or-running operations and dropped when the +# last one releases, so a process that saves many distinct checkpoint IDs -- the +# default, since ``WorkflowCheckpoint`` generates a fresh UUID -- does not retain an +# entry per ID for its lifetime. +# +# The scope really is one process. Several replicas sharing a checkpoint directory -- a +# mounted volume, a network share -- get no serialization from this, because there is no +# shared state between them to coordinate through. Concurrent saves of the same +# checkpoint ID from different processes still rely on ``os.replace`` being atomic on +# the underlying filesystem for the file not to be seen half-written; which write +# survives is undefined. +_destination_queues: dict[Path, _DestinationQueue] = {} +_destination_queues_guard = threading.Lock() + + +@dataclass +class _DestinationQueue: + """FIFO ownership hand-off for one destination path.""" + + #: Completion signal of the most recently enqueued operation, awaited by the next. + tail: Future[None] | None = None + #: Operations queued or running for this path; the entry is dropped at zero. + pending: int = 0 + + +@dataclass +class _WriteTicket: + """One operation's place in a destination's queue.""" + + path: Path + #: Signal to await before taking ownership; ``None`` when the queue was empty. + predecessor: Future[None] | None + #: Signal this operation resolves to hand ownership to its successor. + completion: Future[None] + #: Set by whichever of the worker thread or the coroutine releases first. + released: bool = False + #: Failure raised by the write, recorded on the worker thread. A cancelled caller + #: never sees it, and a cancelled task hides its own exception, so the ticket is the + #: only place it survives. + error: BaseException | None = None + + +def _enqueue_write(file_path: Path) -> _WriteTicket: + """Take a place in *file_path*'s queue without waiting for it.""" + completion: Future[None] = Future() + with _destination_queues_guard: + queue = _destination_queues.get(file_path) + if queue is None: + queue = _DestinationQueue() + _destination_queues[file_path] = queue + predecessor = queue.tail + queue.tail = completion + queue.pending += 1 + return _WriteTicket(path=file_path, predecessor=predecessor, completion=completion) + + +def _wait_for_signal( + source: Future[None], + *, + on_abandoned: Callable[[], None] | None = None, +) -> asyncio.Future[None]: + """Return a fresh awaitable that completes when *source* does. + + Deliberately not ``asyncio.wrap_future``: that chains cancellation into the future it + wraps, so a waiter cancelled here would cancel the hand-off signal an earlier writer + still has to resolve, and every later writer keyed to it. A per-wait future fed by a + done-callback leaves *source* untouched no matter what happens to the waiter. + """ + loop = asyncio.get_running_loop() + waiter: asyncio.Future[None] = loop.create_future() + + def _resolve(_completed: Future[None]) -> None: + def _set() -> None: + if not waiter.done(): + waiter.set_result(None) + + try: + loop.call_soon_threadsafe(_set) + except RuntimeError: + # The waiter's loop has closed, so the coroutine suspended here will never + # resume and cannot do anything on its way out. Whatever it owed -- releasing + # its place in the queue, above all -- has to happen here instead, on the + # thread that resolved the signal. + # + # This catches a loop that is already closed when the signal resolves. It + # cannot catch one that closes in the window after `call_soon_threadsafe` + # succeeded but before the callback runs, nor one abandoned without being + # closed at all: both leave a queued ticket unreleased. Reaching either needs + # a loop closed with tasks still pending, which asyncio already reports as an + # error, and a graceful shutdown is unaffected -- `asyncio.run` cancels + # pending tasks first, which drives the queued save through its cancellation + # path and defers the hand-off onto the predecessor. + if on_abandoned is not None: + on_abandoned() + + source.add_done_callback(_resolve) + return waiter + + +async def _await_signal_through_cancellation(source: Future[None]) -> None: + """Wait until *source* resolves, absorbing cancellations delivered meanwhile. + + Used to wait out a write this coroutine still owns. Waiting on the ``asyncio`` task + wrapping the write is not equivalent: a cancelled task reports ``done()`` while its + function is still running on the executor thread, so ownership would be released + with the write still in flight. Only the signal the worker thread resolves tracks + the write itself, and cancellation cannot mark it done. + + The done-callback is registered once and points at whichever waiter is current, + rather than one callback per wait. Each cancellation would otherwise leave another + callback on the signal, so a caller cancelling in a loop would grow that list in + step with it and pay for the whole list when the write finally lands. + """ + if source.done(): + # Purely to avoid registering a callback that would fire straight back; the loop + # condition below already short-circuits, so this is not load-bearing. + return + + loop = asyncio.get_running_loop() + current: dict[str, asyncio.Future[None] | None] = {"waiter": None} + + def _resolve(_completed: Future[None]) -> None: + def _set() -> None: + waiter = current["waiter"] + if waiter is not None and not waiter.done(): + waiter.set_result(None) + + # Safe to swallow here, unlike the queued wait in `_wait_for_signal`. By the + # time anything drains, the write has been submitted, so the worker thread and + # the submitted future's callback both release the ticket without needing this + # loop; a closed loop only means there is nobody left to wake. + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(_set) + + source.add_done_callback(_resolve) + while not source.done(): + waiter: asyncio.Future[None] = loop.create_future() + current["waiter"] = waiter + try: + await waiter + except asyncio.CancelledError: + # Re-delivered cancellation. The signal may have resolved against the waiter + # we just abandoned, so the loop condition is what decides whether to stop. + continue + + +def _release_write_after(ticket: _WriteTicket, predecessor: Future[None]) -> None: + """Hand *ticket*'s ownership on only once *predecessor* has actually finished. + + A ticket cancelled while still queued must not resolve its own signal immediately: + an earlier writer still owns the destination, and the next waiter would start its + write alongside that one. Deferring keeps the queue in order while still guaranteeing + the hand-off happens, so nothing waits forever behind a cancelled save. + """ + predecessor.add_done_callback(lambda _completed: _release_write(ticket)) + + +class _ReleaseTrampoline(threading.local): + """Releases waiting for the one currently unwinding on this thread. + + Resolving one ticket's signal runs the next ticket's deferred-release callback + synchronously, so a run of cancelled queued saves would otherwise nest one stack + frame per link and raise ``RecursionError`` on the worker thread partway through -- + leaving the destination owned for good. Collecting them here and draining in a loop + keeps the depth flat however long the run is. + """ + + queued: list[_WriteTicket] | None = None + + +_release_trampoline = _ReleaseTrampoline() + + +def _release_write(ticket: _WriteTicket) -> None: + """Hand ownership to the next waiter and drop the entry once nothing is queued. + + Called from three places, whichever gets there first, and idempotent so all can: + + * the worker thread, as the last act of the write itself. This is what keeps a + destination usable if the event loop that submitted the write goes away before the + coroutine can resume -- the thread finishes and releases regardless. + * a done-callback on the submitted write, which fires on success, failure and + cancellation alike, so the executor dropping the work still releases. + * the coroutine, when the write was never submitted at all. + + Resolving the completion signal is what keeps the chain moving, so an operation that + is cancelled or fails must still release rather than stall every later writer. + """ + queued = _release_trampoline.queued + if queued is not None: + # A release is already unwinding on this thread; let it drain this one. + queued.append(ticket) + return + + draining: list[_WriteTicket] = [] + _release_trampoline.queued = draining + try: + _release_one_write(ticket) + while draining: + _release_one_write(draining.pop(0)) + finally: + _release_trampoline.queued = None + + +def _release_one_write(ticket: _WriteTicket) -> None: + """Release exactly one ticket. Only ``_release_write`` should call this.""" + with _destination_queues_guard: + if ticket.released: + return + ticket.released = True + queue = _destination_queues.get(ticket.path) + if queue is not None: + queue.pending -= 1 + if queue.pending <= 0: + del _destination_queues[ticket.path] + # Outside the guard: waking the successor must not happen while holding the lock its + # own release will need. + if not ticket.completion.done(): + ticket.completion.set_result(None) + + class FileCheckpointStorage: """File-based checkpoint storage for persistence. @@ -352,13 +600,143 @@ async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: "this storage's allowed types; refusing to save." ) from ex + def _replace_with_retry(tmp_path: Path) -> None: + # On Windows, os.replace can transiently fail with PermissionError when a + # background indexer or AV scan briefly holds a handle to the destination + # file. The destination queue serializes concurrent save() calls to the same + # path, but the OS callback is still external to the process and can trip a + # transient error even when only one replace is in flight. Retry briefly to + # absorb it. + for attempt in range(5): + try: + os.replace(tmp_path, file_path) + return + except PermissionError: + if attempt == 4: + raise + time.sleep(0.001 * (2**attempt)) + def _write_atomic() -> None: - tmp_path = file_path.with_suffix(".json.tmp") - with open(tmp_path, "w", encoding="utf-8") as f: - json.dump(encoded_checkpoint, f, indent=2, ensure_ascii=False) - os.replace(tmp_path, file_path) + # No lock here: ownership of the destination is already held by the caller + # of this function, taken from the process-wide queue before the write was + # submitted. Acquiring it on the worker instead is what let same-path saves + # pile up inside the executor. + # + # Use a unique temp file per save in the destination directory so + # concurrent saves of distinct checkpoint IDs never contend on a + # shared temporary path, and os.replace remains atomic (same + # filesystem). A short, ID-independent name keeps every destination + # name accepted by _validate_file_path saveable regardless of + # checkpoint-ID length or filesystem limits. + tmp_path: Path | None = None + try: + # O_CREAT | O_EXCL | O_WRONLY with an explicit 0o666 mode, so the + # file is created with the process umask exactly like the previous + # open(..., "w") path was (NamedTemporaryFile would hard-code 0o600 + # and downgrade modes on POSIX after an os.replace over an existing + # checkpoint). + tmp_name = f".maf-ckpt-{uuid.uuid4().hex}.tmp" + tmp_path = file_path.parent / tmp_name + fd = os.open(tmp_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666) + # UTF-8 explicitly, carried over from #8214: `json.dump(..., ensure_ascii=False)` + # writes non-ASCII raw, and the platform default is cp1252 on Windows, so a + # checkpoint containing CJK text or emoji raised UnicodeEncodeError. The read + # sides are UTF-8 too; fixing only one would trade a loud write error for silent + # read corruption. + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(encoded_checkpoint, f, indent=2, ensure_ascii=False) + _replace_with_retry(tmp_path) + tmp_path = None + finally: + if tmp_path is not None and tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + # Best-effort cleanup only; leaking a temp file is harmless + # compared to masking the original exception. + logger.debug(f"Failed to remove checkpoint temp file {tmp_path}", exc_info=True) + + def _write_atomic_and_release(ticket: _WriteTicket) -> None: + # Release on the worker thread, as the write's last act. The coroutine's + # `finally` would otherwise be the only releaser, and it never runs if the + # loop that submitted this write is gone -- leaving the destination owned + # forever and hanging every later save to it. + try: + _write_atomic() + except BaseException as exc: + ticket.error = exc + raise + finally: + _release_write(ticket) + + ticket = _enqueue_write(file_path) + if ticket.predecessor is not None: + # Suspends on the event loop, not on an executor worker, and orders this save + # behind every earlier one for the same destination even when they were + # enqueued from a different loop. + try: + await _wait_for_signal( + ticket.predecessor, + # If this loop dies while we are queued, nothing else would release + # this ticket: the write was never submitted, so there is no worker + # thread to fall back on, and every later save for the destination + # would wait on a signal nobody resolves. + on_abandoned=lambda: _release_write(ticket), + ) + except BaseException: + # Nothing was submitted and nothing written, but the hand-off cannot + # happen yet: an earlier writer still owns the destination, and resolving + # this ticket's signal now would let the next save run its os.replace + # alongside that one -- after which the earlier write can land last and + # overwrite the newer checkpoint. Defer the hand-off until the + # predecessor has actually finished. + _release_write_after(ticket, ticket.predecessor) + raise + + # Ownership held from here. Submit through `run_in_executor` rather than + # `ensure_future(asyncio.to_thread(...))`: the latter creates a Task, and loop + # shutdown cancels every task, so the write could be cancelled before it ever + # reached the executor -- leaving nobody to release the destination and this + # coroutine waiting on a signal that could never be resolved. `run_in_executor` + # returns a plain Future that `asyncio.all_tasks()` does not include, and it + # submits synchronously, so returning from it means the write really is queued. + loop = asyncio.get_running_loop() + try: + write_future = loop.run_in_executor(None, _write_atomic_and_release, ticket) + except BaseException: + # Never reached the executor, so no worker will release on our behalf. + _release_write(ticket) + raise + # The callback fires on success, failure and cancellation alike, so the + # destination is released even if the executor drops the work before the function + # runs. Idempotent with the worker thread's own release, which means no flag is + # needed to decide which of the two owns it. + write_future.add_done_callback(lambda _completed: _release_write(ticket)) - await asyncio.to_thread(_write_atomic) + try: + # Shield so a cancellation arriving mid-write cannot leave the write running + # past this frame: its os.replace would otherwise land after the caller + # returned, overwriting whatever a later save had published. + await asyncio.shield(write_future) + except asyncio.CancelledError: + # Wait out the write itself rather than the task wrapping it. A cancelled + # task reports done() while its function is still running on the executor + # thread, so waiting on `worker` would return with the replace still in + # flight and ownership about to be released. + await _await_signal_through_cancellation(ticket.completion) + if ticket.error is not None: + # The caller is receiving CancelledError and will never see this, and a + # cancelled task hides its own exception, so the log is the only place a + # write that failed while draining can surface. + logger.warning( + f"Checkpoint write to {file_path} failed while draining after cancellation: {ticket.error!r}" + ) + raise + except BaseException: + # The write failed. The worker already released in its own `finally`; this is + # idempotent cover for a submission that never got that far. + _release_write(ticket) + raise logger.info(f"Saved checkpoint {checkpoint.checkpoint_id} to {file_path}") return checkpoint.checkpoint_id diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py index 31ad62ee4bf..6e13a414f8b 100644 --- a/python/packages/core/tests/workflow/test_checkpoint.py +++ b/python/packages/core/tests/workflow/test_checkpoint.py @@ -2,8 +2,13 @@ import asyncio import json +import logging +import os +import sys import tempfile import threading +import time +from concurrent.futures import Future as ConcurrentFuture from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -1196,6 +1201,193 @@ async def test_file_checkpoint_storage_save_and_load(): assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events +async def test_file_checkpoint_storage_concurrent_saves_same_id(): + """Concurrent saves of the same checkpoint ID must not fail on a shared temp path. + + Regression for https://github.com/microsoft/agent-framework/issues/7748: + FileCheckpointStorage.save() used a fixed `.json.tmp` temp path, so concurrent + saves raced on it (one rename removed it before another's rename). Uses enough + concurrent saves to reliably trip the race on the unfixed code. + """ + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + checkpoint_id="shared-id", + ) + + results = await asyncio.gather(*(storage.save(checkpoint) for _ in range(50)), return_exceptions=True) + + errors = [r for r in results if isinstance(r, BaseException)] + assert not errors, f"concurrent saves raised internal filesystem errors: {errors[:1]!r}" + assert all(r == "shared-id" for r in results) + # One of the saves won; the destination is intact and parseable, not corrupted or truncated. + assert (Path(temp_dir) / "shared-id.json").exists() + loaded = await storage.load("shared-id") + assert loaded.checkpoint_id == checkpoint.checkpoint_id + assert loaded.workflow_name == checkpoint.workflow_name + assert loaded.graph_signature_hash == checkpoint.graph_signature_hash + + +async def test_file_checkpoint_storage_destination_queue_registry_released(): + """The destination registry must be empty again once nothing is queued or running. + + Reviewer concern on #7757: the previous design kept a process-wide dict of + `threading.Lock` keyed by destination path and never removed an entry. Because + `WorkflowCheckpoint` generates a fresh UUID by default, every save retained one + entry for the lifetime of the process. Entries are now reference-counted by + queued-or-running operations and dropped by the last release, so repeated saves of + one ID and saves of many distinct IDs both settle back to nothing held. + """ + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + from agent_framework._workflows import _checkpoint as checkpoint_module + + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + baseline = len(registry) + + for _ in range(20): + await storage.save( + WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + checkpoint_id="shared-id", + ) + ) + assert len(registry) == baseline, "same-ID saves left an entry behind" + + # The default: every checkpoint gets its own UUID, so each save is a distinct + # destination. This is the case that grew without bound before. + for _ in range(20): + await storage.save(WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash")) + assert len(registry) == baseline, "distinct-ID saves leaked registry entries" + + # Concurrent same-path saves share one entry while queued, and release it. + await asyncio.gather( + *( + storage.save( + WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + checkpoint_id="concurrent-id", + ) + ) + for _ in range(8) + ) + ) + assert len(registry) == baseline, "concurrent saves left an entry behind" + + +async def test_file_checkpoint_storage_concurrent_saves_across_instances(): + """Two FileCheckpointStorage instances to the same directory must serialize same-ID saves. + + Companion regression for a reviewer concern raised while fixing #7748: the + previous per-instance, per-event-loop lock registry did not span a second + FileCheckpointStorage instance pointed at the same directory, so concurrent + saves could still reach os.replace together and trip the Windows PermissionError + race. Ownership now comes from a process-wide queue keyed by the canonical + destination path, shared by every instance in the process. Both instances must + complete all saves without surfacing filesystem errors. + """ + with tempfile.TemporaryDirectory() as temp_dir: + storage_a = FileCheckpointStorage(temp_dir) + storage_b = FileCheckpointStorage(temp_dir) + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + checkpoint_id="shared-id", + ) + + results = await asyncio.gather( + *[storage_a.save(checkpoint) for _ in range(25)], + *[storage_b.save(checkpoint) for _ in range(25)], + return_exceptions=True, + ) + + errors = [r for r in results if isinstance(r, BaseException)] + assert not errors, f"cross-instance concurrent saves raised: {errors[:1]!r}" + assert all(r == "shared-id" for r in results) + assert (Path(temp_dir) / "shared-id.json").exists() + + loaded = await storage_a.load("shared-id") + assert loaded.checkpoint_id == checkpoint.checkpoint_id + assert loaded.workflow_name == checkpoint.workflow_name + + +async def test_file_checkpoint_storage_cancel_drains_before_releasing(monkeypatch): + """A cancelled save must not release the destination while its write is in flight. + + Reviewer concern on #7757: shielding alone let the caller observe `CancelledError` + while the worker kept running, so a later save on another loop could take the + destination, complete, and then be overwritten when the cancelled worker finally + ran its `os.replace`. The cancellation path now drains the worker before + propagating, which means the cancelled caller does not return until its own write + has finished -- so nothing it wrote can land after a later save. + """ + import threading + + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + real_replace = checkpoint_module.os.replace + replace_started = threading.Event() + release_first_replace = threading.Event() + # `_replace_with_retry` may call os.replace more than once for a single write, so + # count completed writes rather than attempts. + attempts = 0 + replace_calls: list[str] = [] + calls_guard = threading.Lock() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + nonlocal attempts + with calls_guard: + attempts += 1 + first = attempts == 1 + if first: + replace_started.set() + assert release_first_replace.wait(timeout=10) + real_replace(src, dst) + with calls_guard: + replace_calls.append(os.path.basename(str(dst))) + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="shared-id") + + task_a = asyncio.create_task(storage.save(make("workflow-a"))) + assert await asyncio.to_thread(replace_started.wait, 10), "save A never reached os.replace" + + task_a.cancel() + # The drain is the point: A must still be running, holding the destination, + # because its own worker has not finished. + for _ in range(20): + await asyncio.sleep(0.01) + if task_a.done(): + break + assert not task_a.done(), "cancelled save returned while its write was still in flight" + + # A later save cannot take the destination while A is draining. + task_b = asyncio.create_task(storage.save(make("workflow-b"))) + for _ in range(20): + await asyncio.sleep(0.01) + assert attempts == 1, "save B submitted a write while A was still draining" + assert not task_b.done() + + release_first_replace.set() + with pytest.raises(asyncio.CancelledError): + await task_a + await asyncio.wait_for(task_b, timeout=10) + + assert len(replace_calls) == 2 + # B ran second, so B's data is what survives. + assert (await storage.load("shared-id")).workflow_name == "workflow-b" + + async def test_file_checkpoint_storage_load_nonexistent(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) @@ -1951,3 +2143,1309 @@ async def test_file_checkpoint_storage_roundtrip_empty_collections(): # endregion + + +async def test_file_checkpoint_storage_queued_saves_do_not_occupy_executor_threads(monkeypatch): + """Queued same-path saves must wait on the event loop, not inside the executor. + + Reviewer concern on #7757: the previous design acquired the destination lock inside + the worker, so every same-path save occupied an `asyncio.to_thread` worker while + merely waiting. A burst could fill the default pool and stall unrelated work -- + including checkpoint loads -- and deadlock once the write that had to finish first + was queued behind those waiters. Ownership is now taken before submission, so only + the active write holds a worker. + + The gate is a deliberately small executor: with three same-path saves in flight and + only two workers, an unrelated `to_thread` call still has to get a thread. + """ + import threading + from concurrent.futures import ThreadPoolExecutor + + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + real_replace = checkpoint_module.os.replace + first_replace_reached = threading.Event() + release_first_replace = threading.Event() + inside_worker = 0 + max_inside_worker = 0 + counter_guard = threading.Lock() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + nonlocal inside_worker, max_inside_worker + with counter_guard: + inside_worker += 1 + max_inside_worker = max(max_inside_worker, inside_worker) + first = inside_worker == 1 and not first_replace_reached.is_set() + try: + if first: + first_replace_reached.set() + assert release_first_replace.wait(timeout=10) + real_replace(src, dst) + finally: + with counter_guard: + inside_worker -= 1 + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="ckpt-test") + # `set_default_executor(None)` is rejected, so the original has to be put back + # by attribute. Routed through an untyped local rather than ignore comments. + loop: Any = asyncio.get_running_loop() + previous_executor = loop._default_executor + loop.set_default_executor(executor) + try: + saves = [ + asyncio.create_task( + storage.save( + WorkflowCheckpoint( + workflow_name=f"w{index}", + graph_signature_hash="test-hash", + checkpoint_id="shared-id", + ) + ) + ) + for index in range(3) + ] + assert await asyncio.to_thread(first_replace_reached.wait, 10) + + # The decisive assertion: two saves are queued behind the parked one, and an + # unrelated to_thread call must still be scheduled. Under the old design the + # waiters held both workers and this timed out. + marker = await asyncio.wait_for(asyncio.to_thread(lambda: "scheduled"), timeout=5) + assert marker == "scheduled" + + release_first_replace.set() + await asyncio.wait_for(asyncio.gather(*saves), timeout=10) + assert max_inside_worker == 1, f"{max_inside_worker} writes ran concurrently for one destination" + finally: + loop._default_executor = previous_executor + executor.shutdown(wait=True) + + +async def test_file_checkpoint_storage_cancel_before_submission_writes_nothing(monkeypatch): + """A save cancelled while queued must never submit its write, and must not stall the queue. + + This is the other half of the cancellation contract: the reviewer asked that a + cancelled write either be removed before submission or drained. A save cancelled + while still waiting for the destination has nothing in the executor, so it is simply + removed -- and it still has to hand ownership on, or every later save for that path + would wait forever. + """ + import threading + + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + real_replace = checkpoint_module.os.replace + first_replace_reached = threading.Event() + release_first_replace = threading.Event() + # `_replace_with_retry` may call os.replace more than once for a single write, so + # count completed writes rather than attempts. + attempts = 0 + written: list[str] = [] + guard = threading.Lock() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + nonlocal attempts + with guard: + attempts += 1 + first = attempts == 1 + if first: + first_replace_reached.set() + assert release_first_replace.wait(timeout=10) + real_replace(src, dst) + with guard: + written.append(os.path.basename(str(dst))) + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="shared-id") + + holder = asyncio.create_task(storage.save(make("holder"))) + assert await asyncio.to_thread(first_replace_reached.wait, 10) + + queued = asyncio.create_task(storage.save(make("cancelled"))) + follower = asyncio.create_task(storage.save(make("follower"))) + + # Wait on observable state rather than a sleep: both saves must actually be + # queued behind the holder before cancelling, or this would be testing a + # cancellation that raced the enqueue instead. + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + canonical = (Path(temp_dir) / "shared-id.json").resolve() + for _ in range(400): + entry = registry.get(canonical) + if entry is not None and entry.pending == 3: + break + await asyncio.sleep(0.005) + entry = registry.get(canonical) + assert entry is not None and entry.pending == 3, ( + f"expected holder + two queued saves, got {entry.pending if entry else 'no entry'}" + ) + + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + # Nothing was submitted for the cancelled save: the only write in flight is the + # holder's, still parked before its replace. + assert attempts == 1, "the cancelled save submitted a write" + assert written == [] + + release_first_replace.set() + await asyncio.wait_for(asyncio.gather(holder, follower), timeout=10) + + # Two writes total -- the holder and the follower. The cancelled save contributed + # none, and crucially did not stall the follower behind it. + assert len(written) == 2 + assert (await storage.load("shared-id")).workflow_name == "follower" + assert not registry, "a cancelled save left its destination entry behind" + + +def test_file_checkpoint_storage_serializes_across_event_loops(monkeypatch, tmp_path): + """Saves driven from separate event loops must still serialize per destination. + + Reviewer concern on #7757: ownership has to be established by something that is not + bound to a loop, or two workers queued from different loops can reach `os.replace` + together. The queue hands ownership over a `concurrent.futures.Future`, which is not + tied to any loop, so a single chain orders both. Awaited through `_wait_for_signal` + -- a per-wait future fed by a done-callback -- and deliberately never through + `asyncio.wrap_future`, which would chain a waiter's cancellation into the shared + signal an earlier writer still has to resolve. + + Deliberately not an async test: it needs two real loops running at once. + """ + import threading + import time + + from agent_framework._workflows import _checkpoint as checkpoint_module + + real_replace = checkpoint_module.os.replace + inside_replace = 0 + max_inside_replace = 0 + completed: list[str] = [] + guard = threading.Lock() + both_enqueued = threading.Barrier(2, timeout=10) + + def observing_replace(src, dst): # noqa: ANN001, ANN202 - test shim + nonlocal inside_replace, max_inside_replace + with guard: + inside_replace += 1 + max_inside_replace = max(max_inside_replace, inside_replace) + try: + # Widen the window a real overlap would land in. + time.sleep(0.05) + real_replace(src, dst) + finally: + with guard: + inside_replace -= 1 + completed.append(os.path.basename(str(dst))) + + monkeypatch.setattr(checkpoint_module.os, "replace", observing_replace) + + errors: list[BaseException] = [] + + def run_loop(name: str) -> None: + async def main() -> None: + storage = FileCheckpointStorage(str(tmp_path)) + # Make both loops reach save() at the same time so the queue, not timing, + # is what orders them. + await asyncio.to_thread(both_enqueued.wait) + await storage.save( + WorkflowCheckpoint( + workflow_name=name, + graph_signature_hash="test-hash", + checkpoint_id="cross-loop-id", + ) + ) + + try: + asyncio.run(main()) + except BaseException as exc: # noqa: BLE001 - surfaced to the test below + errors.append(exc) + + threads = [threading.Thread(target=run_loop, args=(f"loop-{index}",)) for index in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=30) + assert not thread.is_alive(), "a save driven from its own event loop never finished" + + assert not errors, f"saves raised: {errors!r}" + assert len(completed) == 2 + assert max_inside_replace == 1, f"{max_inside_replace} writes reached os.replace together across event loops" + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + assert not registry, "cross-loop saves left a destination entry behind" + + +async def test_file_checkpoint_storage_repeated_cancellation_still_drains(monkeypatch): + """Cancelling again while a save is draining must not abandon the in-flight write. + + Once a task has a cancellation pending, its next await raises `CancelledError` + immediately -- so a drain built on a single `await asyncio.shield(worker)` returns + with the worker still running and the write can land after ownership is released. + A task group or supervisor that cancels more than once reaches exactly that path, + so the drain absorbs re-delivered cancellations until the worker is genuinely done. + """ + import threading + + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + real_replace = checkpoint_module.os.replace + replace_started = threading.Event() + release_replace = threading.Event() + finished_writes: list[str] = [] + guard = threading.Lock() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + replace_started.set() + assert release_replace.wait(timeout=10) + real_replace(src, dst) + with guard: + finished_writes.append(os.path.basename(str(dst))) + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + task = asyncio.create_task( + storage.save( + WorkflowCheckpoint( + workflow_name="drained", + graph_signature_hash="test-hash", + checkpoint_id="shared-id", + ) + ) + ) + assert await asyncio.to_thread(replace_started.wait, 10) + + # Cancel repeatedly while the worker is parked. Each one is re-delivered into + # the drain, which must keep waiting rather than return early. + for _ in range(5): + task.cancel() + await asyncio.sleep(0.01) + assert not task.done(), "drain gave up while the write was still in flight" + assert finished_writes == [] + + release_replace.set() + with pytest.raises(asyncio.CancelledError): + await task + + # The write it owned completed before it propagated, so nothing lands later. + assert finished_writes == ["shared-id.json"] + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + assert not registry + + +async def test_file_checkpoint_storage_write_failing_during_drain_is_reported(monkeypatch, caplog): + """A write that fails while draining must surface in the log, not vanish. + + The cancelled caller never sees the write's exception -- it receives + `CancelledError` -- so the only place a failure can be noticed is the log. + """ + import threading + + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + replace_started = threading.Event() + release_replace = threading.Event() + + def failing_replace(src, dst): # noqa: ANN001, ANN202 - test shim + replace_started.set() + assert release_replace.wait(timeout=10) + raise OSError("disk went away mid-publish") + + monkeypatch.setattr(checkpoint_module.os, "replace", failing_replace) + + task = asyncio.create_task( + storage.save( + WorkflowCheckpoint( + workflow_name="failing", + graph_signature_hash="test-hash", + checkpoint_id="shared-id", + ) + ) + ) + assert await asyncio.to_thread(replace_started.wait, 10) + + task.cancel() + await asyncio.sleep(0.01) + with caplog.at_level(logging.WARNING, logger=checkpoint_module.logger.name): + release_replace.set() + with pytest.raises(asyncio.CancelledError): + await task + + assert any("failed while draining after cancellation" in record.message for record in caplog.records), ( + f"the drained write's failure was not reported: {[r.message for r in caplog.records]}" + ) + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + assert not registry, "a failed drained write left its destination entry behind" + + +def test_release_write_tolerates_an_already_dropped_entry(): + """Releasing a ticket whose entry is gone must be a no-op, not a KeyError. + + Defensive: the registry entry is dropped by whichever operation releases last, so a + release must never assume its entry is still present. + """ + from agent_framework._workflows import _checkpoint as checkpoint_module + + path = Path("/nonexistent/never-enqueued.json") + ticket = checkpoint_module._WriteTicket( # pyright: ignore[reportPrivateUsage] + path=path, + predecessor=None, + completion=ConcurrentFuture(), + ) + # No entry was ever created for this path. + checkpoint_module._release_write(ticket) # pyright: ignore[reportPrivateUsage] + assert ticket.completion.done() + assert path not in checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + + +def test_file_checkpoint_storage_abandoned_loop_does_not_own_destination_forever(monkeypatch, tmp_path): + """A loop that goes away mid-write must not leave its destination owned forever. + + Ownership is released as the write's last act on the worker thread, not only in the + coroutine's `finally`. A thread outlives the loop that submitted it, so the write + finishes and hands the destination on; releasing solely from the coroutine would + leave the path owned for the life of the process and hang every later save to it. + + Deliberately not an async test: it has to close a loop out from under a pending task. + """ + import threading + import time + + from agent_framework._workflows import _checkpoint as checkpoint_module + + real_replace = checkpoint_module.os.replace + parked = threading.Event() + release_parked = threading.Event() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + parked.set() + assert release_parked.wait(timeout=20) + real_replace(src, dst) + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="abandoned-id") + + abandoned_loop = asyncio.new_event_loop() + # Closing a loop with a pending task makes asyncio report "Task was destroyed but it + # is pending!" through the loop's exception handler. That is exactly the situation + # under test, so silence it rather than leaving the noise in CI output. + abandoned_loop.set_exception_handler(lambda loop, context: None) + try: + storage = FileCheckpointStorage(str(tmp_path)) + + async def start_and_park() -> asyncio.Task[str]: + task = abandoned_loop.create_task(storage.save(make("abandoned"))) + await asyncio.to_thread(parked.wait, 20) + return task + + pending = abandoned_loop.run_until_complete(start_and_park()) + assert not pending.done() + finally: + # Abrupt: no cancellation, so the coroutine's `finally` never runs. + abandoned_loop.close() + + # Let the orphaned worker finish. Its release happens on the thread. + release_parked.set() + deadline = time.monotonic() + 10 + while checkpoint_module._destination_queues and time.monotonic() < deadline: # pyright: ignore[reportPrivateUsage] + time.sleep(0.05) + assert not checkpoint_module._destination_queues, ( # pyright: ignore[reportPrivateUsage] + "the abandoned loop's destination entry was never released" + ) + + # A fresh loop must be able to save to the same destination. + outcome: dict[str, bool] = {} + + def run_later_save() -> None: + async def main() -> None: + later_storage = FileCheckpointStorage(str(tmp_path)) + try: + await asyncio.wait_for(later_storage.save(make("later")), timeout=10) + outcome["saved"] = True + except asyncio.TimeoutError: + outcome["saved"] = False + + asyncio.run(main()) + + thread = threading.Thread(target=run_later_save) + thread.start() + thread.join(timeout=30) + assert not thread.is_alive() + assert outcome.get("saved") is True, "a later save to the abandoned destination hung" + + +async def test_file_checkpoint_storage_failed_save_releases_the_destination(monkeypatch): + """A save that raises must not leave its destination owned. + + The failure path matters as much as cancellation: if a raising write kept ownership, + one transient disk error would hang every later save to that checkpoint for the life + of the process. + """ + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + def exploding_replace(src, dst): # noqa: ANN001, ANN202 - test shim + raise OSError("simulated disk failure") + + monkeypatch.setattr(checkpoint_module.os, "replace", exploding_replace) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="shared-id") + + with pytest.raises(OSError, match="simulated disk failure"): + await storage.save(make("fails")) + + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + assert not registry, "a failed save kept its destination" + + # And the path is still usable once the failure clears. + monkeypatch.undo() + await asyncio.wait_for(storage.save(make("after-failure")), timeout=10) + assert (await storage.load("shared-id")).workflow_name == "after-failure" + assert not registry + + +async def test_file_checkpoint_storage_cancelling_a_queued_save_does_not_let_the_next_overtake(monkeypatch): + """A save cancelled while queued must not hand off before its predecessor finishes. + + Reviewer concern on #7757: `asyncio.wrap_future` chains cancellation into the future + it wraps, so cancelling the middle of three queued saves cancelled the shared + hand-off signal, and the unconditional release then let the third save reach + `os.replace` while the first still owned the destination -- after which the first + write could land last and overwrite the newer checkpoint. The hand-off for a + cancelled ticket is now deferred until its predecessor actually completes. + """ + import threading + + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + real_replace = checkpoint_module.os.replace + holder_parked = threading.Event() + release_holder = threading.Event() + inside_replace = 0 + max_inside_replace = 0 + completed: list[str] = [] + guard = threading.Lock() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + nonlocal inside_replace, max_inside_replace + with guard: + inside_replace += 1 + max_inside_replace = max(max_inside_replace, inside_replace) + park = inside_replace == 1 and not holder_parked.is_set() + try: + if park: + holder_parked.set() + assert release_holder.wait(timeout=15) + real_replace(src, dst) + with guard: + completed.append(os.path.basename(str(dst))) + finally: + with guard: + inside_replace -= 1 + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="shared-id") + + holder = asyncio.create_task(storage.save(make("holder"))) + assert await asyncio.to_thread(holder_parked.wait, 15) + + queued = asyncio.create_task(storage.save(make("cancelled"))) + third = asyncio.create_task(storage.save(make("third"))) + + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + canonical = (Path(temp_dir) / "shared-id.json").resolve() + for _ in range(400): + entry = registry.get(canonical) + if entry is not None and entry.pending == 3: + break + await asyncio.sleep(0.005) + entry = registry.get(canonical) + assert entry is not None and entry.pending == 3 + + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + + # The holder is still parked inside its replace. The third save must not have + # taken the destination on the back of the cancellation. + for _ in range(30): + await asyncio.sleep(0.01) + with guard: + assert max_inside_replace == 1, "a second write started while the holder still owned the path" + assert completed == [], "a write completed while the holder was still parked" + assert not third.done() + + release_holder.set() + await asyncio.wait_for(asyncio.gather(holder, third), timeout=15) + + with guard: + assert max_inside_replace == 1, "writes overlapped for one destination" + assert len(completed) == 2, f"expected holder + third, got {completed}" + assert (await storage.load("shared-id")).workflow_name == "third" + assert not registry, "a cancelled queued save leaked its destination entry" + + +def test_file_checkpoint_storage_cancelled_worker_task_does_not_release_early(monkeypatch, tmp_path): + """Cancelling the worker task must not release ownership while the thread writes. + + Reviewer concern on #7757: loop shutdown cancels `save()` and the task wrapping + `asyncio.to_thread`, which makes `worker.done()` true while the function is still + blocked in `os.replace`. Draining on that task therefore returned immediately and + ownership was released with the write in flight. The drain now waits on the signal + the worker thread resolves, which cancellation cannot mark done. + + Deliberately not an async test: it cancels every task the way shutdown does. + """ + import threading + import time + + from agent_framework._workflows import _checkpoint as checkpoint_module + + real_replace = checkpoint_module.os.replace + parked = threading.Event() + release_parked = threading.Event() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + parked.set() + assert release_parked.wait(timeout=15) + real_replace(src, dst) + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + canonical = (tmp_path / "shared-id.json").resolve() + observed: dict[str, bool] = {} + + async def main() -> None: + storage = FileCheckpointStorage(str(tmp_path)) + task = asyncio.create_task( + storage.save( + WorkflowCheckpoint(workflow_name="holder", graph_signature_hash="test-hash", checkpoint_id="shared-id") + ) + ) + await asyncio.to_thread(parked.wait, 15) + + # What loop shutdown does: cancel every remaining task, including the one + # wrapping asyncio.to_thread. + for pending in [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]: + pending.cancel() + + for _ in range(40): + await asyncio.sleep(0.01) + if canonical not in checkpoint_module._destination_queues: # pyright: ignore[reportPrivateUsage] + break + # The write is still parked, so the destination must still be owned. + observed["released_early"] = canonical not in checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + observed["still_writing"] = not release_parked.is_set() + + release_parked.set() + await asyncio.gather(task, return_exceptions=True) + + asyncio.run(main()) + + assert observed["still_writing"], "the test never held the write open" + assert not observed["released_early"], "ownership was released while the write was still in flight" + # The worker thread releases once its write lands, so nothing is left owned. + deadline = time.monotonic() + 10 + while checkpoint_module._destination_queues and time.monotonic() < deadline: # pyright: ignore[reportPrivateUsage] + time.sleep(0.05) + assert not checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + + +def test_file_checkpoint_storage_shutdown_before_the_write_starts_does_not_hang(monkeypatch, tmp_path): + """Loop shutdown before the write reaches the executor must not strand the save. + + Submitting through `asyncio.ensure_future(asyncio.to_thread(...))` makes the write a + Task, and shutdown cancels every task -- potentially before it has reached the + executor. Nothing would then release the destination, and a coroutine waiting for the + write's completion signal would wait for a signal that could never be resolved, so + the save never finished at all. Submission now goes through `run_in_executor`, which + returns a plain Future that `asyncio.all_tasks()` does not include, and a done + callback releases even if the executor drops the work. + + Deliberately not an async test: it has to cancel every task the way shutdown does. + """ + pending: list[Any] = [] + + from agent_framework._workflows import _checkpoint as checkpoint_module + + canonical = (tmp_path / "shared-id.json").resolve() + outcome: dict[str, object] = {} + + # Hold the submitted write at its first syscall. Without this the count below races the + # filesystem: `await asyncio.sleep(0)` drains every ready callback, so on a fast tmpfs the + # executor write can finish and resolve the shield inside that same batch, leaving the save + # already done and `all_tasks()` empty. CI saw `found 0 tasks` that way. Gating keeps the save + # deterministically suspended while still being the "write submitted but not started" case. + write_reached = threading.Event() + release_write = threading.Event() + real_open = checkpoint_module.os.open + + def gated_open(path, *args, **kwargs): # noqa: ANN001, ANN002, ANN003, ANN202 - test shim + # `checkpoint_module.os` is the real `os` module, so this patch is process-wide. + # Gate only this save's temp file: blocking every `os.open` would stall pytest's own + # I/O and coverage writes on `release_write` and deadlock the run. + if str(path).endswith(".tmp") and ".maf-ckpt-" in str(path): + write_reached.set() + assert release_write.wait(timeout=25) + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(checkpoint_module.os, "open", gated_open) + + async def main() -> None: + storage = FileCheckpointStorage(str(tmp_path)) + # Held for the test's lifetime on purpose. An unreferenced pending task is + # collectable at any moment, and this one's loop is deliberately abandoned, so + # letting GC decide when its finalizer touches that loop makes the test + # nondeterministic -- it crashed a Windows CI worker that way. + pending.append( + asyncio.create_task( + storage.save( + WorkflowCheckpoint( + workflow_name="victim", graph_signature_hash="test-hash", checkpoint_id="shared-id" + ) + ) + ) + ) + # Wait for the write to actually reach the executor rather than assuming one tick did + # it. Cancelling before submission takes the clean cancelled-before-submission path, + # which was never the broken case. + assert await asyncio.to_thread(write_reached.wait, 25) + victims = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] + outcome["task_count"] = len(victims) + + # Assert the structural property *before* cancelling anything. If the write is a + # task, shutdown sweeps it and the save can end up waiting for a completion + # signal nothing will ever resolve -- which hangs `asyncio.run`'s own shutdown, + # outside any `wait_for` this test could wrap around it. Failing here keeps the + # regression a clean assertion rather than a hung CI job. + # Two distinct failures, kept apart so the message identifies which one happened: an + # empty list means the gate did not hold and the save finished early, which is a + # problem with this test rather than with the code; two tasks means the write is a + # task again, which is the regression. + try: + assert victims, "the save should still be in flight while the write is held at os.open" + assert len(victims) == 1, ( + f"the write must not be a task, or loop shutdown cancels it: found {len(victims)} tasks" + ) + except AssertionError: + # Let the held worker go before propagating, or a failing assertion stalls for the + # gate's full timeout before the real error surfaces. + release_write.set() + raise + + for task in victims: + task.cancel() + # Let the held write proceed only after the cancellation is delivered, so the drain + # path is the one under test. Released here rather than in a `finally` because the + # worker must outlive this coroutine for the release-on-the-worker-thread guarantee + # to mean anything. + release_write.set() + await asyncio.wait_for(asyncio.gather(*victims, return_exceptions=True), timeout=10) + + asyncio.run(main()) + assert outcome["task_count"] == 1 + + deadline = time.monotonic() + 10 + while canonical in checkpoint_module._destination_queues and time.monotonic() < deadline: # pyright: ignore[reportPrivateUsage] + time.sleep(0.05) + assert canonical not in checkpoint_module._destination_queues, ( # pyright: ignore[reportPrivateUsage] + "shutdown before the write started left the destination owned forever" + ) + + +async def test_file_checkpoint_storage_executor_shutdown_at_submission_releases(): + """If the write cannot be submitted at all, the coroutine must release the destination. + + `run_in_executor` raises synchronously against a shut-down executor, so no worker + exists to release on our behalf. This is the one path where the coroutine, not the + worker thread, owns the release. + """ + from concurrent.futures import ThreadPoolExecutor + + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + dead_executor = ThreadPoolExecutor(max_workers=1) + dead_executor.shutdown(wait=True) + loop: Any = asyncio.get_running_loop() + previous_executor = loop._default_executor + loop.set_default_executor(dead_executor) + try: + with pytest.raises(RuntimeError): + await storage.save( + WorkflowCheckpoint( + workflow_name="no-executor", + graph_signature_hash="test-hash", + checkpoint_id="shared-id", + ) + ) + finally: + loop._default_executor = previous_executor + + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + assert not registry, "a save that could not be submitted kept its destination" + + # The path is still usable once an executor is available again. + await asyncio.wait_for( + storage.save( + WorkflowCheckpoint(workflow_name="after", graph_signature_hash="test-hash", checkpoint_id="shared-id") + ), + timeout=10, + ) + assert (await storage.load("shared-id")).workflow_name == "after" + + +def test_wait_for_signal_survives_its_loop_being_closed(): + """Resolving a signal after its waiter's loop closed must not raise. + + The worker thread outlives the loop that submitted the write, so it can resolve a + hand-off signal that a now-dead loop was waiting on. `call_soon_threadsafe` raises + `RuntimeError` on a closed loop, and that runs inside a `concurrent.futures` + done-callback -- where an exception would be swallowed into the callback machinery + rather than surfacing usefully. + """ + import threading + from concurrent.futures import Future as ConcurrentFuture + + from agent_framework._workflows import _checkpoint as checkpoint_module + + source: ConcurrentFuture[None] = ConcurrentFuture() + loop = asyncio.new_event_loop() + try: + + async def register() -> None: + checkpoint_module._wait_for_signal(source) # pyright: ignore[reportPrivateUsage] + + loop.run_until_complete(register()) + finally: + loop.close() + + # The worker thread resolves it after the loop is gone. + errors: list[BaseException] = [] + + def resolve() -> None: + try: + source.set_result(None) + except BaseException as exc: # noqa: BLE001 - surfaced below + errors.append(exc) + + thread = threading.Thread(target=resolve) + thread.start() + thread.join(timeout=10) + assert not thread.is_alive() + assert not errors, f"resolving after loop close raised: {errors!r}" + assert source.done() + + +def test_await_signal_through_cancellation_survives_its_loop_being_closed(): + """Resolving the signal after the draining loop closed must not raise. + + The worker thread outlives the loop that submitted the write, so it can resolve a + signal that a now-dead loop was draining on. `call_soon_threadsafe` raises + `RuntimeError` against a closed loop, inside a `concurrent.futures` done-callback + where it would be swallowed rather than surface. + """ + import threading + from concurrent.futures import Future as ConcurrentFuture + + from agent_framework._workflows import _checkpoint as checkpoint_module + + source: ConcurrentFuture[None] = ConcurrentFuture() + loop = asyncio.new_event_loop() + loop.set_exception_handler(lambda active_loop, context: None) + try: + # Start the drain, let it subscribe and suspend, then abandon the loop. + drain = loop.create_task( + checkpoint_module._await_signal_through_cancellation(source) # pyright: ignore[reportPrivateUsage] + ) + loop.run_until_complete(asyncio.sleep(0)) + assert not drain.done() + finally: + loop.close() + + errors: list[BaseException] = [] + + def resolve() -> None: + try: + source.set_result(None) + except BaseException as exc: # noqa: BLE001 - surfaced below + errors.append(exc) + + thread = threading.Thread(target=resolve) + thread.start() + thread.join(timeout=10) + assert not thread.is_alive() + assert not errors, f"resolving after the draining loop closed raised: {errors!r}" + assert source.done() + + +def test_release_write_does_not_recurse_per_deferred_link(): + """A long run of deferred releases must not nest one stack frame per link. + + A save cancelled while queued defers its hand-off onto its predecessor, so resolving + the first ticket runs the second's callback, which resolves the third, and so on. Done + naively that is synchronous recursion: a run longer than the recursion limit raised + `RecursionError` on the worker thread partway through and left the destination owned + for the life of the process. The chain is drained in a loop instead. + + Built from tickets directly so the depth can exceed the recursion limit without + thousands of real saves. + """ + from concurrent.futures import Future as ConcurrentFuture + + from agent_framework._workflows import _checkpoint as checkpoint_module + + depth = 2000 + assert depth > sys.getrecursionlimit(), "the run has to be longer than the recursion limit to prove anything" + + path = Path("/nonexistent/deferred-chain.json") + tickets = [ + checkpoint_module._WriteTicket( # pyright: ignore[reportPrivateUsage] + path=path, + predecessor=None, + completion=ConcurrentFuture(), + ) + for _ in range(depth) + ] + # Each link releases only once the one before it has. + for earlier, later in zip(tickets, tickets[1:]): + checkpoint_module._release_write_after(later, earlier.completion) # pyright: ignore[reportPrivateUsage] + + # Releasing the head must unwind the whole run. + checkpoint_module._release_write(tickets[0]) # pyright: ignore[reportPrivateUsage] + + unresolved = [index for index, ticket in enumerate(tickets) if not ticket.completion.done()] + assert not unresolved, f"{len(unresolved)} links never released, first at index {unresolved[0]}" + assert all(ticket.released for ticket in tickets) + + +def test_file_checkpoint_storage_abandoned_loop_while_queued_releases_the_destination(monkeypatch, tmp_path): + """A save whose loop dies while it is still queued must not strand the destination. + + Reviewer follow-up on #7757: the submitted write is released by its worker thread, but + a ticket still *waiting* for its predecessor has no worker. If that waiter's loop + closes, `call_soon_threadsafe` raises and the coroutine never resumes, so nothing + resolves its hand-off signal and every later save for the destination waits forever. + The predecessor's callback now performs the release itself in that case. + + Deliberately not an async test: it needs two loops and has to close one of them. + """ + pending: list[Any] = [] + + import threading + import time + + from agent_framework._workflows import _checkpoint as checkpoint_module + + real_replace = checkpoint_module.os.replace + parked = threading.Event() + release_parked = threading.Event() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + parked.set() + assert release_parked.wait(timeout=20) + real_replace(src, dst) + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="shared-id") + + canonical = (tmp_path / "shared-id.json").resolve() + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + + holder_loop = asyncio.new_event_loop() + queued_loop = asyncio.new_event_loop() + queued_loop.set_exception_handler(lambda loop, context: None) + try: + holder_storage = FileCheckpointStorage(str(tmp_path)) + + async def start_holder() -> asyncio.Task[str]: + task = holder_loop.create_task(holder_storage.save(make("holder"))) + await asyncio.to_thread(parked.wait, 20) + return task + + holder = holder_loop.run_until_complete(start_holder()) + + # A second save queues behind the parked holder on its own loop. + queued_storage = FileCheckpointStorage(str(tmp_path)) + + async def start_queued() -> None: + # Held for the test's lifetime on purpose. An unreferenced pending task is + # collectable at any moment, and this one's loop is deliberately abandoned, so + # letting GC decide when its finalizer touches that loop makes the test + # nondeterministic -- it crashed a Windows CI worker that way. + pending.append(queued_loop.create_task(queued_storage.save(make("queued")))) + for _ in range(400): + entry = registry.get(canonical) + if entry is not None and entry.pending == 2: + return + await asyncio.sleep(0.005) + raise AssertionError("the second save never queued behind the holder") + + queued_loop.run_until_complete(start_queued()) + finally: + # Abandoned while its ticket is still queued and nothing has been submitted. + queued_loop.close() + + release_parked.set() + holder_loop.run_until_complete(asyncio.gather(holder, return_exceptions=True)) + holder_loop.close() + + deadline = time.monotonic() + 10 + while canonical in registry and time.monotonic() < deadline: + time.sleep(0.05) + assert canonical not in registry, "the abandoned queued save left the destination owned" + + # And the destination is usable again from a fresh loop. + outcome: dict[str, bool] = {} + + def later_save() -> None: + async def main() -> None: + storage = FileCheckpointStorage(str(tmp_path)) + try: + await asyncio.wait_for(storage.save(make("later")), timeout=10) + outcome["saved"] = True + except asyncio.TimeoutError: + outcome["saved"] = False + + asyncio.run(main()) + + thread = threading.Thread(target=later_save) + thread.start() + thread.join(timeout=30) + assert not thread.is_alive() + assert outcome.get("saved") is True, "a later save to the abandoned destination hung" + + +def test_file_checkpoint_storage_graceful_shutdown_releases_a_queued_save(tmp_path, monkeypatch): + """A queued save released through the normal shutdown path, which is the guarantee. + + `asyncio.run` cancels pending tasks before closing, so a save still waiting for its + predecessor takes its cancellation path and defers the hand-off onto that + predecessor. Pinning it because the `on_abandoned` hook only covers a loop that is + already closed when the signal resolves -- this is the path that has to stay safe + without it. + """ + pending: list[Any] = [] + + import threading + import time + + from agent_framework._workflows import _checkpoint as checkpoint_module + + real_replace = checkpoint_module.os.replace + parked = threading.Event() + release_parked = threading.Event() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + parked.set() + assert release_parked.wait(timeout=20) + real_replace(src, dst) + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="shared-id") + + canonical = (tmp_path / "shared-id.json").resolve() + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + + holder_loop = asyncio.new_event_loop() + try: + holder_storage = FileCheckpointStorage(str(tmp_path)) + + async def start_holder() -> asyncio.Task[str]: + task = holder_loop.create_task(holder_storage.save(make("holder"))) + await asyncio.to_thread(parked.wait, 20) + return task + + holder = holder_loop.run_until_complete(start_holder()) + + # A second save queues behind it, on a loop that exits the normal way. + def run_queued() -> None: + async def main() -> None: + storage = FileCheckpointStorage(str(tmp_path)) + # Held for the test's lifetime on purpose. An unreferenced pending task is + # collectable at any moment, and this one's loop is deliberately abandoned, so + # letting GC decide when its finalizer touches that loop makes the test + # nondeterministic -- it crashed a Windows CI worker that way. + pending.append(asyncio.create_task(storage.save(make("queued")))) + for _ in range(400): + entry = registry.get(canonical) + if entry is not None and entry.pending == 2: + return + await asyncio.sleep(0.005) + raise AssertionError("the second save never queued behind the holder") + + asyncio.run(main()) + + thread = threading.Thread(target=run_queued) + thread.start() + thread.join(timeout=30) + assert not thread.is_alive() + + release_parked.set() + holder_loop.run_until_complete(asyncio.gather(holder, return_exceptions=True)) + finally: + holder_loop.close() + + deadline = time.monotonic() + 10 + while canonical in registry and time.monotonic() < deadline: + time.sleep(0.05) + assert canonical not in registry, "a gracefully cancelled queued save left the destination owned" + + +def test_file_checkpoint_storage_abandonment_mid_chain_keeps_the_queue_ordered(tmp_path, monkeypatch): + """Releasing an abandoned queued ticket must hand off to its successor, in order. + + The two-ticket case only shows the entry clearing. With a live save queued *behind* + the abandoned one, the release has to propagate through that link and still serialize + the writes -- if it handed off early, the successor's `os.replace` would run beside + the holder's. + """ + pending: list[Any] = [] + + import threading + import time + + from agent_framework._workflows import _checkpoint as checkpoint_module + + real_replace = checkpoint_module.os.replace + parked = threading.Event() + release_parked = threading.Event() + inside_replace = 0 + peak_inside_replace = 0 + completed: list[str] = [] + guard = threading.Lock() + + def gated_replace(src, dst): # noqa: ANN001, ANN202 - test shim + nonlocal inside_replace, peak_inside_replace + with guard: + inside_replace += 1 + peak_inside_replace = max(peak_inside_replace, inside_replace) + park = inside_replace == 1 and not parked.is_set() + try: + if park: + parked.set() + assert release_parked.wait(timeout=25) + real_replace(src, dst) + with guard: + completed.append(os.path.basename(str(dst))) + finally: + with guard: + inside_replace -= 1 + + monkeypatch.setattr(checkpoint_module.os, "replace", gated_replace) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="shared-id") + + canonical = (tmp_path / "shared-id.json").resolve() + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + + def wait_for_pending(count: int, timeout: float = 20.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + entry = registry.get(canonical) + if entry is not None and entry.pending == count: + return + time.sleep(0.005) + entry = registry.get(canonical) + raise AssertionError(f"expected {count} queued, saw {entry.pending if entry else 0}") + + holder_loop = asyncio.new_event_loop() + abandoned_loop = asyncio.new_event_loop() + abandoned_loop.set_exception_handler(lambda loop, context: None) + successor: dict[str, bool] = {} + try: + holder_storage = FileCheckpointStorage(str(tmp_path)) + + async def start_holder() -> asyncio.Task[str]: + task = holder_loop.create_task(holder_storage.save(make("holder"))) + await asyncio.to_thread(parked.wait, 25) + return task + + holder = holder_loop.run_until_complete(start_holder()) + + async def start_abandoned() -> None: + storage = FileCheckpointStorage(str(tmp_path)) + # Held for the test's lifetime on purpose. An unreferenced pending task is + # collectable at any moment, and this one's loop is deliberately abandoned, so + # letting GC decide when its finalizer touches that loop makes the test + # nondeterministic -- it crashed a Windows CI worker that way. + pending.append(abandoned_loop.create_task(storage.save(make("abandoned")))) + await asyncio.sleep(0) + + abandoned_loop.run_until_complete(start_abandoned()) + wait_for_pending(2) + + def run_successor() -> None: + async def main() -> None: + storage = FileCheckpointStorage(str(tmp_path)) + try: + await asyncio.wait_for(storage.save(make("successor")), timeout=25) + successor["saved"] = True + except asyncio.TimeoutError: + successor["saved"] = False + + asyncio.run(main()) + + thread = threading.Thread(target=run_successor) + thread.start() + wait_for_pending(3) + + abandoned_loop.close() + + release_parked.set() + holder_loop.run_until_complete(asyncio.gather(holder, return_exceptions=True)) + thread.join(timeout=30) + assert not thread.is_alive() + finally: + holder_loop.close() + + assert successor.get("saved") is True, "the save queued behind the abandoned one never ran" + with guard: + assert peak_inside_replace == 1, "writes overlapped across the abandoned link" + assert len(completed) == 2, f"expected holder + successor, got {completed}" + deadline = time.monotonic() + 10 + while canonical in registry and time.monotonic() < deadline: + time.sleep(0.05) + assert canonical not in registry + + +async def test_file_checkpoint_storage_replace_retry_gives_up_and_releases(monkeypatch): + """A destination that never becomes replaceable must fail loudly, not silently or forever. + + The retry around ``os.replace`` exists for a Windows-specific transient: an indexer or + AV scan briefly holding a handle to the destination. It is bounded on purpose -- a + handle held for good has to surface as an error rather than a hang -- and giving up + must still release the destination, or one stuck file would wedge every later save to + it for the life of the process. + + Both halves are asserted here because the suite only ever reached this code by + accident: the concurrency tests occasionally trip a real ``PermissionError`` on + Windows, which is environment-dependent and absent on Linux CI. + """ + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + attempts: list[int] = [] + real_replace = checkpoint_module.os.replace + + def always_locked(src, dst): # noqa: ANN001, ANN202 - test shim + attempts.append(1) + raise PermissionError("simulated handle held by another process") + + monkeypatch.setattr(checkpoint_module.os, "replace", always_locked) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="locked-id") + + with pytest.raises(PermissionError, match="simulated handle held"): + await storage.save(make("never-lands")) + + assert len(attempts) == 5, f"expected the bounded retry to stop at 5 attempts, got {len(attempts)}" + + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + assert not registry, "giving up on the replace kept the destination owned" + + # No temp file survives the failure, and the path works once the lock clears. + leftovers = await asyncio.to_thread(lambda: list(Path(temp_dir).glob(".maf-ckpt-*.tmp"))) + assert not leftovers, f"the abandoned write leaked temp files: {leftovers}" + + monkeypatch.setattr(checkpoint_module.os, "replace", real_replace) + await asyncio.wait_for(storage.save(make("after-the-lock")), timeout=10) + assert (await storage.load("locked-id")).workflow_name == "after-the-lock" + assert not registry + + +async def test_file_checkpoint_storage_temp_cleanup_failure_does_not_mask_the_write_error(monkeypatch, caplog): + """A temp file that cannot be removed must not replace the error that stranded it. + + The cleanup runs in a ``finally`` while a write exception is propagating. Letting an + ``OSError`` out of it there would substitute a misleading "cannot remove temp file" + for the real disk failure the caller needs to see, and would skip the release that + keeps the destination usable. + """ + from agent_framework._workflows import _checkpoint as checkpoint_module + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + def exploding_replace(src, dst): # noqa: ANN001, ANN202 - test shim + raise OSError("the real disk failure") + + real_unlink = Path.unlink + + def refuse_temp_unlink(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003, ANN202 - test shim + if self.name.startswith(".maf-ckpt-"): + raise OSError("temp file is not removable either") + return real_unlink(self, *args, **kwargs) + + monkeypatch.setattr(checkpoint_module.os, "replace", exploding_replace) + monkeypatch.setattr(Path, "unlink", refuse_temp_unlink) + + def make(name: str) -> WorkflowCheckpoint: + return WorkflowCheckpoint(workflow_name=name, graph_signature_hash="test-hash", checkpoint_id="masked-id") + + with ( + caplog.at_level(logging.DEBUG, logger=checkpoint_module.logger.name), + pytest.raises(OSError, match="the real disk failure"), + ): + await storage.save(make("fails")) + + assert any("Failed to remove checkpoint temp file" in record.message for record in caplog.records), ( + "the swallowed cleanup failure left no diagnostic behind" + ) + + registry = checkpoint_module._destination_queues # pyright: ignore[reportPrivateUsage] + assert not registry, "a failed cleanup kept the destination owned" + + monkeypatch.undo() + await asyncio.wait_for(storage.save(make("after-failure")), timeout=10) + assert (await storage.load("masked-id")).workflow_name == "after-failure" + + +async def test_await_signal_through_cancellation_returns_immediately_for_a_resolved_signal(): + """The drain must not suspend on a write that already finished. + + Exercised directly because arranging the race -- a cancellation arriving in the + window after the worker resolved the signal but before the coroutine resumes -- is + not deterministic through ``save()``. The fast path only skips registering a callback + that would fire straight back; this pins the behaviour it is allowed to have. + """ + from agent_framework._workflows import _checkpoint as checkpoint_module + + resolved: ConcurrentFuture[None] = ConcurrentFuture() + resolved.set_result(None) + + await asyncio.wait_for( + checkpoint_module._await_signal_through_cancellation(resolved), # pyright: ignore[reportPrivateUsage] + timeout=5, + ) From 070ed9313605181d48635e7de9e66fa73815301d Mon Sep 17 00:00:00 2001 From: WhaleTech Date: Wed, 16 Sep 2026 08:09:49 +0000 Subject: [PATCH 103/108] Python: Isolate Foundry Hosting test state (#8409) Co-authored-by: WhaleTech <304937387+ryo-whaletech@users.noreply.github.com> --- python/packages/foundry_hosting/tests/conftest.py | 13 +++++++++++++ .../foundry_hosting/tests/test_state_store.py | 9 +++++++++ 2 files changed, 22 insertions(+) create mode 100644 python/packages/foundry_hosting/tests/conftest.py diff --git a/python/packages/foundry_hosting/tests/conftest.py b/python/packages/foundry_hosting/tests/conftest.py new file mode 100644 index 00000000000..2ca7446f1a3 --- /dev/null +++ b/python/packages/foundry_hosting/tests/conftest.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Fixtures for Foundry Hosting tests.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def isolate_local_agentserver_state_root(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Give each test an independent local AgentServer state root.""" + monkeypatch.setenv("AGENTSERVER_STATE_ROOT", str(tmp_path / "agentserver-state")) diff --git a/python/packages/foundry_hosting/tests/test_state_store.py b/python/packages/foundry_hosting/tests/test_state_store.py index fc36d00373c..7ae5088a4d9 100644 --- a/python/packages/foundry_hosting/tests/test_state_store.py +++ b/python/packages/foundry_hosting/tests/test_state_store.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. +import os from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -72,6 +74,13 @@ def _platform_context(call_id: str = "call-1", user_id: str = "user-1") -> Found return FoundryAgentRequestContext(call_id=call_id, user_id=user_id) +def test_local_agentserver_state_root_is_test_scoped(tmp_path: Path) -> None: + """Independent tests must not share the local fallback state directory.""" + state_root = Path(os.environ["AGENTSERVER_STATE_ROOT"]) + + assert state_root.is_relative_to(tmp_path) + + def test_storage_providers_use_public_abstraction() -> None: assert issubclass(CheckpointStoreProvider, ContextScopedStoreProvider) assert not issubclass(CheckpointStoreProvider, StoreProvider) From 0fb9e353a58bf2b820283b5e628e58bd2742f438 Mon Sep 17 00:00:00 2001 From: WhaleTech Date: Wed, 16 Sep 2026 08:09:56 +0000 Subject: [PATCH 104/108] Fix WorkflowAgent response for empty streams (#8389) Co-authored-by: WhaleTech <304937387+ryo-whaletech@users.noreply.github.com> --- .../core/agent_framework/_workflows/_agent.py | 3 +- .../tests/workflow/test_workflow_agent.py | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index b05ec391abb..ecfc00265f0 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -424,8 +424,7 @@ async def _run_stream_impl( # Build the final response from collected updates so after_run providers # (e.g. InMemoryHistoryProvider) can persist the response messages. - if all_updates: - session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment] + session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment] await self._run_after_providers(session=provider_session, context=session_context) diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 3374d15d395..bd2c1e6a2df 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -14,6 +14,7 @@ AgentResponseUpdate, AgentSession, Content, + ContextProvider, Executor, FinishReason, HistoryProvider, @@ -21,6 +22,7 @@ Message, ResponseStream, ServiceSessionId, + SessionContext, SupportsAgentRun, UsageDetails, WorkflowAgent, @@ -1220,6 +1222,44 @@ async def test_multi_turn_session_stores_responses_streaming(self) -> None: assert roles[1] == "assistant" assert roles[2] == "user" + async def test_zero_output_stream_populates_response_before_after_run(self) -> None: + """After-run providers receive a response even when a workflow emits no output.""" + + @executor + async def silent(messages: list[Message], ctx: WorkflowContext[Any, str]) -> None: + assert messages[0].text == "hello" + del ctx + + captured: list[AgentResponse | None] = [] + + class CapturingProvider(ContextProvider): + async def after_run( + self, + *, + agent: SupportsAgentRun, + session: AgentSession, + context: SessionContext, + state: dict[str, Any], + ) -> None: + captured.append(context.response) + + workflow = WorkflowBuilder(start_executor=silent, output_from=[silent]).build() + agent = workflow.as_agent(context_providers=[CapturingProvider("capture")]) + + non_stream_response = await agent.run("hello") + stream = agent.run("hello", stream=True) + updates = [update async for update in stream] + stream_response = await stream.get_final_response() + + assert updates == [] + assert non_stream_response.messages == [] + assert stream_response.messages == [] + assert len(captured) == 2 + assert isinstance(captured[0], AgentResponse) + assert captured[0].messages == [] + assert isinstance(captured[1], AgentResponse) + assert captured[1].messages == [] + async def test_multi_turn_session_roundtrip_serialization(self) -> None: """Test that session can be serialized/deserialized and multi-turn still works.""" capturing_executor = ConversationHistoryCapturingExecutor(id="roundtrip_test", streaming=False) From 5391d56de5799a97a6b2891879961880bf38853c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:10:04 +0000 Subject: [PATCH 105/108] Python: Build(deps-dev): bump ty from 0.0.77 to 0.0.78 in /python in the python-type-checkers group (#8249) * Build(deps-dev): bump ty in /python in the python-type-checkers group Bumps the python-type-checkers group in /python with 1 update: [ty](https://github.com/astral-sh/ty). Updates `ty` from 0.0.77 to 0.0.78 - [Release notes](https://github.com/astral-sh/ty/releases) - [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ty/compare/0.0.77...0.0.78) --- updated-dependencies: - dependency-name: ty dependency-version: 0.0.78 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-type-checkers ... Signed-off-by: dependabot[bot] * Fix ty dependency update checks Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> * Fix checkpoint queue release deadlock --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com> Co-authored-by: Eduard van Valkenburg Co-authored-by: moonbox3 <35585003+moonbox3@users.noreply.github.com> Co-authored-by: Evan Mattson --- .../agent_framework/_workflows/_checkpoint.py | 7 ++- python/pyproject.toml | 2 +- .../02-agents/mcp/mcp_long_running_task.py | 2 +- python/uv.lock | 44 +++++++++---------- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index 67b98f2bdb6..a0eaed0797b 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -288,7 +288,12 @@ async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID] # the underlying filesystem for the file not to be seen half-written; which write # survives is undefined. _destination_queues: dict[Path, _DestinationQueue] = {} -_destination_queues_guard = threading.Lock() +# A completed predecessor invokes callbacks synchronously. If cancellation lands while +# a save is entering the queue, its deferred release can therefore run re-entrantly on +# the same thread before the enqueue frame has left this guard. A plain Lock deadlocks +# that release (and permanently wedges every later save), while an RLock still excludes +# all other threads and lets the owning thread finish the hand-off. +_destination_queues_guard = threading.RLock() @dataclass diff --git a/python/pyproject.toml b/python/pyproject.toml index 15f63b0db14..5f7911d1b54 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -40,7 +40,7 @@ dev = [ "mypy==2.3.1", "pyright==1.1.411", "pyrefly==1.2.0", - "ty==0.0.77", + "ty==0.0.78", "zuban==0.9.3", "opentelemetry-sdk", "pandas>=2,<4", diff --git a/python/samples/02-agents/mcp/mcp_long_running_task.py b/python/samples/02-agents/mcp/mcp_long_running_task.py index a4437ff4097..50ce078471b 100644 --- a/python/samples/02-agents/mcp/mcp_long_running_task.py +++ b/python/samples/02-agents/mcp/mcp_long_running_task.py @@ -53,7 +53,7 @@ async def _run_server() -> None: server: Server[Any, Any] = Server("mcp-long-running-task-demo") # Auto-registers handlers for tasks/get, tasks/result, tasks/cancel, tasks/list # backed by an in-memory store. - server.experimental.enable_tasks() + server.experimental.enable_tasks() # ty: ignore[deprecated] @server.list_tools() async def _list_tools() -> list[types.Tool]: # pyright: ignore[reportUnusedFunction] diff --git a/python/uv.lock b/python/uv.lock index 5b144f1dfe0..27bf49e5eb1 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -176,7 +176,7 @@ dev = [ { name = "rich", specifier = ">=13.7.1,<16.0.0" }, { name = "ruff", specifier = "==0.16.6" }, { name = "tomli", specifier = "==2.4.1" }, - { name = "ty", specifier = "==0.0.77" }, + { name = "ty", specifier = "==0.0.78" }, { name = "uv", specifier = "==0.12.9" }, { name = "zuban", specifier = "==0.9.3" }, ] @@ -5850,27 +5850,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.77" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/ef/a2024d1d33d5ba8436677a6fd734f87a137150aba99906fc009f7c5de956/ty-0.0.77.tar.gz", hash = "sha256:8898f3097610f4a772ead6bfad7b204c7d76e8eb3a010c7285b9186278e0fb82", size = 7005734, upload-time = "2026-09-01T00:26:26.901Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/03/930f7454a6d797abc09aebbf537017825ebb08d9f8c2e2d905e74341dbb1/ty-0.0.77-py3-none-linux_armv6l.whl", hash = "sha256:ea76012f1ed387b6fc6500894fb8a7654b724c38713e263a23f12756f00e2469", size = 13241626, upload-time = "2026-09-01T00:25:51.02Z" }, - { url = "https://files.pythonhosted.org/packages/65/41/7e064045775718673851f6c0e1cfde70d6e380b0db9d91d4484a362f2d67/ty-0.0.77-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:237a58c389e01d65c4693e0394feb0f0adea3fba0345c3b932d209084372f3d3", size = 12830037, upload-time = "2026-09-01T00:25:53.172Z" }, - { url = "https://files.pythonhosted.org/packages/5b/64/66f0b0dc89bd239922bdf12fa3e6d066aa7a425e8b70414368c4eb44a6e6/ty-0.0.77-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bbb8ae7a753b9a6af25725c314d0eca967e5fde5eebb7a35b04b0d763dfb9d7c", size = 12612071, upload-time = "2026-09-01T00:25:55.116Z" }, - { url = "https://files.pythonhosted.org/packages/fd/5c/bef8b1f471931a9395792be023613b6cee0ef1449815bf97b18be07f883c/ty-0.0.77-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:603620c063b718ddbe302f83fb0d7c01a13f1941c3b9763512a89c9bbfabba96", size = 12641403, upload-time = "2026-09-01T00:25:57.305Z" }, - { url = "https://files.pythonhosted.org/packages/43/66/e4d32a0145162f79bc3dd6fca4770f88966c3b5206121ffeecd5f7b05e8b/ty-0.0.77-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03f128cb8b204e22a18f4b01ef0194aa445f4edb080cb7d77621cb2ab353885c", size = 13013526, upload-time = "2026-09-01T00:25:59.278Z" }, - { url = "https://files.pythonhosted.org/packages/aa/be/33493ee43d4db7d402ceaee55c5d6c22e2b1105f10b1ac5928c220f79650/ty-0.0.77-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:97d1600ef69fc8e3b2310de1915cd34ba3b712fdc730cb02b713520956baec0a", size = 13828076, upload-time = "2026-09-01T00:26:01.343Z" }, - { url = "https://files.pythonhosted.org/packages/cf/d5/b6335fdc8c09aaaaf6541322076c5a7205fa16c29dbeddc1f24dd89b3894/ty-0.0.77-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dced0924a83b6d945fd48a29aa85e131fb98dc574f4c9a0e7c43d03eff373402", size = 14247770, upload-time = "2026-09-01T00:26:03.388Z" }, - { url = "https://files.pythonhosted.org/packages/11/64/bbe96c1da26585919ac10f33df1d337d9b498013fc60c5178e76955c2a53/ty-0.0.77-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b14b002233a954115a04932b3a93d75a387569848276cf7bb0b5dc6b040001c4", size = 13926528, upload-time = "2026-09-01T00:26:05.587Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e2/cdc94e9a1b69e9b7dd0ec3ad3ca75741245b8b5b2e3ffe9b365ca31d8052/ty-0.0.77-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:581adf1ae1e48e00e89ec162913d559ddf75a7805646a13ffd68943c1b5e8daa", size = 13290834, upload-time = "2026-09-01T00:26:07.912Z" }, - { url = "https://files.pythonhosted.org/packages/92/8c/4929dd8e924ddbd6284dcb3cfbda488bd6cb091e5384f8b7fb5dd3de9122/ty-0.0.77-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9bda523b40e06c643feb6d5d5bcbacc56a2bd9eed3d7cae1119452502bcd69b0", size = 13829048, upload-time = "2026-09-01T00:26:10.093Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f4/441a74ddc5e2db2690264c1ee0e478d46b8e42bd529472fdb1656b63bff2/ty-0.0.77-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4b8b04d8c3af8148e963e950094bd53c8cc4d3a11f5f8764ff8d39627bd6e64a", size = 12803755, upload-time = "2026-09-01T00:26:12.055Z" }, - { url = "https://files.pythonhosted.org/packages/85/39/875bb7092ee1edb090b62eca74b7311f6416dc3aa7da442b4a1f95408251/ty-0.0.77-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:33a9ff9be488edf4d6fac46c456198cc848d9641f418f1a83aa123825e93c6ec", size = 13028244, upload-time = "2026-09-01T00:26:14.212Z" }, - { url = "https://files.pythonhosted.org/packages/e3/76/4b0a48f118dca6a32e1609f2d0bea2c3f7be9051af645fe5044ca35cbbb4/ty-0.0.77-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61ad5467206e5ea6531c8aebebf1276c6150989a94a1d5974a84d9e0eeed5c38", size = 13321068, upload-time = "2026-09-01T00:26:16.418Z" }, - { url = "https://files.pythonhosted.org/packages/8d/46/cd21bc6441df4b221d9e131551bab2a5f9c0b724e60e1c960622ee175128/ty-0.0.77-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1293b94f26d1f330424e3b97c20b434f7e2ac1938287b8588466c75ece6c0b10", size = 13609112, upload-time = "2026-09-01T00:26:18.424Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/0da4537a7eca4eb3ca72a2cb4539cfbb6fb3138d1dc4ae7a66299b941fb3/ty-0.0.77-py3-none-win32.whl", hash = "sha256:cb71152bdf56860375c790682cc3efc120aaf8e36f1cd6367773312905e82b50", size = 12573821, upload-time = "2026-09-01T00:26:20.404Z" }, - { url = "https://files.pythonhosted.org/packages/96/91/aec75b11bfaa5a358f5a505afa6307fc4bee1c5f4c6444e18fa82c25f3be/ty-0.0.77-py3-none-win_amd64.whl", hash = "sha256:86f01d4af8b006c9442a2de0ab949cc24462f8f9431bebc0b73f6166fbbca19f", size = 13154851, upload-time = "2026-09-01T00:26:22.389Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e5/77772f95ff81bf7c817595cd08b52d007acab90dac0f2339faa486e6a525/ty-0.0.77-py3-none-win_arm64.whl", hash = "sha256:942a3bb2a4786c80bd8ef4503e5024046617cdfcc550b56c1acc4817ce7ac144", size = 12992392, upload-time = "2026-09-01T00:26:24.44Z" }, +version = "0.0.78" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/c7/2ba0861384c5b5097ac354383abb98188112cb330208c21d5197e98a29e5/ty-0.0.78.tar.gz", hash = "sha256:770b45854f85fa11595208f08c0f28df80943164d10a2832d86be6ac29f135b2", size = 7050609, upload-time = "2026-09-02T22:41:33.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ed/f34cfc06a9ba72979df219e48dae1a0b6a6c74a340763ccd30a547edf913/ty-0.0.78-py3-none-linux_armv6l.whl", hash = "sha256:122700b98f9d45785c1ce91a9418154562e7f64f4bf34679f6f7b38c5b97453f", size = 13304624, upload-time = "2026-09-02T22:40:56.649Z" }, + { url = "https://files.pythonhosted.org/packages/1c/28/5576e2a08b57676d9b2a736d528f077a6c6e32c3d1de2d75dbbe28346966/ty-0.0.78-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cbe7e3709ccf29ef3d9f58f5914cc30ec36fb647008a5a4ff8483abe401bfe8d", size = 12928383, upload-time = "2026-09-02T22:40:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/027d49c3da5235e634da3d3fc52c4874ec89f50830388c9c259f8fb71e0e/ty-0.0.78-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c3528897b3ab9d3589561bc2b5e61a4d5d686de527a4400bb09a439b922a6c70", size = 12730058, upload-time = "2026-09-02T22:41:01.235Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bc/6bf8ffefd8063730a70ae889cf85def72bc155fd1453135ebf8a09c2f1c2/ty-0.0.78-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bfba4c44a06484093f527b8ef43a317abcf91395b49396170266629eedf74aa", size = 12813321, upload-time = "2026-09-02T22:41:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c0/b3cdf26f82108908d92fdb7ddb741019c446ceb666cf204d4dbf611bde33/ty-0.0.78-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f903c06fdee17baf8373173039ab28f1bce28e66b1c734929a5f50b37eb54d9", size = 13072219, upload-time = "2026-09-02T22:41:05.225Z" }, + { url = "https://files.pythonhosted.org/packages/4d/36/16bfb0abdd178dae11dbc4b57b9a86c2b62642a18c1103fd2c2930aa5879/ty-0.0.78-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd83e5fe3f07291d1bd4e591f8d2607c204f3eab53bcb1b8060c40e876e1aa61", size = 13903958, upload-time = "2026-09-02T22:41:07.333Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0c/74d3b1f0344b13156c719dd68a7edf7e48c2051718c9f326ba3cbf45ea53/ty-0.0.78-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:325285377319cf7168a2b8b771ae5027f411530e3c7eab1faf4583cdd72fd7c9", size = 14355581, upload-time = "2026-09-02T22:41:09.56Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d7/7ca0359e1e61b15b8b02328c8d120db3c507876b9b7c6bd9f26935353e0e/ty-0.0.78-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b7846a404da6492697b524a769755ee9bee157d67674063ecd9c5f69dc52ff", size = 14044749, upload-time = "2026-09-02T22:41:11.678Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/731f16ff42c5fc96e742c2f4e0a6915356bae114ae02ae4af6f33502175f/ty-0.0.78-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:169d3b9d134c0b48fe1af8a844142d374655552054a9d6c15a4b6e51bb0382ea", size = 13391647, upload-time = "2026-09-02T22:41:13.928Z" }, + { url = "https://files.pythonhosted.org/packages/0c/af/250cc29daf310e837188509ea4d78460c495d8a74c4fb84b4152d9ef2d4f/ty-0.0.78-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6108cb3b2d28dac5981d4e25008a0a5547d8c877a67f6da9e8c38e7e946be44f", size = 13945020, upload-time = "2026-09-02T22:41:15.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/96aab1dee4535e66554e8dc7657c69f6c61c181d8e70b9c3527908e93ef4/ty-0.0.78-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:589ad03608d9d2975ef4b23c41c4f847e325bcc7686f51d4c66066b8dbc18a2a", size = 12851149, upload-time = "2026-09-02T22:41:18.06Z" }, + { url = "https://files.pythonhosted.org/packages/85/b0/53d8fe9a847534ef5fe2165c7d5292cb853b29dfd7011cbfb1cdb90a8012/ty-0.0.78-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b3fa0786edc1af06030f872d83499c0cea7c261dbbb651e0aee8306bf2c8a869", size = 13091464, upload-time = "2026-09-02T22:41:20.227Z" }, + { url = "https://files.pythonhosted.org/packages/21/65/94a4e5a02de559f6c6c0ee7a14b88660b4eee058ec6fec5c0ff6ecfbf5cc/ty-0.0.78-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92c5639befc577578c8abd4e5a7fccf98d828db98b09e8d4dd81605dc0e54aef", size = 13389389, upload-time = "2026-09-02T22:41:22.27Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/d0c7fe6be64ca5a15100c4b1f0e39c19660d53bc544f609fa117b346e661/ty-0.0.78-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0dce70bc51652b2775debd1d1e422fb0179f5207c73deb4d5d0aca37e5f61695", size = 13691928, upload-time = "2026-09-02T22:41:24.292Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5d/36081205611ba3fa802efc4ad63e4b1e949bda2f7bb37b34ac9bb6c00db0/ty-0.0.78-py3-none-win32.whl", hash = "sha256:1c80976ca9185d7a9d1baab1fb57240331b8dac58d3b11e46200284086a6de8d", size = 12647346, upload-time = "2026-09-02T22:41:26.699Z" }, + { url = "https://files.pythonhosted.org/packages/f1/89/b925fe1ea1bc56fc7f11d2496e29072fdd2ceb7b389884fc7b2d07cf0c41/ty-0.0.78-py3-none-win_amd64.whl", hash = "sha256:32e82b704471eab34f67b51c151660ca8a00815977b28278905d76fba54f7415", size = 13241810, upload-time = "2026-09-02T22:41:28.857Z" }, + { url = "https://files.pythonhosted.org/packages/91/f1/090ef7b52355bcedfbfbff6ce70fa81ba5bd5f6ed3f8d99ae05e6f2fe75b/ty-0.0.78-py3-none-win_arm64.whl", hash = "sha256:3a14d641a3c04fa9a80f2a46be1531d915f60d4fb79d4b894627bbe46bb35d64", size = 13077433, upload-time = "2026-09-02T22:41:31.525Z" }, ] [[package]] From 0626a59e556d8589bd24fdd918b69cdb32aec4a0 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 16 Sep 2026 09:01:39 +0000 Subject: [PATCH 106/108] Python: fix API compatibility checks for fork PRs (#8419) * CI: safely inspect fork API changes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 * CI: narrow API attribute filtering Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 --------- Copilot-Session: d1fa3e1a-596f-446f-b77f-5c84ec1eaa38 --- .../scripts/check_python_api_compatibility.py | 24 ++++- .../tests/test_python_api_compatibility.py | 87 ++++++++++++++++++- .github/workflows/merge-gatekeeper.yml | 3 +- .../workflows/python-api-compatibility.yml | 32 ++++++- CONTRIBUTING.md | 11 ++- 5 files changed, 145 insertions(+), 12 deletions(-) diff --git a/.github/scripts/check_python_api_compatibility.py b/.github/scripts/check_python_api_compatibility.py index bfc7967b9cf..c6969e96cc5 100644 --- a/.github/scripts/check_python_api_compatibility.py +++ b/.github/scripts/check_python_api_compatibility.py @@ -18,6 +18,7 @@ from griffe import ( Alias, AliasResolutionError, + AttributeChangedValueBreakage, ExplanationStyle, Object, find_breaking_changes, @@ -184,12 +185,20 @@ def main() -> int: "--base-sha", required=True, help="Trusted pull request base commit" ) parser.add_argument( - "--repo", type=Path, default=Path.cwd(), help="Pull request checkout" + "--repo", type=Path, default=Path.cwd(), help="Trusted repository checkout" + ) + parser.add_argument( + "--current-source", + type=Path, + help="Extracted pull request source; defaults to the repository checkout", ) parser.add_argument("--acknowledge-breaking-changes", action="store_true") args = parser.parse_args() repo = args.repo.resolve() + current_source = ( + args.current_source.resolve() if args.current_source is not None else repo + ) os.chdir(repo) package_states = _package_states(repo, args.base_sha) specs = _module_specs(repo, args.base_sha, package_states) @@ -207,7 +216,7 @@ def main() -> int: try: new_package = load( spec.module, - search_paths=[spec.search_path], + search_paths=[current_source / spec.search_path], allow_inspection=False, resolve_aliases=True, ) @@ -221,6 +230,13 @@ def main() -> int: continue for breakage in find_breaking_changes(old_package, new_package): + if ( + isinstance(breakage, AttributeChangedValueBreakage) + and "instance-attribute" in breakage.obj.labels + and "class-attribute" not in breakage.obj.labels + ): + continue + try: old_breakage_obj = old_package.modules_collection.get_member( breakage.obj.path @@ -231,7 +247,9 @@ def main() -> int: if unreleased_owner is None: breakage_count += 1 - print(breakage.explain(style=ExplanationStyle.GITHUB)) + explanation = breakage.explain(style=ExplanationStyle.GITHUB) + source_prefix = f"file={current_source.as_posix()}/" + print(explanation.replace(source_prefix, "file=", 1)) print("::endgroup::") diff --git a/.github/tests/test_python_api_compatibility.py b/.github/tests/test_python_api_compatibility.py index 48df83867c6..4e8a5f519ea 100644 --- a/.github/tests/test_python_api_compatibility.py +++ b/.github/tests/test_python_api_compatibility.py @@ -74,7 +74,11 @@ def commit(self) -> str: return self.run_git("rev-parse", "HEAD") def run_checker( - self, base_sha: str, *, acknowledged: bool = False + self, + base_sha: str, + *, + acknowledged: bool = False, + current_source: Path | None = None, ) -> subprocess.CompletedProcess[str]: summary_path = self.repo / "summary.md" command = [ @@ -85,6 +89,8 @@ def run_checker( "--repo", str(self.repo), ] + if current_source is not None: + command.extend(["--current-source", str(current_source)]) if acknowledged: command.append("--acknowledge-breaking-changes") env = {**os.environ, "GITHUB_STEP_SUMMARY": str(summary_path)} @@ -238,6 +244,85 @@ def test_compatible_api_is_successful_and_writes_summary(self) -> None: "- Published API breakages: 0", (self.repo / "summary.md").read_text() ) + def test_external_current_source_reports_repository_relative_path(self) -> None: + self.write_status([("agent-framework-stable", "stable", "released")]) + self.write_module( + "stable", + "stable_api", + {"__init__.py": "def public_function(value): pass\n"}, + ) + base_sha = self.commit() + + with tempfile.TemporaryDirectory() as current_dir: + current_source = Path(current_dir) + shutil.copytree(self.repo / "python", current_source / "python") + ( + current_source + / "python" + / "packages" + / "stable" + / "stable_api" + / "__init__.py" + ).write_text("def public_function(): pass\n") + + result = self.run_checker(base_sha, current_source=current_source) + + self.assertEqual(result.returncode, 1) + warning = next( + line for line in result.stdout.splitlines() if line.startswith("::warning ") + ) + self.assertIn( + "file=python/packages/stable/stable_api/__init__.py", + warning, + ) + self.assertNotIn(str(current_source), warning) + + def test_ignores_instance_values_but_reports_other_attribute_values(self) -> None: + self.write_status([("agent-framework-stable", "stable", "released")]) + self.write_module( + "stable", + "stable_api", + { + "__init__.py": ( + "from enum import Enum\n\n" + 'PUBLIC_VALUE = "old"\n\n' + "class PublicClass:\n" + " class_value: int = 1\n\n" + " instance_value: str\n\n" + " def __init__(self):\n" + ' self.instance_value = "old"\n\n' + "class PublicEnum(Enum):\n" + " MEMBER = 1\n" + ) + }, + ) + base_sha = self.commit() + (self.repo / "python/packages/stable/stable_api/__init__.py").write_text( + "from enum import Enum\n\n" + 'PUBLIC_VALUE = "new"\n\n' + "class PublicClass:\n" + " class_value: int = 2\n\n" + " instance_value: str\n\n" + " def __init__(self):\n" + ' self.instance_value = "new"\n\n' + "class PublicEnum(Enum):\n" + " MEMBER = 2\n" + ) + + result = self.run_checker(base_sha) + + self.assertEqual(result.returncode, 1) + warnings = [ + line for line in result.stdout.splitlines() if line.startswith("::warning ") + ] + self.assertEqual(len(warnings), 3) + self.assertTrue(any("PUBLIC_VALUE" in warning for warning in warnings)) + self.assertTrue( + any("PublicClass.class_value" in warning for warning in warnings) + ) + self.assertTrue(any("PublicEnum.MEMBER" in warning for warning in warnings)) + self.assertFalse(any("instance_value" in warning for warning in warnings)) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/merge-gatekeeper.yml b/.github/workflows/merge-gatekeeper.yml index 8aadcace48b..d2162a1781c 100644 --- a/.github/workflows/merge-gatekeeper.yml +++ b/.github/workflows/merge-gatekeeper.yml @@ -27,7 +27,8 @@ jobs: # "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs # created by an org-level GitHub App (MSDO), not by any workflow in this repo. # They are outside our control and their transient failures should not block merges. - IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review" + # "Public API Compatibility" is advisory and reports potential breaking changes. + IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review,Public API Compatibility" with: script: | const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS); diff --git a/.github/workflows/python-api-compatibility.yml b/.github/workflows/python-api-compatibility.yml index 735945b99ad..7e8719a4ec4 100644 --- a/.github/workflows/python-api-compatibility.yml +++ b/.github/workflows/python-api-compatibility.yml @@ -21,12 +21,13 @@ jobs: name: Public API Compatibility runs-on: ubuntu-latest continue-on-error: true + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: refs/pull/${{ github.event.pull_request.number }}/merge - fetch-depth: 0 + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 1 persist-credentials: false - name: Set up uv @@ -39,10 +40,34 @@ jobs: working-directory: ${{ runner.temp }} env: BREAKING_CHANGE_ACKNOWLEDGED: ${{ contains(github.event.pull_request.labels.*.name, 'breaking change') }} + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} UV_NO_CONFIG: "1" shell: bash run: | - BASE_SHA="$(git -C "$GITHUB_WORKSPACE" rev-parse HEAD^1)" + git -C "$GITHUB_WORKSPACE" fetch \ + --no-tags \ + --no-recurse-submodules \ + --depth=2 \ + "https://github.com/$GITHUB_REPOSITORY.git" \ + "refs/pull/$PR_NUMBER/merge" + MERGE_SHA="$(git -C "$GITHUB_WORKSPACE" rev-parse --verify 'FETCH_HEAD^{commit}')" + BASE_SHA="$(git -C "$GITHUB_WORKSPACE" rev-parse "$MERGE_SHA^1")" + HEAD_SHA="$(git -C "$GITHUB_WORKSPACE" rev-parse "$MERGE_SHA^2")" + if [[ "$HEAD_SHA" != "$EXPECTED_HEAD_SHA" ]]; then + echo "Fetched merge ref head $HEAD_SHA does not match event head $EXPECTED_HEAD_SHA." >&2 + exit 1 + fi + + PR_SOURCE="$RUNNER_TEMP/pr-source" + mkdir "$PR_SOURCE" + git -C "$GITHUB_WORKSPACE" archive "$MERGE_SHA" python | + tar -x -C "$PR_SOURCE" + if [[ -n "$(find "$PR_SOURCE/python" -type l -print -quit)" ]]; then + echo "Refusing to analyze pull request source containing symbolic links." >&2 + exit 1 + fi + git -C "$GITHUB_WORKSPACE" show "$BASE_SHA:.github/scripts/check_python_api_compatibility.py" \ > "$RUNNER_TEMP/check_python_api_compatibility.py" git -C "$GITHUB_WORKSPACE" show "$BASE_SHA:python/pyproject.toml" \ @@ -66,6 +91,7 @@ jobs: args=( --base-sha "$BASE_SHA" + --current-source "$PR_SOURCE" --repo "$GITHUB_WORKSPACE" ) if [[ "$BREAKING_CHANGE_ACKNOWLEDGED" == "true" ]]; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff920a62bcd..bce1a471435 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,9 +80,10 @@ Python pull requests run a non-blocking [Griffe](https://mkdocstrings.github.io/ check that compares the pull request's public API with its base commit. The workflow only runs when Python files change, and reports potential breaking changes as annotations and in the job summary. The experimental `agent-framework-lab` package is excluded. The -workflow runs from the trusted base branch, checks out GitHub's synthetic merge commit -with read-only permissions, and statically parses source without importing it. This keeps -the comparison current when a pull request branch is behind `main`. +workflow checks out only trusted base-branch code, fetches GitHub's synthetic merge commit +without checking it out, and statically parses its Python source from a temporary directory +without importing it. This supports fork pull requests while keeping the comparison current +when a pull request branch is behind `main`. Only APIs from packages marked `released` in `python/PACKAGE_STATUS.md` are checked. Prerelease packages and APIs marked with `@experimental` or `@release_candidate`—including @@ -90,7 +91,9 @@ members of a staged class—are excluded. Package state and feature-stage marker from the base commit, so changing either in the same pull request cannot suppress a compatibility finding. The Griffe version is pinned with the other Python development dependencies in `python/pyproject.toml`; the workflow reads that pin from the trusted base -commit. +commit. Instance-attribute initializer values are excluded because Griffe derives them from +constructor control flow and can report implementation-only assignment changes; other +Griffe-detected attribute value changes remain checked. If a breaking change is intentional, add the `breaking change` label to the pull request or add `[BREAKING]` to its title. Existing title/label automation keeps those signals From fca126db375963a0dce3755c4c92ee4dc529b51e Mon Sep 17 00:00:00 2001 From: Anish Mehta <145196704+anishmehta24@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:14:40 +0000 Subject: [PATCH 107/108] fix(openai): preserve reasoning_content on tool-call follow-up messages (#8405) When one assistant turn contains text, tool calls and reasoning, the Chat Completions serializer emitted two assistant dicts (text-only, then tool_calls) and the buffered reasoning field only travelled with the second one. OpenAI-compatible providers such as DeepSeek in thinking mode reject the tool-result follow-up because the message that carries the content no longer carries its reasoning. Attach the tool calls of a turn to the assistant dict built from that turn's plain text (and text that follows coalesced tool calls to the tool_calls dict), so content, tool_calls and the reasoning field are sent as a single assistant message. Reasoning text surfaced via the response_parser hook keeps its own dict so message_preparer correlation is unchanged; refusals, images and other content are not merged. Fixes #8382 Co-authored-by: Claude Fable 5.1 Co-authored-by: Eduard van Valkenburg --- .../_chat_completion_client.py | 25 +++ .../test_openai_chat_completion_client.py | 184 ++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 700825d67f8..a816a391259 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -1111,6 +1111,13 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: all_messages: list[dict[str, Any]] = [] pending_reasoning: Any = None assistant_refusal_parts: list[str] = [] + # The most recently emitted assistant dict when it was built from plain text or tool + # calls (always ``all_messages[-1]`` while set). Text and tool calls from one assistant + # turn belong in a single Chat Completions message: splitting them leaves a provider + # reasoning field (``reasoning_details`` or one added by ``message_preparer``) on only + # one half, which providers like DeepSeek reject on the tool-result follow-up. + # See https://github.com/microsoft/agent-framework/issues/8382 + mergeable_assistant: dict[str, Any] | None = None for content in message.contents: # Skip approval content - it's internal framework state, not for the LLM if content.type in ("function_approval_request", "function_approval_response"): @@ -1131,8 +1138,22 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: if all_messages and "tool_calls" in all_messages[-1]: # If the last message already has tool calls, append to it all_messages[-1]["tool_calls"].append(self._prepare_content_for_openai(content)) + elif mergeable_assistant is not None: + # Attach to the text message emitted for this same turn + mergeable_assistant["tool_calls"] = [self._prepare_content_for_openai(content)] else: args["tool_calls"] = [self._prepare_content_for_openai(content)] + case "text" if ( + message.role == "assistant" + and mergeable_assistant is not None + and "content" not in mergeable_assistant + and not _is_refusal_text_content(content) + ): + # Text following the tool calls of this same turn (streaming can coalesce + # tool calls first) joins that message instead of starting a new one. + if prepared_text := self._prepare_content_for_openai(content): + mergeable_assistant["content"] = [prepared_text] + continue case "function_result": args["tool_call_id"] = content.call_id if content.items: @@ -1148,6 +1169,7 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: else: args["content"] = content.result if content.result is not None else "" all_messages.append(args) + mergeable_assistant = None continue case "text_reasoning" if (protected_data := content.protected_data) is not None: # Buffer reasoning to attach to the next message with content/tool_calls @@ -1171,6 +1193,9 @@ def _build_openai_messages(self, message: Message) -> list[dict[str, Any]]: args["reasoning_details"] = pending_reasoning pending_reasoning = None all_messages.append(args) + mergeable_assistant = ( + args if message.role == "assistant" and content.type in ("text", "function_call") else None + ) # If reasoning was the only content, emit a valid message with empty content if pending_reasoning is not None: diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index a90185d607f..158baa975ac 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -1131,6 +1131,113 @@ def test_prepare_message_with_text_reasoning_before_function_call( assert prepared[0]["role"] == "assistant" +def test_prepare_message_with_text_and_function_call_keeps_single_assistant_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Text, tool calls and reasoning from one assistant turn are sent as one assistant message. + + Regression test for https://github.com/microsoft/agent-framework/issues/8382 + Providers such as DeepSeek (thinking mode) reject the follow-up request when the turn is split + into a text-only assistant message and a tool-call assistant message, because the reasoning + field then only travels with the second one. + """ + client = OpenAIChatCompletionClient() + + mock_reasoning_data = {"effort": "medium", "summary": "Deciding to call a function"} + message = Message( + role="assistant", + contents=[ + Content.from_text(text="I'll check the weather in New York City for you."), + Content.from_function_call(call_id="call_abc", name="get_weather", arguments='{"city": "NYC"}'), + Content.from_text_reasoning(text=None, protected_data=json.dumps(mock_reasoning_data)), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + assert prepared == [ + { + "role": "assistant", + "content": "I'll check the weather in New York City for you.", + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city": "NYC"}'}, + } + ], + "reasoning_details": mock_reasoning_data, + } + ] + + +def test_prepare_message_with_function_call_before_text_keeps_single_assistant_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Streaming can coalesce tool calls ahead of text; the turn is still one assistant message.""" + client = OpenAIChatCompletionClient() + + message = Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="call_abc", name="get_weather", arguments='{"city": "NYC"}'), + Content.from_text(text="Checking the weather now."), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 1 + assert prepared[0]["content"] == "Checking the weather now." + assert [call["id"] for call in prepared[0]["tool_calls"]] == ["call_abc"] + + +def test_prepare_message_with_parallel_function_calls_after_text_keeps_single_assistant_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Parallel tool calls following text all attach to the same assistant message.""" + client = OpenAIChatCompletionClient() + + message = Message( + role="assistant", + contents=[ + Content.from_text(text="Looking both up."), + Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city": "NYC"}'), + Content.from_function_call(call_id="call_2", name="get_weather", arguments='{"city": "LA"}'), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 1 + assert prepared[0]["content"] == "Looking both up." + assert [call["id"] for call in prepared[0]["tool_calls"]] == ["call_1", "call_2"] + + +def test_prepare_message_with_image_between_text_and_function_call_stays_separate( + openai_unit_test_env: dict[str, str], +) -> None: + """Only plain text and tool calls merge; other content keeps its own message and is not merged across.""" + client = OpenAIChatCompletionClient() + + message = Message( + role="assistant", + contents=[ + Content.from_text(text="First."), + Content.from_uri(uri="https://example.com/image.png", media_type="image/png"), + Content.from_function_call(call_id="call_2", name="get_weather", arguments="{}"), + ], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 3 + assert prepared[0] == {"role": "assistant", "content": "First."} + assert prepared[1]["content"][0]["type"] == "image_url" + assert "content" not in prepared[2] + assert [call["id"] for call in prepared[2]["tool_calls"]] == ["call_2"] + + def test_function_approval_content_is_skipped_in_preparation( openai_unit_test_env: dict[str, str], ) -> None: @@ -3055,6 +3162,83 @@ def test_hooks_roundtrip_vllm_reasoning(openai_unit_test_env: dict[str, str]) -> assert prepared[0]["reasoning"] == "Because reasons." +def test_hooks_roundtrip_reasoning_content_with_text_and_function_call_keeps_single_assistant_message( + openai_unit_test_env: dict[str, str], +) -> None: + """End-to-end: DeepSeek-style ``reasoning_content`` next to text and a tool call stays on one message. + + Regression test for https://github.com/microsoft/agent-framework/issues/8382 + """ + from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function + + def deepseek_parser(message: Any, contents: list[Content]) -> list[Content]: + reasoning_content = getattr(message, "reasoning_content", None) + if isinstance(reasoning_content, str) and reasoning_content: + return [*contents, Content.from_text_reasoning(protected_data=json.dumps(reasoning_content))] + return contents + + def deepseek_preparer(message: Message, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + for prepared_message in messages: + if "reasoning_details" in prepared_message: + prepared_message["reasoning_content"] = prepared_message.pop("reasoning_details") + return messages + + client = OpenAIChatCompletionClient(response_parser=deepseek_parser, message_preparer=deepseek_preparer) + message = ChatCompletionMessage.model_construct( + role="assistant", + content="I'll check the weather in New York City for you.", + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_repro_0001", + type="function", + function=Function(name="get_weather", arguments='{"location": "New York City"}'), + ) + ], + reasoning_content="The user wants the weather, so I should call get_weather.", + ) + + parsed = client._parse_response_from_openai(_make_chat_completion(message, model="deepseek-flash"), {}) + prepared = client._prepare_message_for_openai(parsed.messages[0]) + + # One assistant message carries content, tool_calls and the echoed reasoning field, so the + # provider sees the reasoning on the message that also carries the content. + assert prepared == [ + { + "role": "assistant", + "content": "I'll check the weather in New York City for you.", + "tool_calls": [ + { + "id": "call_repro_0001", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"location": "New York City"}'}, + } + ], + "reasoning_content": "The user wants the weather, so I should call get_weather.", + } + ] + + +def test_message_preparer_hook_reasoning_text_before_function_call_is_not_swallowed( + openai_unit_test_env: dict[str, str], +) -> None: + """Hook-surfaced reasoning text keeps its own dict, so a preparer can still correlate and remove it.""" + client = OpenAIChatCompletionClient(message_preparer=_vllm_reasoning_preparer) + reasoning = Content.from_text_reasoning( + text="Need the tool.", additional_properties={_VLLM_REASONING_FIELD_KEY: "reasoning"} + ) + message = Message( + role="assistant", + contents=[reasoning, Content.from_function_call(call_id="call_1", name="get_weather", arguments="{}")], + ) + + prepared = client._prepare_message_for_openai(message) + + assert len(prepared) == 1 + assert "content" not in prepared[0] + assert [call["id"] for call in prepared[0]["tool_calls"]] == ["call_1"] + assert prepared[0]["reasoning"] == "Need the tool." + + def test_no_hooks_keeps_default_behavior(openai_unit_test_env: dict[str, str]) -> None: """Without hooks, top-level `reasoning` is ignored and `reasoning_details` stays opaque.""" client = OpenAIChatCompletionClient() From a8f0aca843f99f22f99a9e806416adeedaa3a9b3 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Wed, 16 Sep 2026 12:25:14 +0200 Subject: [PATCH 108/108] Scope provider-backed MCP sessions to each invocation Create and clean up separate MCP sessions for custom client-provider calls in .NET and Python. Preserve default session caching and caller-owned HTTP clients, and add lifecycle coverage and compatibility guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DefaultMcpToolHandler.cs | 196 +++++- .../README.md | 20 +- .../DefaultMcpToolHandlerLifetimeTests.cs | 437 +++++++++++++ python/packages/declarative/AGENTS.md | 10 + python/packages/declarative/README.md | 19 + .../_workflows/_mcp_handler.py | 174 ++++-- .../tests/test_default_mcp_tool_handler.py | 586 +++++++++++++++--- 7 files changed, 1282 insertions(+), 160 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerLifetimeTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index 49541ebf428..2b333c25409 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -25,6 +25,9 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp; /// This provider supports per-server authentication via the httpClientProvider callback. /// The callback allows different MCP servers to use different authentication configurations by returning /// a pre-configured for each server. +/// Provider-backed invocations create and dispose a separate MCP session for every call, including +/// tools/list, because provider authentication is not represented in the session cache key. +/// Without a provider, sessions are cached by server URL, label, connection name, and explicit headers. /// public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable { @@ -39,9 +42,13 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true }; private readonly Func>? _httpClientProvider; - private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), McpClient> _clients = []; + private readonly Func _httpMessageHandlerFactory; + private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), ClientConnection> _clients = []; private readonly Dictionary _ownedHttpClients = []; private readonly SemaphoreSlim _clientLock = new(1, 1); + private TaskCompletionSource? _providerInvocationsDrained; + private int _activeProviderInvocations; + private bool _disposing; /// /// Initializes a new instance of the class. @@ -51,6 +58,13 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable /// The callback receives (serverUrl, cancellationToken) and should return an HttpClient /// configured with any required authentication. Return to use a default HttpClient with no auth. /// + /// The callback is invoked for each tool invocation. MCP sessions are not cached when a callback is + /// configured, even if it returns the same client or . This adds session setup + /// per call and does not preserve server-side session state between calls. Supplied HTTP clients + /// remain caller-owned. Applications requiring session continuity should provide an + /// with an explicitly scoped authentication and session lifetime. + /// + /// /// Security: HttpClients created by this handler pin credential headers to the configured server /// origin and disable auto-redirect. When you supply your own , you are /// responsible for equivalent protection — attach credentials only for the configured server origin @@ -59,8 +73,16 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable /// /// public DefaultMcpToolHandler(Func>? httpClientProvider = null) + : this(httpClientProvider, CreateHttpMessageHandler) + { + } + + internal DefaultMcpToolHandler( + Func>? httpClientProvider, + Func httpMessageHandlerFactory) { this._httpClientProvider = httpClientProvider; + this._httpMessageHandlerFactory = Throw.IfNull(httpMessageHandlerFactory); } /// @@ -76,12 +98,65 @@ public async Task InvokeToolAsync( if (IsListToolsToolName(toolName)) { ThrowIfListToolsArgumentsSpecified(arguments); - McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false); - IList tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool)); + } + + if (this._httpClientProvider is not null) + { + await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + this.ThrowIfDisposing(); + if (this._activeProviderInvocations++ == 0) + { + this._providerInvocationsDrained = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + finally + { + this._clientLock.Release(); + } + + try + { + ClientConnection invocationClient = await this.CreateClientAsync( + serverUrl.Trim(), serverLabel, headers, httpClientCacheKey: null, cancellationToken).ConfigureAwait(false); + await using (invocationClient.ConfigureAwait(false)) + { + return await InvokeClientAsync(invocationClient.Client, toolName, arguments, cancellationToken).ConfigureAwait(false); + } + } + finally + { + await this._clientLock.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + if (--this._activeProviderInvocations == 0) + { + this._providerInvocationsDrained!.SetResult(true); + } + } + finally + { + this._clientLock.Release(); + } + } } McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false); + return await InvokeClientAsync(client, toolName, arguments, cancellationToken).ConfigureAwait(false); + } + + private static async Task InvokeClientAsync( + McpClient client, + string toolName, + IDictionary? arguments, + CancellationToken cancellationToken) + { + if (IsListToolsToolName(toolName)) + { + IList tools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool)); + } McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString()); @@ -121,10 +196,28 @@ internal static McpServerToolResultContent CreateListToolsResultContent(IEnumera /// public async ValueTask DisposeAsync() { + Task? providerInvocations; + await this._clientLock.WaitAsync().ConfigureAwait(false); + try + { + this.ThrowIfDisposing(); + this._disposing = true; + providerInvocations = this._providerInvocationsDrained?.Task; + } + finally + { + this._clientLock.Release(); + } + + if (providerInvocations is not null) + { + await providerInvocations.ConfigureAwait(false); + } + await this._clientLock.WaitAsync().ConfigureAwait(false); try { - foreach (McpClient client in this._clients.Values) + foreach (ClientConnection client in this._clients.Values) { await client.DisposeAsync().ConfigureAwait(false); } @@ -147,6 +240,16 @@ public async ValueTask DisposeAsync() this._clientLock.Dispose(); } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Maintainability", "CA1513:Use ObjectDisposedException throw helper", + Justification = "The helper is not available on .NET Framework or .NET Standard 2.0.")] + private void ThrowIfDisposing() + { + if (this._disposing) + { + throw new ObjectDisposedException(nameof(DefaultMcpToolHandler)); + } + } + private async Task GetOrCreateClientAsync( string serverUrl, string? serverLabel, @@ -160,14 +263,15 @@ private async Task GetOrCreateClientAsync( await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (this._clients.TryGetValue(clientCacheKey, out McpClient? existingClient)) + this.ThrowIfDisposing(); + if (this._clients.TryGetValue(clientCacheKey, out ClientConnection? existingClient)) { - return existingClient; + return existingClient.Client; } - McpClient newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false); + ClientConnection newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false); this._clients[clientCacheKey] = newClient; - return newClient; + return newClient.Client; } finally { @@ -188,36 +292,25 @@ internal static (string Url, string Label, string Connection, string HeadersHash IDictionary? headers) => (trimmedUrl, serverLabel ?? string.Empty, connectionName ?? string.Empty, ComputeHeadersHash(headers)); - private async Task CreateClientAsync( + private async Task CreateClientAsync( string serverUrl, string? serverLabel, IDictionary? headers, - string httpClientCacheKey, + string? httpClientCacheKey, CancellationToken cancellationToken) { - // Get or create HttpClient (Can be shared across McpClients for the same server) + // Only the no-provider path shares handler-owned HTTP clients. HttpClient? httpClient = null; + bool ownsHttpClient = false; if (this._httpClientProvider is not null) { httpClient = await this._httpClientProvider(serverUrl, cancellationToken).ConfigureAwait(false); } - if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient)) + if (httpClient is null && + (httpClientCacheKey is null || !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))) { - // Disable cookies so handler-level state (cookie jar) cannot cross the cache-key - // isolation boundary established by GetOrCreateClientAsync. The actual MCP auth - // travels via AdditionalHeaders (set per-transport below), not session cookies. - // Disable auto-redirect so an HTTP redirect cannot carry credential headers to a - // different origin (defense-in-depth alongside the origin pinning applied below). - // CheckCertificateRevocationList satisfies CA5399 since we're explicitly constructing the handler. - HttpClientHandler handler = new() - { - UseCookies = false, - AllowAutoRedirect = false, - CheckCertificateRevocationList = true - }; - // Pin credential headers to the configured server origin as defense-in-depth. Forcing // StreamableHttp (below) already removes the primary vector (a server-advertised cross-origin // SSE message endpoint), and AllowAutoRedirect=false blocks auto-redirects. This handler is the @@ -225,9 +318,16 @@ private async Task CreateClientAsync( // origin even if a future change re-enables AutoDetect or redirects, or the SDK constructs a // request to a new URI (AdditionalHeaders are re-stamped by the transport, so HttpClient's own // redirect header-stripping does not cover them). - OriginPinningHandler pinningHandler = new(new Uri(serverUrl)) { InnerHandler = handler }; + OriginPinningHandler pinningHandler = new(new Uri(serverUrl)) { InnerHandler = this._httpMessageHandlerFactory() }; httpClient = new HttpClient(pinningHandler); - this._ownedHttpClients[httpClientCacheKey] = httpClient; + if (httpClientCacheKey is null) + { + ownsHttpClient = true; + } + else + { + this._ownedHttpClients[httpClientCacheKey] = httpClient; + } } HttpClientTransportOptions transportOptions = new() @@ -242,9 +342,45 @@ private async Task CreateClientAsync( TransportMode = HttpTransportMode.StreamableHttp }; - HttpClientTransport transport = new(transportOptions, httpClient); + HttpClientTransport transport = new(transportOptions, httpClient, ownsHttpClient: ownsHttpClient); + + try + { + McpClient client = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); + return new ClientConnection(client, transport); + } + catch + { + await transport.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private static HttpMessageHandler CreateHttpMessageHandler() => + new HttpClientHandler + { + // Keep cookies out of pooled transport state and prevent credential-bearing redirects. + UseCookies = false, + AllowAutoRedirect = false, + CheckCertificateRevocationList = true + }; + + private sealed class ClientConnection(McpClient client, HttpClientTransport transport) : IAsyncDisposable + { + public McpClient Client { get; } = client; - return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); + public async ValueTask DisposeAsync() + { + try + { + await this.Client.DisposeAsync().ConfigureAwait(false); + } + finally + { + // McpClient owns the connected session, not the reusable transport factory. + await transport.DisposeAsync().ConfigureAwait(false); + } + } } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md index 2a50b6045d2..a1d985c658b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/README.md @@ -13,6 +13,25 @@ we've provided a console application that is able to execute any declarative wor ## Actions +### MCP session lifetime + +`DefaultMcpToolHandler` caches MCP sessions by server URL, label, connection name, +and explicit request headers when no custom HTTP client provider is configured. +Dispose the handler when its owning scope ends. + +When `httpClientProvider` is configured, each invocation (including `tools/list`) +calls the provider, opens a separate MCP session, and disposes that session before +completing. This also applies when the provider returns the same `HttpClient` or +returns `null` for the default transport. Caller-supplied HTTP clients remain +caller-owned; handler-created fallback clients are disposed with the invocation. +Handler disposal waits for active provider-backed invocations to finish cleanup. + +Provider-backed calls therefore incur session setup per invocation and do not +preserve server-side session state between calls. Applications requiring session +continuity should implement `IMcpToolHandler` with an explicit authentication +ownership and session lifetime contract, rather than sharing sessions based only +on the server address. + ### ⚙️ Foundry Actions |Action|Description| @@ -57,4 +76,3 @@ we've provided a console application that is able to execute any declarative wor |**Foreach**|Iterates through a collection of items, executing a set of actions for each. Ideal for processing lists or batch operations. |**GotoAction**|Jumps directly to a specified action within the workflow. Enables non-linear navigation in the logic flow. - diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerLifetimeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerLifetimeTests.cs new file mode 100644 index 00000000000..b2479ee4cf1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerLifetimeTests.cs @@ -0,0 +1,437 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests; + +/// +/// Functional session-lifetime tests using an in-memory HTTP transport and inert tool results. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2025:Do not pass disposable objects into unawaited tasks", + Justification = "Provider callbacks return completed tasks; each invocation and handler disposal is awaited before disposing caller-owned clients.")] +public sealed class DefaultMcpToolHandlerLifetimeTests +{ + [Theory] + [InlineData("ping")] + [InlineData(DefaultMcpToolHandler.ListToolsToolName)] + public async Task Provider_SequentialInvocations_CreateAndDisposeSeparateSessionsAsync(string toolName) + { + // Arrange + ProtocolStub stub = new(); + using HttpClient client = new(stub.CreateMessageHandler()); + int providerCalls = 0; + DefaultMcpToolHandler handler = new((_, _) => + { + providerCalls++; + return Task.FromResult(client); + }); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + await InvokeAsync(handler, toolName, timeout.Token); + Assert.Equal(1, stub.Terminations); + await InvokeAsync(handler, toolName, timeout.Token); + await handler.DisposeAsync(); + + // Assert + Assert.Equal(2, providerCalls); + Assert.Equal(2, stub.Initializations); + Assert.Equal(2, stub.Terminations); + stub.Handlers[0].Protected().Verify("Dispose", Times.Never(), ItExpr.Is(disposing => disposing)); + } + + [Fact] + public async Task ProviderReturningNull_DisposesFallbackTransportPerInvocationAsync() + { + // Arrange + ProtocolStub stub = new(); + int providerCalls = 0; + await using DefaultMcpToolHandler handler = new((_, _) => + { + providerCalls++; + return Task.FromResult(null); + }, stub.CreateMessageHandler); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + await InvokeAsync(handler, "ping", timeout.Token); + await InvokeAsync(handler, "ping", timeout.Token); + + // Assert + Assert.Equal(2, providerCalls); + Assert.Equal(2, stub.Initializations); + Assert.Equal(2, stub.Terminations); + Assert.Equal(2, stub.Handlers.Count); + foreach (Mock transport in stub.Handlers) + { + transport.Protected().Verify("Dispose", Times.AtLeastOnce(), ItExpr.Is(disposing => disposing)); + } + } + + [Fact] + public async Task NoProvider_ReusesSessionUntilHandlerDisposalAsync() + { + // Arrange + ProtocolStub stub = new(); + DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + await InvokeAsync(handler, "ping", timeout.Token); + await InvokeAsync(handler, DefaultMcpToolHandler.ListToolsToolName, timeout.Token); + + // Assert + Assert.Equal(1, stub.Initializations); + Assert.Equal(0, stub.Terminations); + Mock transport = Assert.Single(stub.Handlers); + transport.Protected().Verify("Dispose", Times.Never(), ItExpr.Is(disposing => disposing)); + + await handler.DisposeAsync(); + Assert.Equal(1, stub.Terminations); + transport.Protected().Verify("Dispose", Times.AtLeastOnce(), ItExpr.Is(disposing => disposing)); + } + + [Fact] + public async Task NoProvider_DifferentConnectionNames_UseSeparateCachedSessionsAsync() + { + // Arrange + ProtocolStub stub = new(); + DefaultMcpToolHandler handler = new(null, stub.CreateMessageHandler); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + foreach (string connectionName in new[] { "first", "second", "first" }) + { + await handler.InvokeToolAsync( + "https://mcp.example/api", null, "ping", null, null, connectionName, timeout.Token); + } + + // Assert + Assert.Equal(2, stub.Initializations); + Assert.Single(stub.Handlers); + Assert.Equal(0, stub.Terminations); + await handler.DisposeAsync(); + Assert.Equal(2, stub.Terminations); + } + + [Fact] + public async Task Provider_Failure_DoesNotPreventHandlerDisposalAsync() + { + // Arrange + int providerCalls = 0; + DefaultMcpToolHandler handler = new((_, _) => + { + providerCalls++; + throw new InvalidOperationException("Provider unavailable."); + }); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + InvalidOperationException exception = await Assert.ThrowsAsync( + () => InvokeAsync(handler, "ping", timeout.Token)); + await handler.DisposeAsync(); + await Assert.ThrowsAsync(() => InvokeAsync(handler, "ping", timeout.Token)); + + // Assert + Assert.Equal("Provider unavailable.", exception.Message); + Assert.Equal(1, providerCalls); + } + + [Fact] + public async Task Provider_ConcurrentInvocations_DoNotCoalesceOrSerializeSessionsAsync() + { + // Arrange + ProtocolStub stub = new(); + using HttpClient client = new(stub.CreateMessageHandler()); + using SemaphoreSlim started = new(0); + using SemaphoreSlim finish = new(0); + stub.BeforeOperationAsync = async token => + { + started.Release(); + await finish.WaitAsync(token); + }; + int providerCalls = 0; + await using DefaultMcpToolHandler handler = new((_, _) => + { + Interlocked.Increment(ref providerCalls); + return Task.FromResult(client); + }); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + Task first = InvokeAsync(handler, "ping", timeout.Token); + Task second = InvokeAsync(handler, "ping", timeout.Token); + await started.WaitAsync(timeout.Token); + await started.WaitAsync(timeout.Token); + finish.Release(2); + await Task.WhenAll(first, second); + + // Assert + Assert.Equal(2, providerCalls); + Assert.Equal(2, stub.Initializations); + Assert.Equal(2, stub.Terminations); + } + + [Fact] + public async Task Provider_OperationFailure_DisposesSessionAndPreservesCallerClientAsync() + { + // Arrange + ProtocolStub stub = new() { FailOperation = true }; + using HttpClient client = new(stub.CreateMessageHandler()); + await using DefaultMcpToolHandler handler = new((_, _) => Task.FromResult(client)); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + await Assert.ThrowsAsync(() => InvokeAsync(handler, "ping", timeout.Token)); + + // Assert + Assert.Equal(1, stub.Initializations); + Assert.Equal(1, stub.Terminations); + stub.Handlers[0].Protected().Verify("Dispose", Times.Never(), ItExpr.Is(disposing => disposing)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Provider_InitializationFailure_RespectsTransportOwnershipAsync(bool provideClient) + { + // Arrange + ProtocolStub stub = new() { FailInitialization = true }; + using HttpClient? client = provideClient ? new(stub.CreateMessageHandler()) : null; + await using DefaultMcpToolHandler handler = new( + (_, _) => Task.FromResult(client), stub.CreateMessageHandler); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + await Assert.ThrowsAsync(() => InvokeAsync(handler, "ping", timeout.Token)); + + // Assert + Mock transport = Assert.Single(stub.Handlers); + transport.Protected().Verify("Dispose", provideClient ? Times.Never() : Times.AtLeastOnce(), ItExpr.Is(disposing => disposing)); + } + + [Fact] + public async Task Provider_Cancellation_DisposesSessionAsync() + { + // Arrange + ProtocolStub stub = new(); + using HttpClient client = new(stub.CreateMessageHandler()); + using SemaphoreSlim started = new(0); + stub.BeforeOperationAsync = async token => + { + started.Release(); + await Task.Delay(Timeout.Infinite, token); + }; + await using DefaultMcpToolHandler handler = new((_, _) => Task.FromResult(client)); + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10)); + + // Act + Task invocation = InvokeAsync(handler, "ping", cancellation.Token); + await started.WaitAsync(cancellation.Token); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => invocation); + + // Assert + Assert.Equal(1, stub.Terminations); + stub.Handlers[0].Protected().Verify("Dispose", Times.Never(), ItExpr.Is(disposing => disposing)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DisposeAsync_WaitsForProviderInvocationCleanupAsync(bool pauseProvider) + { + // Arrange + ProtocolStub stub = new(); + using HttpClient client = new(stub.CreateMessageHandler()); + using SemaphoreSlim started = new(0); + using SemaphoreSlim finish = new(0); + async Task PauseAsync(CancellationToken token) + { + started.Release(); + await finish.WaitAsync(token); + } + + if (!pauseProvider) + { + stub.BeforeOperationAsync = PauseAsync; + } + + int providerCalls = 0; + DefaultMcpToolHandler handler = new(async (_, token) => + { + providerCalls++; + if (pauseProvider) + { + await PauseAsync(token); + } + + return client; + }); + using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(10)); + + // Act + Task invocation = InvokeAsync(handler, "ping", timeout.Token); + await started.WaitAsync(timeout.Token); + Task disposal = handler.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + await Assert.ThrowsAsync(() => InvokeAsync(handler, "ping", timeout.Token)); + finish.Release(); + await invocation; + await disposal; + + // Assert + Assert.Equal(1, providerCalls); + Assert.Equal(1, stub.Terminations); + stub.Handlers[0].Protected().Verify("Dispose", Times.Never(), ItExpr.Is(disposing => disposing)); + } + + [Fact] + public async Task Provider_InvalidDiscoveryArguments_DoesNotCallProviderAsync() + { + // Arrange + int providerCalls = 0; + await using DefaultMcpToolHandler handler = new((_, _) => + { + providerCalls++; + return Task.FromResult(null); + }); + + // Act + await Assert.ThrowsAsync(() => handler.InvokeToolAsync( + "https://mcp.example/api", null, DefaultMcpToolHandler.ListToolsToolName, + new Dictionary { ["unused"] = true }, null, null)); + + // Assert + Assert.Equal(0, providerCalls); + } + + private static Task InvokeAsync( + DefaultMcpToolHandler handler, string toolName, CancellationToken cancellationToken) => + handler.InvokeToolAsync("https://mcp.example/api", null, toolName, null, null, null, cancellationToken); + + private sealed class ProtocolStub + { + private int _initializations; + private int _terminations; + + public int Initializations => this._initializations; + public int Terminations => this._terminations; + public List> Handlers { get; } = []; + public Func? BeforeOperationAsync { get; set; } + public bool FailInitialization { get; set; } + public bool FailOperation { get; set; } + + public HttpMessageHandler CreateMessageHandler() + { + Mock handler = new() { CallBase = true }; + handler.Protected() + .Setup>("SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(this.SendAsync); + this.Handlers.Add(handler); + return handler.Object; + } + + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Method == HttpMethod.Get) + { + return EmptyResponse(HttpStatusCode.MethodNotAllowed, request); + } + + if (request.Method == HttpMethod.Delete) + { + Interlocked.Increment(ref this._terminations); + return EmptyResponse(HttpStatusCode.OK, request); + } + +#if NET + using JsonDocument document = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); +#else + using JsonDocument document = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()); +#endif + JsonElement message = document.RootElement; + if (!message.TryGetProperty("id", out JsonElement id)) + { + return EmptyResponse(HttpStatusCode.Accepted, request); + } + + string? method = message.GetProperty("method").GetString(); + if (method == "server/discover") + { + return new(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent( + $$$"""{"jsonrpc":"2.0","id":{{{id.GetRawText()}}},"error":{"code":-32601,"message":"Method not found"}}""", + Encoding.UTF8, "application/json") + }; + } + + string result; + string? sessionId = null; + if (method == "initialize") + { + if (this.FailInitialization) + { + return EmptyResponse(HttpStatusCode.BadRequest, request); + } + + sessionId = Interlocked.Increment(ref this._initializations).ToString(CultureInfo.InvariantCulture); + string protocolVersion = message.GetProperty("params").GetProperty("protocolVersion").GetRawText(); + result = $$$"""{"protocolVersion":{{{protocolVersion}}},"capabilities":{"tools":{}},"serverInfo":{"name":"stub","version":"1.0"}}"""; + } + else + { + if (this.BeforeOperationAsync is not null) + { + await this.BeforeOperationAsync(cancellationToken); + } + + if (this.FailOperation) + { + return EmptyResponse(HttpStatusCode.BadRequest, request); + } + + result = method switch + { + "tools/list" => """{"tools":[]}""", + "tools/call" => """{"content":[{"type":"text","text":"ok"}]}""", + _ => throw new InvalidOperationException($"Unexpected MCP method: {method}") + }; + } + + HttpResponseMessage response = new(HttpStatusCode.OK) + { + RequestMessage = request, + Content = new StringContent( + $$$"""{"jsonrpc":"2.0","id":{{{id.GetRawText()}}},"result":{{{result}}}}""", + Encoding.UTF8, "application/json") + }; + if (sessionId is not null) + { + response.Headers.Add("Mcp-Session-Id", sessionId); + } + + return response; + } + + private static HttpResponseMessage EmptyResponse(HttpStatusCode statusCode, HttpRequestMessage request) => + new(statusCode) + { + RequestMessage = request, + Content = new ByteArrayContent([]) + }; + } +} diff --git a/python/packages/declarative/AGENTS.md b/python/packages/declarative/AGENTS.md index 3c9402fc4e5..490dcbf124c 100644 --- a/python/packages/declarative/AGENTS.md +++ b/python/packages/declarative/AGENTS.md @@ -12,6 +12,16 @@ YAML/JSON-based declarative agent and workflow definitions. - **`MCPToolHandler`** / **`DefaultMCPToolHandler`** - Pluggable MCP transport for the `InvokeMcpTool` declarative action (configured via `WorkflowFactory(mcp_tool_handler=...)`) - **`DeclarativeLoaderError`** / **`ProviderLookupError`** / **`DeclarativeWorkflowError`** / **`DeclarativeActionError`** - Error types +## MCP Handler Lifetimes + +`DefaultMCPToolHandler` caches/coalesces sessions only without a `client_provider`. +With a provider, every invocation (including `tools/list`) gets a fresh tool/session, +even if the provider returns `None` or a shared HTTP client. Invocation cleanup closes +the session and any internally owned fallback client, never caller-owned HTTP clients. +Shutdown waits for active provider-backed invocations to clean up. This intentionally +adds connection overhead and loses server session continuity between invocations; +shared session ownership requires an explicitly scoped custom `MCPToolHandler`. + ## External Input Handling - **`ExternalInputRequest`** / **`ExternalInputResponse`** - Human-in-the-loop support diff --git a/python/packages/declarative/README.md b/python/packages/declarative/README.md index c536403aba4..d6bfacb373d 100644 --- a/python/packages/declarative/README.md +++ b/python/packages/declarative/README.md @@ -39,3 +39,22 @@ owned client with the default cookie policy. Explicit `Cookie` request headers remain supported. Response `Set-Cookie` headers remain available in `HttpRequestResult.headers`; disabling persistence does not redact the response. + +## MCP handler session lifetime + +`DefaultMCPToolHandler()` reuses MCP sessions through a bounded LRU cache. +When configured with `client_provider`, it instead invokes the provider and +creates a fresh MCP tool/session for every invocation, including `tools/list`. +Sessions are never reused in this mode, even if the provider returns `None` +or the same `httpx.AsyncClient` instance. + +Each invocation closes its tool/session on success, failure, or cancellation. +Internally created fallback HTTP clients are also closed; caller-supplied HTTP +clients remain caller-owned and are never closed by the handler. Calling +`aclose()` rejects new invocations and waits for active provider-backed +invocations to finish cleaning up. + +This intentionally incurs connection and initialization overhead and does not +retain server session state between provider-backed invocations. Applications +that require shared session ownership must implement an explicitly scoped +custom `MCPToolHandler`; there is no provider-backed session-cache opt-in. diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_mcp_handler.py b/python/packages/declarative/agent_framework_declarative/_workflows/_mcp_handler.py index 4a26e74c885..2291d6640b4 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_mcp_handler.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_mcp_handler.py @@ -157,14 +157,14 @@ class _CacheEntry: class DefaultMCPToolHandler: """Default :class:`MCPToolHandler` backed by :class:`agent_framework.MCPStreamableHTTPTool`. - Caches one :class:`agent_framework.MCPStreamableHTTPTool` instance per - ``(server_url, server_label, connection_name, headers_hash)`` in a - bounded LRU. The cache prevents re-establishing an MCP session for every + Without a ``client_provider``, caches one + :class:`agent_framework.MCPStreamableHTTPTool` instance per + ``(server_url, server_label, connection_name, headers_hash)`` in a bounded + LRU. The cache prevents re-establishing an MCP session for every invocation while ensuring different header sets (auth tokens) cannot share a session — matches the .NET design intent while bounding - cardinality. ``server_label`` and ``connection_name`` participate in - the key so that callers using ``client_provider`` to dispatch on those - fields receive a fresh client per logical connection (see below). + cardinality. ``server_label`` and ``connection_name`` also participate + in the key to distinguish logical connections. Header *names* are lower-cased inside the hash payload only — the headers passed on the wire keep the caller's original casing — so two YAML actions that spell ``Authorization`` differently still share a @@ -174,12 +174,20 @@ class DefaultMCPToolHandler: 1. ``DefaultMCPToolHandler()`` — owns its own ``httpx.AsyncClient`` instances created lazily per cache entry. Closed by :meth:`aclose`. - 2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-server client + 2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-invocation client lookup (parity with .NET ``httpClientProvider`` callback). The callback receives the full :class:`MCPToolInvocation` so it can dispatch on ``server_url`` / ``connection_name`` / ``server_label``. - Returning ``None`` falls back to an internally-created client. Caller - supplied clients are NOT closed by :meth:`aclose`. + The callback is invoked for every call, including ``tools/list``. + Each call creates and closes its own MCP tool/session, even when the + callback returns ``None`` or the same client object. Returning ``None`` + falls back to an internally-created client, closed with the invocation. + Caller-supplied clients are never closed by this handler. + + This intentionally trades connection/session reuse for invocation + isolation. Server session state is not retained across invocations; + callers needing shared session ownership must implement an explicitly + scoped custom :class:`MCPToolHandler`. .. warning:: @@ -187,11 +195,11 @@ class DefaultMCPToolHandler: or replace it with a custom handler in production deployments. Args: - client_provider: Optional per-server ``httpx.AsyncClient`` provider. - cache_max_size: Maximum number of cached MCP clients. When exceeded, - the least-recently-used entry is evicted and its client closed - (only owned clients are closed; caller-supplied ones are not). - Defaults to ``32``. + client_provider: Optional per-invocation ``httpx.AsyncClient`` provider. + cache_max_size: Maximum number of cached MCP clients in no-provider mode. + When exceeded, the least-recently-used entry is evicted and its + owned client closed. Defaults to ``32``. Does not enable session + caching when a provider is configured. """ LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list" @@ -227,12 +235,14 @@ def __init__( # tasks awaiting the same key will await the same future and share # the resulting cache entry. self._inflight: dict[tuple[str, str, str, str], asyncio.Future[_CacheEntry]] = {} + # Completion signals only: provider-backed calls never share entries. + self._active_invocations: set[asyncio.Future[None]] = set() # Set by ``aclose`` to prevent post-close cache insertions and to # reject new ``invoke_tool`` calls. Once set, never cleared. self._closed = False async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult: - """Invoke ``invocation.tool_name`` on the cached MCP client for the server. + """Invoke ``invocation.tool_name`` on an MCP client for the server. The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is intercepted client-side: instead of being forwarded as a tool call, @@ -241,7 +251,6 @@ async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult: ``TextContent`` containing a JSON tool catalog. """ from agent_framework import Content - from agent_framework.exceptions import ToolExecutionException # Reserved-name args validation runs before connect: rejecting bad # input shouldn't require establishing an MCP session. @@ -253,23 +262,51 @@ async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult: error_message=message, ) + entry: _CacheEntry | None = None + completion: asyncio.Future[None] | None = None try: - entry = await self._get_or_create_entry(invocation) - except Exception as exc: - # Connect / cache lookup failures surface as tool errors so the - # workflow can store them at output.result without crashing. - logger.warning( - "DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s", - invocation.server_url, - invocation.tool_name, - exc, - ) - message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ") - return MCPToolResult( - outputs=[Content.from_text(f"Error: {message}")], - is_error=True, - error_message=message, - ) + try: + if self._client_provider is None: + entry = await self._get_or_create_entry(invocation) + else: + async with self._cache_lock: + if self._closed: + raise RuntimeError("DefaultMCPToolHandler is closed") + completion = asyncio.get_running_loop().create_future() + self._active_invocations.add(completion) + entry = await self._create_entry(invocation) + if self._closed: + raise RuntimeError("DefaultMCPToolHandler is closed") + except Exception as exc: + # Connect / cache lookup failures surface as tool errors so the + # workflow can store them at output.result without crashing. + logger.warning( + "DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s", + invocation.server_url, + invocation.tool_name, + exc, + ) + message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ") + return MCPToolResult( + outputs=[Content.from_text(f"Error: {message}")], + is_error=True, + error_message=message, + ) + + return await self._invoke_entry(entry, invocation) + finally: + if completion is not None: + try: + if entry is not None: + await self._close_invocation_entry(entry) + finally: + self._active_invocations.discard(completion) + completion.set_result(None) + + async def _invoke_entry(self, entry: _CacheEntry, invocation: MCPToolInvocation) -> MCPToolResult: + """Dispatch a connected invocation and preserve tool error mapping.""" + from agent_framework import Content + from agent_framework.exceptions import ToolExecutionException try: if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME: @@ -372,24 +409,33 @@ async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult: return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))]) async def aclose(self) -> None: - """Close all cached MCP clients and the owned httpx clients. + """Close cached clients and wait for provider-backed invocations to clean up. Caller-supplied :class:`httpx.AsyncClient` instances (returned by the ``client_provider`` callback) are NOT closed. - Idempotent — a second call returns immediately. Drains any in-flight - ``_create_entry`` tasks before returning so their resources are + Provider-backed calls already executing may finish; pending connections + are rejected after connecting and cleaned up by their invocation. + Concurrent shutdown calls wait for those invocations as well. + + Idempotent. In no-provider mode, a second call returns immediately. + Drains any in-flight ``_create_entry`` tasks before returning so their resources are cleaned up; the in-flight tasks see ``self._closed`` in phase 3 of :meth:`_get_or_create_entry`, close their own entry, and resolve their future with ``RuntimeError("DefaultMCPToolHandler is closed")``. """ async with self._cache_lock: - if self._closed: + if self._closed and self._client_provider is None: return self._closed = True entries = list(self._cache.values()) self._cache.clear() inflight_futures = list(self._inflight.values()) + active_invocations = list(self._active_invocations) + + for completion in active_invocations: + # Cancelling shutdown must not cancel an invocation's completion signal. + await asyncio.shield(completion) # Wait for in-flight creations to finish their self-cleanup. Each # in-flight task self-closes its entry under the closed-flag branch @@ -509,7 +555,9 @@ async def _create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry: provided_client: httpx.AsyncClient | None = None if self._client_provider is not None: provided_client = await self._client_provider(invocation) - # Capture headers for this cache entry so the header_provider closure + if self._closed: + raise RuntimeError("DefaultMCPToolHandler is closed") + # Capture headers for this entry so the header_provider closure # always returns the same set, regardless of the runtime kwargs. captured_headers = dict(invocation.headers) @@ -526,10 +574,18 @@ def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]: try: await tool.connect() except BaseException: - try: - await tool.close() - except Exception: # pragma: no cover - best effort - logger.debug("DefaultMCPToolHandler: error closing tool after failed connect", exc_info=True) + failed_entry = _CacheEntry( + tool=tool, + owned_httpx_client=( + cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None)) + if provided_client is None + else None + ), + ) + if self._client_provider is not None: + await self._close_invocation_entry(failed_entry) + else: + await self._close_entry(failed_entry) raise # ``MCPStreamableHTTPTool.get_mcp_client`` lazily creates an @@ -542,17 +598,34 @@ def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]: owned_client = cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None)) return _CacheEntry(tool=tool, owned_httpx_client=owned_client) + async def _close_invocation_entry(self, entry: _CacheEntry) -> None: + """Finish invocation cleanup even if the caller is cancelled again.""" + # MCPStreamableHTTPTool dispatches connect/close to its lifecycle owner, + # keeping the SDK's cancel-scope entry and exit on that same task. + cleanup = asyncio.create_task(self._close_entry(entry)) + cancelled = False + while not cleanup.done(): + try: + await asyncio.shield(cleanup) + except asyncio.CancelledError: + cancelled = True + cleanup.result() # Propagate cancellation/errors from cleanup itself. + if cancelled: + raise asyncio.CancelledError + async def _close_entry(self, entry: _CacheEntry) -> None: """Close the MCP tool and any owned httpx client.""" try: - await entry.tool.close() - except Exception: # pragma: no cover - best effort - logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True) - if entry.owned_httpx_client is not None: try: - await entry.owned_httpx_client.aclose() + await entry.tool.close() except Exception: # pragma: no cover - best effort - logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True) + logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True) + finally: + if entry.owned_httpx_client is not None and not entry.owned_httpx_client.is_closed: + try: + await entry.owned_httpx_client.aclose() + except Exception: # pragma: no cover - best effort + logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True) @staticmethod def _cache_key( @@ -563,10 +636,9 @@ def _cache_key( ) -> tuple[str, str, str, str]: """Build an order-independent cache key for the invocation identity. - The key includes ``server_label`` and ``connection_name`` so that - callers using ``client_provider`` to dispatch on those fields - receive a fresh client per logical connection (matches the - documented dispatch contract). + Used only without a ``client_provider``. The key includes + ``server_label`` and ``connection_name`` to distinguish logical + connections. Header *names* are lower-cased inside the hash payload only so that ``Authorization`` and ``authorization`` map to the same diff --git a/python/packages/declarative/tests/test_default_mcp_tool_handler.py b/python/packages/declarative/tests/test_default_mcp_tool_handler.py index a8329dea278..c8abe22466b 100644 --- a/python/packages/declarative/tests/test_default_mcp_tool_handler.py +++ b/python/packages/declarative/tests/test_default_mcp_tool_handler.py @@ -7,10 +7,9 @@ exercisable through the executor stub: cache hit/miss/eviction, concurrent connect via in-flight futures, header isolation across cache keys, string-result normalisation, ``load_prompts=False`` verification, and -owned-vs-caller httpx close semantics. +owned-vs-caller httpx close semantics, and provider-backed invocation lifetimes. -The shared-client regression also exercises the real MCP SDK transport against -an in-process HTTPX mock server. +One lifecycle regression uses the real MCP SDK with an inert HTTPX mock transport. """ from __future__ import annotations @@ -19,7 +18,7 @@ import json import sys from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import httpx import pytest @@ -102,7 +101,7 @@ def __init__(self, **kwargs: Any) -> None: self.connect_delay: float = 0.0 self.connect_error: BaseException | None = None self.call_handler: Any = lambda **_a: [Content.from_text("ok")] - self._httpx_client: httpx.AsyncClient | None = None + self._httpx_client: httpx.AsyncClient | None = kwargs.get("http_client") self.session: FakeMcpSession | None = None # Mimic MCPStreamableHTTPTool: when no caller client AND header_provider # is set, lazily allocate an owned httpx client during connect. @@ -150,81 +149,512 @@ def _invocation( ) -@pytest.mark.parametrize("cache_max_size", [1, 2]) -async def test_shared_client_isolates_cached_authentication_and_cleans_up_hooks(cache_max_size: int) -> None: - sessions: dict[str, str] = {} - calls: list[tuple[str, str]] = [] - writes: dict[str, list[str]] = {"token-a": [], "token-b": []} - - async def caller_hook(request: httpx.Request) -> None: - request.headers["X-Caller"] = "preserved" - - async def handle(request: httpx.Request) -> httpx.Response: - assert request.headers["X-Caller"] == "preserved" - principal = request.headers.get("Authorization", "") - if principal not in writes: - return httpx.Response(401) - if request.method == "GET": - return httpx.Response(405) - if request.method == "DELETE": - return httpx.Response(200) - body = json.loads(request.content) - headers: dict[str, str] = {} - result: dict[str, Any] = {} - if body.get("method") == "initialize": - session_id = f"session-{len(sessions)}" - sessions[session_id] = principal - headers["mcp-session-id"] = session_id - result = { - "protocolVersion": body["params"]["protocolVersion"], - "capabilities": {"tools": {}}, - "serverInfo": {"name": "auth-test", "version": "1"}, - } - elif body.get("method") == "tools/list": - result = { - "tools": [ - { - "name": "search", - "inputSchema": {"type": "object", "properties": {"marker": {"type": "string"}}}, - } - ] - } - elif body.get("method") == "tools/call": - calls.append((request.headers["mcp-session-id"], principal)) - if marker := body["params"].get("arguments", {}).get("marker"): - writes[principal].append(marker) - result = {"content": [{"type": "text", "text": principal}]} - if "id" not in body: - return httpx.Response(202) - return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) - - async with httpx.AsyncClient( - transport=httpx.MockTransport(handle), event_hooks={"request": [caller_hook]} - ) as client: - - async def client_provider(invocation: MCPToolInvocation) -> httpx.AsyncClient: - return client - - async with DefaultMCPToolHandler(client_provider=client_provider, cache_max_size=cache_max_size) as handler: - outputs: list[str | None] = [] - for index, principal in enumerate(("token-a", "token-b", "token-a")): - result = await handler.invoke_tool( - _invocation( - headers={"Authorization": principal}, - arguments={"marker": "a-only"} if index == 2 else {}, - ) +# ---------- Provider-backed invocation lifetimes -------------------------- + + +class TestProviderLifetimes: + @pytest.mark.parametrize("returns_none", [False, True]) + async def test_actual_sdk_lifecycle_and_hook_cleanup( + self, returns_none: bool, caplog: pytest.LogCaptureFixture + ) -> None: + import agent_framework + from agent_framework import MCPStreamableHTTPTool + + caplog.set_level("DEBUG", logger="agent_framework_declarative._workflows._mcp_handler") + initialized: list[str] = [] + terminated: list[str] = [] + methods: list[str] = [] + tools: list[MCPStreamableHTTPTool] = [] + clients: list[RecordingClient] = [] + caller_hook = AsyncMock() + + async def respond(request: httpx.Request) -> httpx.Response: + assert request.headers["X-Test"] == "lifecycle" + if request.method == "GET": + return httpx.Response(405) + if request.method == "DELETE": + terminated.append(request.headers["mcp-session-id"]) + return httpx.Response(200) + + body = json.loads(request.content) + method = body["method"] + methods.append(method) + if "id" not in body: + return httpx.Response(202) + headers: dict[str, str] = {} + if method == "initialize": + session_id = f"lifecycle-{len(initialized) + 1}" + initialized.append(session_id) + headers["mcp-session-id"] = session_id + result = { + "protocolVersion": body["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "lifecycle", "version": "1"}, + } + elif method == "tools/list": + result = { + "tools": [{"name": "ok", "inputSchema": {"type": "object", "properties": {}}}], + } + elif method == "tools/call": + result = {"content": [{"type": "text", "text": "ok"}]} + else: + assert method == "ping" + result = {} + return httpx.Response(200, headers=headers, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + + class RecordingClient(httpx.AsyncClient): + def __init__(self, **kwargs: Any) -> None: + super().__init__( + transport=httpx.MockTransport(respond), event_hooks={"request": [caller_hook]}, **kwargs ) + self.close_count = 0 + clients.append(self) + + async def aclose(self) -> None: + self.close_count += 1 + await super().aclose() + + def create_tool(**kwargs: Any) -> MCPStreamableHTTPTool: + tool = MCPStreamableHTTPTool(**kwargs) + tools.append(tool) + return tool + + caller_client = None if returns_none else RecordingClient() + provider = AsyncMock(return_value=caller_client) + try: + with ( + patch.object(httpx, "AsyncClient", RecordingClient), + patch.object(agent_framework, "MCPStreamableHTTPTool", side_effect=create_tool), + ): + async with DefaultMCPToolHandler(client_provider=provider) as handler: + for index, tool_name in enumerate(("ok", "tools/list", "ok", "tools/list"), start=1): + result = await handler.invoke_tool( + _invocation(tool_name=tool_name, headers={"X-Test": "lifecycle"}) + ) + assert not result.is_error + if tool_name == "ok": + assert result.outputs[0].text == "ok" + else: + assert result.outputs[0].text is not None + assert json.loads(result.outputs[0].text)["tools"][0]["name"] == "ok" + assert provider.await_count == index + assert len(tools) == index + assert len(initialized) == index + assert terminated == initialized + assert not handler._active_invocations + assert not handler._cache + tool = tools[-1] + assert tool.session is None + assert not tool.is_connected + assert tool._lifecycle_owner_task is None + assert tool._header_hook_client is None + assert all(client.event_hooks["request"] == [caller_hook] for client in clients) + if returns_none: + assert len(clients) == index + assert all(client.is_closed and client.close_count == 1 for client in clients) + else: + assert caller_client is not None + assert not caller_client.is_closed + assert caller_client.close_count == 0 + assert len({id(tool) for tool in tools}) == 4 + assert methods.count("tools/call") == 2 + assert methods.count("tools/list") == 6 + assert not any( + "Could not cleanly close MCP exit stack" in record.getMessage() + or "DefaultMCPToolHandler: error closing" in record.getMessage() + for record in caplog.records + ) + finally: + for client in clients: + if not client.is_closed: + await client.aclose() + + @pytest.mark.parametrize("returns_none", [False, True]) + async def test_sequential_invocations_get_fresh_tools(self, returns_none: bool) -> None: + async with httpx.AsyncClient() as client: + provider = AsyncMock(return_value=None if returns_none else client) + with _patch_tool(): + async with DefaultMCPToolHandler(client_provider=provider, cache_max_size=1) as handler: + for index, tool_name in enumerate(("search", "tools/list", "search", "tools/list"), start=1): + invocation = _invocation(tool_name=tool_name, headers={"X-Test": "1"}) + result = await handler.invoke_tool(invocation) + assert not result.is_error + assert result.outputs[0].text == ('{\n "tools": []\n}' if tool_name == "tools/list" else "ok") + assert provider.await_count == index + assert provider.await_args is not None + assert provider.await_args.args[0] is invocation + assert len(FakeTool.instances) == index + tool = FakeTool.instances[-1] + assert tool.connect_count == 1 + assert tool.close_count == 1 + assert tool.kwargs["http_client"] is (None if returns_none else client) + if returns_none: + assert tool._httpx_client is not None + assert tool._httpx_client.is_closed + assert not client.is_closed + assert not handler._cache + assert not handler._inflight + assert not handler._active_invocations + assert len({id(tool.session) for tool in FakeTool.instances}) == 4 + assert all(tool.close_count == 1 for tool in FakeTool.instances) + assert not client.is_closed + + @pytest.mark.parametrize("returns_none", [False, True]) + async def test_concurrent_invocations_get_fresh_tools(self, returns_none: bool) -> None: + all_connecting = asyncio.Event() + release = asyncio.Event() + original_connect = FakeTool.connect + + async def gated_connect(tool: FakeTool) -> None: + await original_connect(tool) + if len(FakeTool.instances) == 3: + all_connecting.set() + await release.wait() + + async with httpx.AsyncClient() as client: + provider = AsyncMock(return_value=None if returns_none else client) + with _patch_tool(), patch.object(FakeTool, "connect", gated_connect): + async with DefaultMCPToolHandler(client_provider=provider) as handler: + tasks = [ + asyncio.create_task(handler.invoke_tool(_invocation(tool_name=name, headers={"X-Test": "1"}))) + for name in ("search", "tools/list", "search") + ] + try: + await asyncio.wait_for(all_connecting.wait(), timeout=5) + assert provider.await_count == 3 + assert len({id(tool.session) for tool in FakeTool.instances}) == 3 + assert all(tool.close_count == 0 for tool in FakeTool.instances) + assert not handler._cache + assert not handler._inflight + finally: + release.set() + results = await asyncio.gather(*tasks) + assert all(not result.is_error for result in results) + assert all(tool.close_count == 1 for tool in FakeTool.instances) + if returns_none: + assert all(tool._httpx_client and tool._httpx_client.is_closed for tool in FakeTool.instances) + assert not handler._active_invocations + assert not client.is_closed + + async def test_list_tools_paginates_within_each_invocation(self) -> None: + original_connect = FakeTool.connect + + async def connect(tool: FakeTool) -> None: + await original_connect(tool) + assert tool.session is not None + tool.session.list_tools_pages = [ + FakeListToolsResult([FakeMcpTool("first")], next_cursor="next"), + FakeListToolsResult([FakeMcpTool("second")]), + ] + + provider = AsyncMock(return_value=None) + with ( + _patch_tool(), + patch.object(FakeTool, "connect", connect), + patch.object(FakeTool, "call_tool", new_callable=AsyncMock) as call_tool, + ): + async with DefaultMCPToolHandler(client_provider=provider) as handler: + for _ in range(2): + result = await handler.invoke_tool(_invocation(tool_name="tools/list")) + assert not result.is_error + assert result.outputs[0].text is not None + payload = json.loads(result.outputs[0].text) + assert [tool["name"] for tool in payload["tools"]] == ["first", "second"] + call_tool.assert_not_awaited() + assert provider.await_count == 2 + assert len(FakeTool.instances) == 2 + for tool in FakeTool.instances: + assert tool.close_count == 1 + assert tool.session is not None + assert len(tool.session.list_tools_calls) == 2 + assert tool.session.list_tools_calls[0] is None + assert tool.session.list_tools_calls[1].cursor == "next" + + @pytest.mark.parametrize("stage", ["provider", "connect", "search", "tools/list"]) + @pytest.mark.parametrize( + "error_type", [httpx.ReadTimeout, ToolExecutionException, RuntimeError, asyncio.CancelledError] + ) + @pytest.mark.parametrize("returns_none", [False, True]) + async def test_errors_clean_up_and_preserve_mapping( + self, stage: str, error_type: type[BaseException], returns_none: bool + ) -> None: + error = error_type("operation stopped") + original_connect = FakeTool.connect + + async def connect(tool: FakeTool) -> None: + await original_connect(tool) + if stage == "connect": + raise error + + async with httpx.AsyncClient() as client: + provider = AsyncMock( + return_value=None if returns_none else client, + side_effect=error if stage == "provider" else None, + ) + with ( + _patch_tool(), + patch.object(FakeTool, "connect", connect), + patch.object(FakeTool, "call_tool", new_callable=AsyncMock, side_effect=error), + patch.object(FakeMcpSession, "list_tools", new_callable=AsyncMock, side_effect=error), + ): + async with DefaultMCPToolHandler(client_provider=provider) as handler: + invocation = _invocation( + tool_name="tools/list" if stage == "tools/list" else "search", headers={"X-Test": "1"} + ) + if error_type is asyncio.CancelledError or ( + error_type is RuntimeError and stage in ("search", "tools/list") + ): + with pytest.raises(error_type, match="operation stopped"): + await handler.invoke_tool(invocation) + else: + result = await handler.invoke_tool(invocation) + assert result.is_error + assert "operation stopped" in (result.error_message or "") + assert result.outputs[0].text is not None + assert result.outputs[0].text.startswith("Error:") + assert not handler._active_invocations + assert not handler._cache + assert not handler._inflight + assert provider.await_count == 1 + assert len(FakeTool.instances) == (0 if stage == "provider" else 1) + for tool in FakeTool.instances: + assert tool.close_count == 1 + if returns_none: + assert tool._httpx_client is not None + assert tool._httpx_client.is_closed + assert not client.is_closed + + @pytest.mark.parametrize("tool_name", ["search", "tools/list"]) + async def test_mcp_error_mapping_cleans_up(self, tool_name: str) -> None: + from mcp.shared.exceptions import McpError + from mcp.types import ErrorData + + error = McpError(ErrorData(code=-32603, message="operation stopped")) + with ( + _patch_tool(), + patch.object(FakeTool, "call_tool", new_callable=AsyncMock, side_effect=error), + patch.object(FakeMcpSession, "list_tools", new_callable=AsyncMock, side_effect=error), + ): + async with DefaultMCPToolHandler(client_provider=AsyncMock(return_value=None)) as handler: + result = await handler.invoke_tool(_invocation(tool_name=tool_name, headers={"X-Test": "1"})) + assert result.is_error + assert result.error_message == "operation stopped" + assert not handler._active_invocations + tool = FakeTool.instances[0] + assert tool.close_count == 1 + assert tool._httpx_client is not None + assert tool._httpx_client.is_closed + + async def test_tool_close_failure_still_closes_fallback_client(self) -> None: + async def close(tool: FakeTool) -> None: + tool.close_count += 1 + raise RuntimeError("close failed") + + with _patch_tool(), patch.object(FakeTool, "close", close): + async with DefaultMCPToolHandler(client_provider=AsyncMock(return_value=None)) as handler: + result = await handler.invoke_tool(_invocation(headers={"X-Test": "1"})) assert not result.is_error - outputs.append(result.outputs[0].text) - assert outputs == ["token-a", "token-b", "token-a"] - assert len(client.event_hooks["request"]) == 1 + cache_max_size - - assert [principal for _, principal in calls] == ["token-a", "token-b", "token-a"] - assert all(sessions[session_id] == principal for session_id, principal in calls) - assert len(sessions) == (3 if cache_max_size == 1 else 2) - assert writes == {"token-a": ["a-only"], "token-b": []} - assert client.event_hooks["request"] == [caller_hook] - assert not client.is_closed + assert result.outputs[0].text == "ok" + assert not handler._active_invocations + tool = FakeTool.instances[0] + assert tool.close_count == 1 + assert tool._httpx_client is not None + assert tool._httpx_client.is_closed + + @pytest.mark.parametrize("connect_fails", [False, True]) + async def test_cleanup_cancellation_signals_completion(self, connect_fails: bool) -> None: + original_connect = FakeTool.connect + completions: list[asyncio.Future[None]] = [] + + async def connect(tool: FakeTool) -> None: + await original_connect(tool) + if connect_fails: + raise httpx.ConnectError("connect stopped") + + async def close(tool: FakeTool) -> None: + tool.close_count += 1 + completions.extend(handler._active_invocations) + raise asyncio.CancelledError("cleanup stopped") + + with ( + _patch_tool(), + patch.object(FakeTool, "connect", connect), + patch.object(FakeTool, "close", close), + ): + handler = DefaultMCPToolHandler(client_provider=AsyncMock(return_value=None)) + with pytest.raises(asyncio.CancelledError, match="cleanup stopped"): + await handler.invoke_tool(_invocation(headers={"X-Test": "1"})) + assert not handler._active_invocations + assert len(completions) == 1 + assert completions[0].done() + assert completions[0].result() is None + await asyncio.wait_for(handler.aclose(), timeout=5) + tool = FakeTool.instances[0] + assert tool.close_count == 1 + assert tool._httpx_client is not None + assert tool._httpx_client.is_closed + + @pytest.mark.parametrize("stage", ["provider", "connect", "search", "tools/list"]) + async def test_task_cancellation_cleans_up(self, stage: str) -> None: + started = asyncio.Event() + original_connect = FakeTool.connect + + async def wait(*_args: Any, **_kwargs: Any) -> None: + started.set() + await asyncio.Event().wait() + + async def provider(_invocation: MCPToolInvocation) -> None: + if stage == "provider": + await wait() + + async def connect(tool: FakeTool) -> None: + await original_connect(tool) + if stage == "connect": + await wait() + + with ( + _patch_tool(), + patch.object(FakeTool, "connect", connect), + patch.object(FakeTool, "call_tool", new_callable=AsyncMock, side_effect=wait), + patch.object(FakeMcpSession, "list_tools", new_callable=AsyncMock, side_effect=wait), + ): + async with DefaultMCPToolHandler(client_provider=provider) as handler: + task = asyncio.create_task( + handler.invoke_tool( + _invocation( + tool_name="tools/list" if stage == "tools/list" else "search", headers={"X-Test": "1"} + ) + ) + ) + await asyncio.wait_for(started.wait(), timeout=5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert not handler._active_invocations + assert len(FakeTool.instances) == (0 if stage == "provider" else 1) + for tool in FakeTool.instances: + assert tool.close_count == 1 + assert tool._httpx_client is not None + assert tool._httpx_client.is_closed + + async def test_repeated_cancellation_waits_for_cleanup(self) -> None: + cleanup_started = asyncio.Event() + release_cleanup = asyncio.Event() + original_close = FakeTool.close + + async def close(tool: FakeTool) -> None: + cleanup_started.set() + await release_cleanup.wait() + await original_close(tool) + + with _patch_tool(), patch.object(FakeTool, "close", close): + async with DefaultMCPToolHandler(client_provider=AsyncMock(return_value=None)) as handler: + task = asyncio.create_task(handler.invoke_tool(_invocation(headers={"X-Test": "1"}))) + await asyncio.wait_for(cleanup_started.wait(), timeout=5) + for _ in range(2): + task.cancel() + await asyncio.sleep(0) + assert not task.done() + assert handler._active_invocations + release_cleanup.set() + with pytest.raises(asyncio.CancelledError): + await task + assert not handler._active_invocations + tool = FakeTool.instances[0] + assert tool.close_count == 1 + assert tool._httpx_client is not None + assert tool._httpx_client.is_closed + + @pytest.mark.parametrize("stage", ["provider", "connect", "search", "tools/list", "close"]) + @pytest.mark.parametrize("cancel_shutdown", [False, True]) + async def test_shutdown_waits_for_invocation_cleanup(self, stage: str, cancel_shutdown: bool) -> None: + started = asyncio.Event() + release = asyncio.Event() + original_connect = FakeTool.connect + original_close = FakeTool.close + original_call = FakeTool.call_tool + original_list = FakeMcpSession.list_tools + + async def gate(phase: str) -> None: + if stage == phase: + started.set() + await release.wait() + + async def provider(_invocation: MCPToolInvocation) -> None: + await gate("provider") + + async def connect(tool: FakeTool) -> None: + await original_connect(tool) + await gate("connect") + + async def close(tool: FakeTool) -> None: + await gate("close") + await original_close(tool) + + async def call(tool: FakeTool, tool_name: str, **arguments: Any) -> Any: + await gate("search") + return await original_call(tool, tool_name, **arguments) + + async def list_tools(session: FakeMcpSession, params: Any = None) -> FakeListToolsResult: + await gate("tools/list") + return await original_list(session, params) + + provider_mock = AsyncMock(side_effect=provider) + with ( + _patch_tool(), + patch.object(FakeTool, "connect", connect), + patch.object(FakeTool, "close", close), + patch.object(FakeTool, "call_tool", call), + patch.object(FakeMcpSession, "list_tools", list_tools), + ): + handler = DefaultMCPToolHandler(client_provider=provider_mock) + invocation = _invocation( + tool_name="tools/list" if stage == "tools/list" else "search", headers={"X-Test": "1"} + ) + task = asyncio.create_task(handler.invoke_tool(invocation)) + await asyncio.wait_for(started.wait(), timeout=5) + shutdown = asyncio.create_task(handler.aclose()) + await asyncio.sleep(0) + assert handler._closed + assert not shutdown.done() + rejected = await handler.invoke_tool(invocation) + assert rejected.is_error + assert "closed" in (rejected.error_message or "") + assert provider_mock.await_count == 1 + if cancel_shutdown: + shutdown.cancel() + with pytest.raises(asyncio.CancelledError): + await shutdown + second_shutdown = asyncio.create_task(handler.aclose()) + await asyncio.sleep(0) + assert not second_shutdown.done() + release.set() + result = await task + await second_shutdown + if not cancel_shutdown: + await shutdown + await handler.aclose() + assert result.is_error == (stage in ("provider", "connect")) + if result.is_error: + assert "closed" in (result.error_message or "") + assert not handler._active_invocations + assert not handler._cache + assert not handler._inflight + assert len(FakeTool.instances) == (0 if stage == "provider" else 1) + for tool in FakeTool.instances: + assert tool.close_count == 1 + assert tool._httpx_client is not None + assert tool._httpx_client.is_closed + + async def test_invalid_list_arguments_skip_provider(self) -> None: + provider = AsyncMock(return_value=None) + with _patch_tool(): + async with DefaultMCPToolHandler(client_provider=provider) as handler: + result = await handler.invoke_tool(_invocation(tool_name="tools/list", arguments={"q": "test"})) + assert result.is_error + assert "does not accept tool arguments" in (result.error_message or "") + provider.assert_not_awaited() + assert not FakeTool.instances # ---------- Construction ---------------------------------------------------