From 1db411c8a7dbf358767b8b5a57bd6d9124dbdc2e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 30 Sep 2026 14:50:54 +0200 Subject: [PATCH 1/7] fix(python): harden Foundry Responses integrations --- .../skills/python-code-quality/SKILL.md | 3 + python/packages/foundry_hosting/README.md | 16 ++ .../_toolbox.py | 40 +-- python/pyrefly.samples.toml | 2 + .../claw_step04_production_ready/README.md | 52 +++- .../claw_step04_production_ready/agent.py | 20 +- .../claw_step04_production_ready/hosted.py | 151 ++++++++-- .../foundry-hosted-agents/README.md | 31 +- .../responses/azure_search_rag/README.md | 17 ++ .../responses/azure_search_rag/main.py | 104 ++++--- .../responses/custom_storage/.env.example | 2 +- .../responses/custom_storage/README.md | 119 +++++--- .../custom_storage/agent.manifest.yaml | 4 +- .../responses/custom_storage/agent.yaml | 4 +- .../responses/custom_storage/main.py | 270 ++++++++++++++---- .../custom_storage/tests/test_storage.py | 154 ++++++++++ .../responses/files/README.md | 193 ++++++------- .../responses/files/file_access.py | 144 ++++++++++ .../responses/files/main.py | 122 ++++---- .../responses/files/tests/test_file_access.py | 150 ++++++++++ .../responses/files/upload_file.py | 56 ++++ .../responses/foundry_memory/.env.example | 2 + .../responses/foundry_memory/README.md | 60 +++- .../responses/foundry_memory/main.py | 122 +++++--- .../foundry_memory/provision_memory_store.py | 15 +- .../foundry_memory/tests/test_memory_scope.py | 109 +++++++ .../responses/foundry_toolbox/README.md | 25 ++ .../responses/foundry_toolbox/main.py | 74 +++-- .../foundry_toolbox_mcp_skills/README.md | 21 +- .../foundry_toolbox_mcp_skills/main.py | 84 ++++-- .../responses/mcp/README.md | 18 ++ .../responses/mcp/main.py | 99 ++++--- .../responses/monty_codeact/README.md | 61 ++-- .../monty_codeact/agent.manifest.yaml | 2 +- .../responses/monty_codeact/agent.yaml | 2 +- .../responses/monty_codeact/main.py | 73 +++-- .../responses/observability/README.md | 25 +- .../observability/agent.manifest.yaml | 2 +- .../responses/observability/agent.yaml | 2 +- .../responses/observability/main.py | 70 +++-- .../responses/tools/README.md | 14 + .../responses/tools/main.py | 70 +++-- 42 files changed, 2027 insertions(+), 577 deletions(-) create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests/test_storage.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/files/file_access.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/files/tests/test_file_access.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/files/upload_file.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests/test_memory_scope.py diff --git a/python/.github/skills/python-code-quality/SKILL.md b/python/.github/skills/python-code-quality/SKILL.md index d36aa07794f..e7e92ab128a 100644 --- a/python/.github/skills/python-code-quality/SKILL.md +++ b/python/.github/skills/python-code-quality/SKILL.md @@ -99,6 +99,9 @@ Following the "too many type checkers" approach, type checkers are split by targ `# type: ignore[code]`. Suppress relaxed-pyright friction with `# pyright: ignore[rule]`. - **Samples** add `pyright` to `pyrefly` + `ty` — mypy/zuban can't resolve script-style sample layouts (numeric-prefixed dirs, duplicate `main.py`), but pyright handles them. +- `pyrefly.samples.toml` enables fallback lookup for standalone sibling imports. For a + targeted `ty` check of the excluded hosted claw harness, add its deployment directory + with `--extra-search-path samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready`. - The strict source-pyright (`[tool.pyright]`) enforces `reportUnnecessaryTypeIgnoreComment` and excludes tests/samples; the relaxed test/sample pyright configs do not flag unnecessary ignores. diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md index ab8fb4b5cc1..9395ff87180 100644 --- a/python/packages/foundry_hosting/README.md +++ b/python/packages/foundry_hosting/README.md @@ -29,6 +29,22 @@ The Responses host continues regular agents through its existing session store a existing checkpoint store. A callable does not make arbitrary instance fields persistent; state needed by later requests must remain in the supported stores. +For Responses integrations, use a factory when an MCP connection, provider, tool +cache or client carries request identity. A Toolbox's streamable-HTTP writer +inherits the context of the request that **connects** it; sharing that connection +across callers can retain the first call ID. Create the Toolbox and its skills +provider inside the factory, not at process startup. + +Factory agents are entered/exited for each request, including failed entry and +cancellation. `Agent` manages context-managed clients and MCP tools, but it does +not automatically manage every context provider or external credential. The +[integration samples](../../samples/04-hosting/foundry-hosted-agents/) explicitly +own their SDK transports, credentials and providers: Search is request-owned; +Memory binds a fresh provider/project client to the trusted user and current call; +custom Cosmos state uses user **and** sandbox namespaces with conditional writes. +Only close resources that the factory creates and owns, never a supplied shared +client or somebody else's credential. + ## Responses agent history and storage The caller's `POST /responses` **`store` flag** controls whether the *outer* response is retrievable and whether diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_toolbox.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_toolbox.py index 386bb383af6..e58daaab844 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_toolbox.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_toolbox.py @@ -109,9 +109,10 @@ class _ToolboxAuth(httpx.Auth): asynchronous :class:`~azure.core.credentials_async.AsyncTokenCredential` credentials are supported: the async flow awaits an async credential's ``get_token``, while the sync flow requires a synchronous credential. The - per-request ``x-agent-foundry-call-id`` is read from the request-scoped context - populated by the hosting endpoint; it resolves to a fresh value on each request - and is absent (no header) for protocol ``1.0.0`` or local development. + ``x-agent-foundry-call-id`` is read from the hosting context inherited by the + MCP transport task. That task captures context at connection time, so hosted + callers must use a request-owned toolbox/connection rather than sharing one + across requests. The header is absent when no call ID is supplied. """ def __init__(self, credential: AzureCredentialTypes, scope: str) -> None: @@ -166,15 +167,17 @@ class FoundryToolbox(MCPStreamableHTTPTool): - forwards the platform per-request call-id (``x-agent-foundry-call-id``) so the Foundry MCP proxy can resolve the caller context server-side. - The call-id forwarding is transparent: it is read from the request-scoped context - the hosting endpoint binds on each request, so no per-request wiring is needed. + The call-id is read from the context inherited by the MCP connection's writer. + Construct this toolbox inside a request-scoped agent factory so it captures + the current caller's context, not a preceding request's. Because the toolbox endpoint is a first-party Foundry service, forwarding the opaque caller token to it is safe. Like any MCP tool, the connection lifecycle is driven by the agent: the hosting - server enters the agent, which connects the toolbox on first use and closes it - (and the HTTP client it owns) at shutdown. Using it as an ``async with`` context - manager directly is supported but not required. + server enters a factory-created agent for its request and closes its toolbox + and owned HTTP client afterward. An instance-owned agent instead keeps that + connection until shutdown and is not appropriate for differing caller contexts. + Using it as an ``async with`` context manager directly is also supported. Examples: .. code-block:: python @@ -185,14 +188,19 @@ class FoundryToolbox(MCPStreamableHTTPTool): from azure.identity import DefaultAzureCredential credential = DefaultAzureCredential() - # The hosting server enters the agent, which connects/closes the toolbox. - toolbox = FoundryToolbox(credential) - agent = Agent( - client=FoundryChatClient(credential=credential), - tools=toolbox, - default_options={"store": False}, - ) - await ResponsesHostServer(agent).run_async() + + + def create_agent(): + return Agent( + client=FoundryChatClient(credential=credential), + tools=FoundryToolbox(credential), + ) + + + await ResponsesHostServer(agent=create_agent).run_async() + + See the Responses Toolbox sample for explicit ownership and cleanup of + the request's project/model transports and credentials as well. """ def __init__( diff --git a/python/pyrefly.samples.toml b/python/pyrefly.samples.toml index 7d7f49d755d..4d31bcd1bf5 100644 --- a/python/pyrefly.samples.toml +++ b/python/pyrefly.samples.toml @@ -2,6 +2,8 @@ # real mistakes (bad imports, wrong attribute/module access) without forcing readers to # wade through casts and overload gymnastics just to satisfy third-party SDK stubs. project-includes = ["samples"] +# Standalone hosted scripts import siblings from their own deployment directory. +enable-fallback-search-path = true project-excludes = [ "**/autogen-migration/**", "**/semantic-kernel-migration/**", diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md index 48f3081b520..315dfbd1c60 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md @@ -42,7 +42,8 @@ export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" > request and forwards the platform's per-request `x-agent-foundry-call-id`. See > [`04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills`](../../../../04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills) > for the minimal version of this pattern. Hosted runs connect the toolbox because -> `ResponsesHostServer` enters the agent; `console_app.py` and `evals.py` do it explicitly with +> `ResponsesHostServer` enters a **new factory-created agent per request**; +> `console_app.py` and `evals.py` do it explicitly with > `async with agent:`. > **The hosted agent's managed identity needs the `Foundry User` role.** This is the single most @@ -53,16 +54,14 @@ export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" > dereferences the project-level skill resource, which does require the role. The toolbox answers > with a bare JSON-RPC `-32603` and no `data`, so nothing in the error names the cause. > -> The container logs the identity to grant it to at startup: -> -> ``` -> Agent managed identity (grant it the Foundry User role): -> ``` +> Obtain the hosted managed identity's principal ID from the platform/resource +> configuration when granting the role. Startup diagnostics log only which +> variables are present, not identity/session/call values or tokens. > > ```bash > az role assignment create --assignee-object-id \ > --assignee-principal-type ServicePrincipal --role "Foundry User" \ -> --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/ +> --scope /subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts//projects/ > ``` > > Resolve the object id with `az ad sp list --filter "startswith(displayName,'')" -o table` @@ -89,12 +88,47 @@ uv run --prerelease=allow python/samples/02-agents/harness/build_your_own_claw/c uv run --prerelease=allow python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py ``` -The hosted version **disables file access and shell** on the container. In a shared, hosted environment, giving the model arbitrary read/write access to the container filesystem or letting it run shell commands is a serious security risk (data exfiltration, tampering, persistence), and the local confirmations vault the shell operates on doesn't exist there. Background agents and Monty CodeAct (alpha) remain enabled. If you need file access when hosted, pass an external `file_access_store` (for example, one backed by Azure Blob Storage) instead of the container disk. +The hosted version **disables file access and shell** on the container. In a +shared, hosted environment, arbitrary filesystem/shell access is a +data-exfiltration and tampering risk, and the local confirmations vault does not +exist there. Background research and Monty CodeAct remain enabled. An external +`file_access_store` must enforce trusted user/sandbox access before it can be +enabled; using a Blob Storage backend alone is not an authorization boundary. File memory **stays enabled** when hosted, but its store has to move. The harness writes file memory to `{cwd}/agent-file-memory` by default, and the deployed code directory (`/app`) is mounted **read-only** on Foundry hosted agents, so the default directory fails. `hosted.py` therefore passes a -`FileSystemAgentFileStore` rooted at `~/.claw/agent-file-memory`, which is writable. +`FileSystemAgentFileStore` rooted at +`$HOME/.claw/agent-file-memory/`, which is writable. +The hash comes from `FoundryRequestScope.storage_key`; hosted scope validation +uses the configured sandbox plus trusted user/call IDs, never a caller-selected +conversation or MAF session ID. File memory is **sandbox-specific**, unlike the +intentional user-wide sharing in the +[Foundry Memory sample](../../../../04-hosting/foundry-hosted-agents/responses/foundry_memory/). + +`ResponsesHostServer(agent=create_agent, history_source="agent_server")` builds +new clients, Toolbox/skills providers, CodeAct and harness providers for each +request. The sample injects its own context-managed client into +`build_claw_agent`, leaving ownership of supplied/shared clients elsewhere +unchanged. That client closes only its own model/project transports and +credential; it captures the current platform call ID, not an earlier caller's. +History/approval state is persisted by the host, not retained on provider +instances. Local hosts keep their original builder defaults. + +Request-lifetime middleware releases only this agent's background-provider +tasks for the active MAF session, in a `finally` path on success, failure or +cancellation. Streaming teardown wraps **consumption**, not construction of a +lazy stream. Outstanding research is cancelled and joined before the request's +transports close; it cannot keep running with an obsolete call context after +the turn. Complete/collect research within a turn; unfinished runtime tasks +cannot be resumed by a later factory-created agent. Cleanup failure is logged +without identity values and does not replace an existing run failure. + +Local runs use `AzureCliCredential` and are single-user development only. +Hosted runs use managed identity; project/Toolbox/Purview permissions and +resources require separate configuration. The unrelated Telegram sample also +requires externally configured Telegram/Key Vault credentials. None of those +credential-gated behaviors or deployments is proven by an offline smoke check. ### Deploy to Foundry diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.py index 754f771814a..1bb6ababfc1 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.py +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/agent.py @@ -187,7 +187,7 @@ def _build_skills_provider(credential: TokenCredential) -> tuple[SkillsProvider, platform's per-request ``x-agent-foundry-call-id``. Note that reading a skill's body requires the caller identity to hold the ``Foundry User`` role - on the Foundry account. Discovery does not, so a missing grant shows up as skills that load and + on the Foundry project. Discovery does not, so a missing grant shows up as skills that load and advertise fine but fail on first use — see the README. """ sources: list[SkillsSource] = [FileSkillsSource(str(_SKILLS_DIR), script_runner=subprocess_script_runner)] @@ -211,10 +211,9 @@ def _build_skills_provider(credential: TokenCredential) -> tuple[SkillsProvider, # (for example a literal ``{{TOOLBOX_MCP_SERVER_URL}}``). Silently skipping it makes a # deployment error look identical to a deliberate opt-out, so warn instead. logger.warning( - "Foundry skills disabled: TOOLBOX_MCP_SERVER_URL is set but is not an http(s) URL (got %r). " + "Foundry skills disabled: TOOLBOX_MCP_SERVER_URL is set but is not an http(s) URL. " "If this looks like an unsubstituted placeholder, check the environment variable wiring " "in azure.yaml / agent.manifest.yaml.", - toolbox_url, ) else: logger.info("Foundry skills disabled. Set TOOLBOX_MCP_SERVER_URL to enable them.") @@ -293,6 +292,7 @@ def _build_purview_middleware(credential: TokenCredential | None = None) -> list async def build_claw_agent( *, credential: TokenCredential | None = None, + client: FoundryChatClient | None = None, project_endpoint: str | None = None, model: str | None = None, default_options: Mapping[str, Any] | None = None, @@ -308,6 +308,7 @@ async def build_claw_agent( Args: credential: Azure credential for the Foundry chat client. Defaults to AzureCliCredential. + client: Optional preconfigured client. The caller owns its resources and middleware. project_endpoint: Optional Foundry project endpoint override. model: Optional model deployment override. default_options: Optional per-agent default chat options, such as ``{"store": False}`` for hosting. @@ -341,12 +342,13 @@ async def build_claw_agent( # resolved_credential = credential or AzureCliCredential() - client = FoundryChatClient( - project_endpoint=project_endpoint, - model=model, - credential=resolved_credential, - middleware=_build_purview_middleware(purview_credential), - ) + if client is None: + client = FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=resolved_credential, + middleware=_build_purview_middleware(purview_credential), + ) # skills_provider, skills_tools = _build_skills_provider(resolved_credential) diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py index 452cf54b836..b589c09c872 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py @@ -7,6 +7,7 @@ # "agent-framework-tools", # "agent-framework-monty", # "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", # "mcp", # "httpx", # "azure-identity", @@ -46,19 +47,28 @@ import asyncio import logging import os +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path - -from agent import build_claw_agent -from agent_framework import FileSystemAgentFileStore, InMemoryHistoryProvider -from agent_framework_foundry_hosting import ResponsesHostServer -from azure.identity import DefaultAzureCredential +from types import TracebackType + +from agent import _build_purview_middleware, build_claw_agent +from agent_framework import ( + Agent, + AgentContext, + AgentResponseUpdate, + BackgroundAgentsProvider, + FileSystemAgentFileStore, + InMemoryHistoryProvider, + ResponseStream, + agent_middleware, +) +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import FoundryRequestScope, ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv -# File memory writes to disk, and the deployed code directory is read-only on Foundry hosted agents, -# so the harness default of ``{cwd}/agent-file-memory`` cannot be created. The home directory is -# writable, so root the store there. -_FILE_MEMORY_DIR = Path.home() / ".claw" / "agent-file-memory" - logger = logging.getLogger(__name__) @@ -82,33 +92,52 @@ def _configure_logging() -> None: def _log_environment() -> None: """Log which platform variables are present, to make misconfiguration self-evident. - Only variable *names* are logged for the platform-injected ``FOUNDRY_*`` set: their values can - carry session and project details. ``FOUNDRY_AGENT_INSTANCE_CLIENT_ID`` is the exception. It is - the client id of the managed identity the container authenticates as, and that identity needs - the ``Foundry User`` role to read Toolbox skill content — so when a skill fails to load, this - line names the exact principal to grant the role to (see the README). + Log variable names only, never user/session/call IDs, tokens or identity values. """ foundry_vars = sorted(name for name in os.environ if name.startswith("FOUNDRY_")) logger.info("Platform-injected FOUNDRY_* variables present: %s", ", ".join(foundry_vars) or "(none)") logger.info( - "Agent managed identity (grant it the Foundry User role): %s", - os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID") or "(not set)", + "Agent managed identity configured: %s", + bool(os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")), ) -async def main() -> None: - """Build the claw and expose it with the Foundry Responses host server.""" - _configure_logging() - load_dotenv() - _log_environment() - - credential = DefaultAzureCredential() - logger.info("File memory enabled (local filesystem at %s).", _FILE_MEMORY_DIR) +async def create_agent() -> Agent: + """Build request-owned tools and scope file memory by trusted user and sandbox.""" + config, context = AgentConfig.from_env(), get_request_context() + scope = FoundryRequestScope.from_context(config, context, local_session_id="local-development") + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if config.is_hosted + else AzureCliCredential() + ) + memory_dir = Path.home() / ".claw" / "agent-file-memory" / scope.storage_key + + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) + + client = RequestClient( + project_endpoint=endpoint, + model=model, + credential=credential, + default_headers=context.platform_headers(), + middleware=_build_purview_middleware(credential), + ) + logger.info("File memory enabled (trusted user/sandbox-scoped filesystem).") agent = await build_claw_agent( credential=credential, - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - default_options={"store": False}, + client=client, history_provider=InMemoryHistoryProvider(load_messages=False), # Disable filesystem and shell access on the hosted container. Arbitrary read/write or # command execution in a shared hosted environment is a serious security risk, and the @@ -116,13 +145,73 @@ async def main() -> None: # external file_access_store (e.g. one backed by Azure Blob Storage) instead of the disk. enable_file_access=False, enable_shell=False, - # File memory is on by default; keep it, but on a writable path (see _FILE_MEMORY_DIR). - file_memory_store=FileSystemAgentFileStore(_FILE_MEMORY_DIR), + file_memory_store=FileSystemAgentFileStore(memory_dir), # Purview authenticates via the container's managed identity; InteractiveBrowserCredential # cannot run on a headless hosted container. purview_credential=credential, ) - server = ResponsesHostServer(agent) + background_providers = [ + provider for provider in agent.context_providers if isinstance(provider, BackgroundAgentsProvider) + ] + + @agent_middleware + async def request_background_lifetime(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + if context.session is None: + raise RuntimeError("The hosted harness requires a request-owned AgentSession.") + session = context.session + + @asynccontextmanager + async def release_background() -> AsyncIterator[None]: + run_failed = False + try: + yield + except BaseException: + run_failed = True + raise + finally: + try: + for provider in background_providers: + await provider.release_session(session, timeout=None) + except BaseException as cleanup_error: + logger.error("Failed to release request-owned background tasks (%s).", type(cleanup_error).__name__) + if not run_failed: + raise + + if context.stream: + # Streaming returns before iteration; teardown must wrap consumption, not construction. + try: + await call_next() + except BaseException: + async with release_background(): + raise + inner = context.result + if not isinstance(inner, ResponseStream): + raise RuntimeError("The streaming hosted harness must return a ResponseStream.") + + async def updates() -> AsyncIterator[AgentResponseUpdate]: + async with release_background(): + try: + async for update in inner: + yield update + await inner.get_final_response() + finally: + await inner.close() + + context.result = ResponseStream(updates(), finalizer=lambda _: inner.get_final_response()) + else: + async with release_background(): + await call_next() + + agent.middleware = [request_background_lifetime, *(agent.middleware or [])] + return agent + + +async def main() -> None: + """Expose the claw with a fresh agent and resource lifecycle for each request.""" + _configure_logging() + load_dotenv() + _log_environment() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") await server.run_async() diff --git a/python/samples/04-hosting/foundry-hosted-agents/README.md b/python/samples/04-hosting/foundry-hosted-agents/README.md index f9c9ef13e44..e1ea0a5b39a 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/README.md @@ -16,17 +16,30 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew | 3 | [MCP](responses/mcp/) | An agent connected to a remote MCP server (GitHub), demonstrating external MCP tool provider integration. | | 4 | [Foundry Toolbox](responses/foundry_toolbox/) | An agent using Azure Foundry Toolbox, demonstrating toolbox provisioning and querying available tools at runtime. | | 5 | [Workflows](responses/workflows/) | An agent with a multi-step orchestrated workflow, demonstrating chaining prompts through an orchestrated flow. | -| 6 | [Files](responses/files/) | An agent demonstrating how to work with files in a hosted agent session, including uploading files to a hosted agent session and having the agent read and manipulate those files at runtime. | +| 6 | [Files](responses/files/) | Bounded, symlink-safe reads of explicitly uploaded files under the current sandbox's home, with local staging and hosted upload guidance. | | 7 | [Observability](responses/observability/) | A sample demonstrating how to enable observability for the agent deployed to Foundry. | | 8 | [Azure AI Search RAG](responses/azure_search_rag/) | An agent with Retrieval Augmented Generation (RAG) capabilities backed by Azure AI Search, grounding answers in documents indexed in a pre-provisioned search index. | | 9 | [Foundry Memory](responses/foundry_memory/) | An agent with persistent semantic memory backed by a Microsoft Foundry Memory Store, using `FoundryMemoryProvider` to remember user facts across sessions. | | 10 | [Monty CodeAct](responses/monty_codeact/) | An agent with a Monty-backed CodeAct context provider, exposing a single `execute_code` tool that runs Python in a [pydantic-monty](https://github.com/pydantic/monty) interpreter and invokes typed host tools (`compute`, `fetch_data`) from inside the sandbox. Uses the beta `agent-framework-monty` package. | | 11 | [Foundry Toolbox MCP Skills](responses/foundry_toolbox_mcp_skills/) | An agent that discovers MCP-based skills attached to a Foundry Toolbox and serves them via `SkillsProvider(MCPSkillsSource(...))`, fetching `SKILL.md` bodies and supplementary resources on demand. | -| 13 | [Custom Storage](responses/custom_storage/) | An agent demonstrating how to implement a custom storage provider for agent sessions (in-memory and Cosmos DB). | +| 13 | [Custom Storage](responses/custom_storage/) | Trusted user-and-sandbox session snapshots with create-only/ETag writes and managed-identity Cosmos authentication; local snapshots need no account. | | 14 | [Resilient Long-Running Workflow](responses/resilient_long_running_workflow/) | A long-running, crash-resilient workflow demonstrating how `resilient_background=True` lets a background response survive a hard crash of the server process and resume from its last checkpoint instead of restarting from scratch. | | 15 | [Long-Running Agent (steering gated)](responses/steerable_long_running_agent/) | A working long-running Responses agent with ordinary background polling; steering currently fails at host construction until a patched AgentServer SDK is published and verified. | | 16 | [Using deployed agent](responses/using_deployed_agent.py) | Invoke an agent already deployed to Foundry using either a service-created or user-created hosted session, then delete the session after use. | +The integration examples use **request-owned factories** and the current +`history_source="agent_server"` API. Use the current workspace, or a hosting +release containing that API; before the coordinated beta is published, older +PyPI wheels are not evidence that the examples include these behaviors. +Toolbox/skills connections inherit the current request's call ID and are closed +afterward. Files and custom MAF state are sandbox-specific; Foundry Memory +intentionally shares long-term memories across the **same user's** sandboxes. +Their READMEs document the external resource/permission boundaries. GitHub MCP +PAT/OAuth and Telegram/Key Vault require separate setup; the +[Hyperlight container example](../container/hyperlight_codeact/) requires a +hypervisor unavailable in the default Foundry runtime. Offline checks do not +claim any of those deployments or credential-gated integrations ran. + ## Session Identifiers Foundry hosted agents use multiple session-related values for different purposes. They are stored together on an @@ -53,6 +66,12 @@ Keep the same `AgentSession` across turns so Agent Framework can forward both va read the Foundry `agent_session_id` from `session.state` and pass that value to the Foundry session deletion API. See [Using deployed agent](responses/using_deployed_agent.py) for service-created and user-created lifecycle examples. +On the **hosted server**, response/conversation IDs may be canonical store +lookup keys for snapshots whose inner `AgentSession.session_id` is different. +The custom store must preserve that inner ID while isolating each lookup key +by trusted platform user **and** sandbox, with per-key conditional writes. +An opaque call ID correlates one request; it is not a storage namespace. + ### Invocations API | # | Sample | Description | @@ -63,6 +82,14 @@ See [Using deployed agent](responses/using_deployed_agent.py) for service-create ## Running the Agent Host Locally +The Responses integration entrypoints declare their additional imports in +**PEP 723 inline script metadata**, not a project dependency group. With a hosting +release that contains the current API, `uv run --script --prerelease=allow main.py` +resolves those script dependencies. While developing this coordinated beta, +use the current workspace's installed packages with +`uv run --no-sync python /main.py`. Do not add sample-only dependencies +to workspace, package or sample `pyproject.toml` files. + ### Using `azd` #### Prerequisites diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/azure_search_rag/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/azure_search_rag/README.md index 08702b9466a..85c465fc6e1 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/azure_search_rag/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/azure_search_rag/README.md @@ -14,6 +14,23 @@ The agent uses `FoundryChatClient` from the Agent Framework to create a Response See [main.py](main.py) for the full implementation. +`create_agent` allocates a fresh Search provider, credential and model client for +each request. The sample's context-managed client explicitly enters/closes the +provider and closes its own SDK transports and credential, including failed +entry and cancellation; `Agent` does not automatically enter every provider. +`history_source="agent_server"` owns conversation history and disables inner +model storage. Only first-party Foundry model calls receive the platform call +ID; it is not forwarded to Search as an authorization substitute. + +The configured index is **intentionally shared, read-only public Contoso sample +documentation**. It contains no user-private or sandbox-private data. A private +multi-tenant index requires trusted per-request ACL filtering or separately +authorized index routing before retrieval; do not rely on caller options, +prompt instructions, or a new Python provider instance to isolate documents. +The index name and endpoint here come from operator configuration, not request +input. Provisioning an index, Search RBAC and live retrieval require separate +resources and are not covered by offline checks. + ### Agent Hosting The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/azure_search_rag/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/azure_search_rag/main.py index 85ea92f363b..a97ff62db0f 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/azure_search_rag/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/azure_search_rag/main.py @@ -1,57 +1,95 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "agent-framework-azure-ai-search", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. +"""Request-owned retrieval over an explicitly shared, read-only public sample index.""" + +from __future__ import annotations + import asyncio import os +from contextlib import AsyncExitStack +from types import TracebackType from agent_framework import Agent from agent_framework.azure import AzureAISearchContextProvider -from agent_framework.foundry import FoundryChatClient, ResponsesHostServer -from azure.identity import DefaultAzureCredential +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv -# Load environment variables from .env file -load_dotenv() +def create_agent() -> Agent: + """Allocate a new Search provider and close its transports after this request.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + search_endpoint = os.environ["AZURE_SEARCH_ENDPOINT"] + index_name = os.environ["AZURE_SEARCH_INDEX_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() + ) -async def main(): - credential = DefaultAzureCredential() - - # Connect to a pre-provisioned Azure AI Search index. The index is expected to - # exist and contain documents with the schema described in README.md - # (id / content / sourceName / sourceLink). The context provider runs a search - # against this index before each model invocation and injects the matching - # documents into the model context. search_provider = AzureAISearchContextProvider( source_id="azure_search_rag", - endpoint=os.environ["AZURE_SEARCH_ENDPOINT"], - index_name=os.environ["AZURE_SEARCH_INDEX_NAME"], + endpoint=search_endpoint, + index_name=index_name, credential=credential, mode="semantic", top_k=3, ) - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + try: + await search_provider.__aenter__() + except BaseException: + await self.__aexit__(None, None, None) + raise + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) + cleanup.push_async_callback(search_provider.close) + + client = RequestClient( + project_endpoint=endpoint, + model=model, credential=credential, + default_headers=get_request_context().platform_headers(), ) + return Agent( + client=client, + instructions=( + "You are a support specialist for Contoso Outdoors. " + "Answer using the provided public documentation and cite the source when available." + ), + context_providers=[search_provider], + ) + - async with search_provider: - agent = Agent( - client=client, - instructions=( - "You are a helpful support specialist for Contoso Outdoors. " - "Answer questions using the provided context and cite the source " - "document when available." - ), - context_providers=[search_provider], - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, - ) - server = ResponsesHostServer(agent) - await server.run_async() +async def main() -> None: + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") + await server.run_async() if __name__ == "__main__": diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/.env.example index c136203a33b..cd863e9f23e 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/.env.example +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/.env.example @@ -1,5 +1,5 @@ FOUNDRY_PROJECT_ENDPOINT="..." AZURE_AI_MODEL_DEPLOYMENT_NAME="..." -COSMOS_CONNECTION_STRING="..." +AZURE_COSMOS_ENDPOINT="https://.documents.azure.com:443/" COSMOS_DATABASE_NAME="..." COSMOS_CONTAINER_NAME="..." diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md index 30c1d67a8a7..e08c9dc1184 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md @@ -1,48 +1,103 @@ -# Custom session storage provider +# Custom session storage (Responses protocol) -This sample shows how to provide custom session storage to `ResponsesHostServer`. -The `CustomSessionStoreProvider` selects storage based on the resolved hosting -configuration: +This sample implements custom **MAF agent-session persistence**, including user +and sandbox isolation and optimistic concurrency. The outer Responses store and +function-approval store remain host-managed. This is not a workflow example. -- Local runs use the in-memory `SessionStore` and do not require Cosmos DB. -- Foundry-hosted runs use the custom `CosmosSessionStore` implementation. +## Identity and storage keys -The sample customizes agent-session persistence only. The host's default providers -continue to manage workflow checkpoints and function approvals. +For hosted requests, `FoundryRequestScope.from_context(config, platform_context)` +requires the platform-configured sandbox ID and trusted user/call IDs. A request +whose `agent_session_id` differs from `FOUNDRY_AGENT_SESSION_ID` fails closed. +The provider resolves this scope **before** creating a backend client. -## Azure Cosmos DB setup +The Cosmos partition key is `/scope_key`: the same bounded, framed hash of +**trusted user + sandbox** used by the default hosted store. Each item ID hashes +the host's response/conversation lookup key inside that partition. Changing the +call ID does not change the namespace. Changing either the user or the sandbox +does. No raw platform IDs or tokens are written into document keys or logs. -Before deploying the sample, create an Azure Cosmos DB database and container. The -container must use `/user_id` as its partition key. Each session document includes -the Foundry platform user ID, so session IDs and data are isolated between users. -Set these environment variables to -the existing resources: +The storage key is not `AgentSession.session_id`. A host may save one MAF +snapshot under a response ID and a conversation ID; each key has its own ETag. +The snapshot's inner `session_id` is preserved when loading. Keys are not built +from caller model options or an inner model-service session ID. -- `COSMOS_CONNECTION_STRING` -- `COSMOS_DATABASE_NAME` -- `COSMOS_CONTAINER_NAME` +**Existing unscoped data is not migrated.** The old `/user_id` container layout +and connection-string configuration are intentionally replaced. Use a new +container partitioned by `/scope_key` and start fresh conversations; do not fall +back to user-only documents or silently copy old snapshots. -See [main.py](main.py) for the complete provider and store implementations. +## Conditional writes and deletes -## Running locally +Each request receives a new store view with its own per-key ETags; only the +Cosmos client/pool is shared. A loaded snapshot is replaced with +`MatchConditions.IfNotModified` and the **loaded ETag**. A missing or never-loaded +key is **create-only**, never an unconditional upsert. Successful writes refresh +that key's ETag without changing another key's token. -Copy `.env.example` to `.env`, set the Foundry project and model values, and leave -the Cosmos values unset. The provider creates one in-memory store for the lifetime -of the local server process. +HTTP 409/412 conflicts raise an explicit error. Reload and reconsider the turn; +do not retry the stale write as an unconditional update. A delete also uses the +loaded ETag (loading first if necessary), so it cannot remove another request's +newer snapshot. An absent delete is idempotent. Missing ETags and mismatched +stored scope fail closed; other Cosmos failures propagate without a local +fallback. -Follow [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) -in the parent README, then send a request: +Local runs use isolated in-memory snapshots with the same conditional behavior. +They are lost when the process exits and are **single-user development only**: +local headers/session selectors are not a trusted platform authentication +boundary. Explicit local sessions have separate namespaces. + +## Cosmos prerequisites + +Create the database/container separately and set: + +```text +AZURE_COSMOS_ENDPOINT=https://.documents.azure.com:443/ +COSMOS_DATABASE_NAME= +COSMOS_CONTAINER_NAME= +``` + +The hosted provider uses `azure.identity.aio.ManagedIdentityCredential`, with +the platform-provided `FOUNDRY_AGENT_INSTANCE_CLIENT_ID` when present. Grant that +managed identity **Cosmos DB Built-in Data Contributor** at the narrowest +applicable container scope using Cosmos **data-plane RBAC**, not an account +management role. No Cosmos account key, connection string, password or raw +credential is deployed. The container is not provisioned by `main.py`. + +The model also requires access to the configured Foundry project. Local model +calls use `AzureCliCredential` (`az login`); they do not require Cosmos. + +## Run + +Set `FOUNDRY_PROJECT_ENDPOINT` and `AZURE_AI_MODEL_DEPLOYMENT_NAME`, then follow +the [parent local-host instructions](../../README.md#running-the-agent-host-locally). ```bash -curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi"}' +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input":"Hi"}' ``` -Local session data is lost when the process exits. +The host uses `history_source="agent_server"` and a request-owned agent/client +factory. It reconstructs history from the outer transcript rather than retaining +it on Python instances or enabling inner model storage. Outer `store=false` +does not access/write this custom session store. + +For hosted use, configure the endpoint/database/container in the manifest and +follow the [parent deployment instructions](../../README.md#deploying-the-agent-to-foundry). +The provider closes the Cosmos client and its own credential at shutdown; +request clients are closed after their request, including failed tool entry and +cancellation. + +## Offline checks -## Deploying to Foundry +From `python/`: + +```bash +uv run pytest samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests -q +``` -Set all variables in `.env.example`, including the Cosmos settings, and follow -[Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) -in the parent README. The hosted provider initializes Cosmos DB lazily on its first -request and reuses the client and container for later requests. Each request receives -a session store scoped to the non-empty user ID supplied by Foundry. +These checks use a fake Cosmos container, not an account. They cover create +races, stale updates/deletes, per-key ETags, independent snapshots, canonical +lookup IDs, trusted-scope rejection, and user/sandbox isolation. Live Cosmos +access and deployment need separately approved resources and permissions. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/agent.manifest.yaml index 51cc26b01a6..a045ba5333e 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/agent.manifest.yaml +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/agent.manifest.yaml @@ -17,8 +17,8 @@ template: environment_variables: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - - name: COSMOS_CONNECTION_STRING - value: "{{COSMOS_CONNECTION_STRING}}" + - name: AZURE_COSMOS_ENDPOINT + value: "{{AZURE_COSMOS_ENDPOINT}}" - name: COSMOS_DATABASE_NAME value: "{{COSMOS_DATABASE_NAME}}" - name: COSMOS_CONTAINER_NAME diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/agent.yaml index 5934b928012..4f8a99e5d34 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/agent.yaml +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/agent.yaml @@ -10,8 +10,8 @@ resources: environment_variables: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - - name: COSMOS_CONNECTION_STRING - value: ${COSMOS_CONNECTION_STRING} + - name: AZURE_COSMOS_ENDPOINT + value: ${AZURE_COSMOS_ENDPOINT} - name: COSMOS_DATABASE_NAME value: ${COSMOS_DATABASE_NAME} - name: COSMOS_CONTAINER_NAME diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/main.py index 3601ad2513a..349a5257c90 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/main.py @@ -1,109 +1,261 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-core", +# "azure-cosmos>=4.9.0", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. +"""Trusted user-and-sandbox session snapshots with optimistic concurrency. + +Hosted runs use an existing Cosmos DB container partitioned by /scope_key and +authenticate with managed identity. Local runs use process-local snapshots with +the same create-only/conditional-write behavior; they are not a multi-user +authentication boundary. +""" + +from __future__ import annotations + +import asyncio +import hashlib import os -from contextlib import suppress +import uuid +from contextlib import AsyncExitStack +from copy import deepcopy +from types import TracebackType from typing import Any from agent_framework import Agent, AgentSession, SessionStore from agent_framework.foundry import FoundryChatClient -from agent_framework_foundry_hosting import ResponsesHostServer, StoreProvider -from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext +from agent_framework_foundry_hosting import FoundryRequestScope, ResponsesHostServer, StoreProvider +from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext, get_request_context +from azure.core import MatchConditions from azure.cosmos.aio import ContainerProxy, CosmosClient -from azure.cosmos.exceptions import CosmosResourceNotFoundError -from azure.identity import DefaultAzureCredential +from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceNotFoundError +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv -"""Host an agent with a custom session storage provider. - -The provider uses an in-memory store when the agent runs locally and Azure -Cosmos DB when the agent runs in Foundry. Create the database and container -before deploying the agent. The container must use /user_id as its partition key. - -Environment variables: - FOUNDRY_PROJECT_ENDPOINT: Microsoft Foundry project endpoint. - AZURE_AI_MODEL_DEPLOYMENT_NAME: Model deployment name. - COSMOS_CONNECTION_STRING: Azure Cosmos DB connection string. - COSMOS_DATABASE_NAME: Existing database name. - COSMOS_CONTAINER_NAME: Existing container name partitioned by /user_id. -""" - -load_dotenv() +_CONFLICT = "Another request advanced this agent session; reload before writing." class CosmosSessionStore(SessionStore): - """Persist Agent Framework session snapshots in Azure Cosmos DB.""" + """Keep a request's ETags separate from every other request's working copy.""" - def __init__(self, *, container: ContainerProxy, user_id: str) -> None: + def __init__(self, *, container: ContainerProxy, scope: FoundryRequestScope) -> None: super().__init__() + if ( + not scope.is_hosted + or not scope.session_id.strip() + or not scope.user_id + or not scope.user_id.strip() + or not scope.call_id + ): + raise RuntimeError("Cosmos session storage requires a trusted hosted user and call ID.") self._container = container - self._user_id = user_id + self._scope_key = scope.storage_key + self._etags: dict[str, str | None] = {} - async def get(self, session_id: str) -> AgentSession | None: - """Load a session snapshot, or return None when it does not exist.""" + def _item_id(self, session_id: str) -> str: self.validate_session_id(session_id) + return hashlib.sha256(session_id.encode("utf-8")).hexdigest() + + async def get(self, session_id: str) -> AgentSession | None: + """Load by the host's response/conversation key, not the inner MAF ID.""" + item_id = self._item_id(session_id) try: - item = await self._container.read_item(item=session_id, partition_key=self._user_id) + item = await self._container.read_item(item=item_id, partition_key=self._scope_key) except CosmosResourceNotFoundError: + self._etags[session_id] = None return None - return AgentSession.from_dict(item["session"]) + if item.get("scope_key") != self._scope_key or item.get("id") != item_id: + raise RuntimeError("A stored agent session does not belong to this trusted user and sandbox.") + etag = item.get("_etag") + if not isinstance(etag, str) or not etag: + raise RuntimeError("Stored Cosmos session is missing its ETag.") + session = AgentSession.from_dict(item["session"]) + self._etags[session_id] = etag + return session async def set(self, session_id: str, session: AgentSession) -> None: - """Create or replace a session snapshot.""" - self.validate_session_id(session_id) - item: dict[str, Any] = {"id": session_id, "user_id": self._user_id, "session": session.to_dict()} - await self._container.upsert_item(item) + """Create a new key, or replace only the version this request loaded.""" + item: dict[str, Any] = { + "id": self._item_id(session_id), + "scope_key": self._scope_key, + "session": session.to_dict(), + } + try: + etag = self._etags.get(session_id) + if etag is None: + result = await self._container.create_item(body=item) + else: + result = await self._container.replace_item( + item=item["id"], + body=item, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + except CosmosHttpResponseError as exc: + if exc.status_code not in (409, 412): + raise + raise RuntimeError(_CONFLICT) from exc + new_etag = result.get("_etag") + if not isinstance(new_etag, str) or not new_etag: + raise RuntimeError("Cosmos did not return an ETag after saving.") + self._etags[session_id] = new_etag async def delete(self, session_id: str) -> None: - """Delete a session snapshot when it exists.""" + """Delete the loaded version; an absent key is an idempotent no-op.""" + item_id = self._item_id(session_id) + if session_id not in self._etags: + await self.get(session_id) + etag = self._etags[session_id] + if etag is not None: + try: + await self._container.delete_item( + item=item_id, + partition_key=self._scope_key, + etag=etag, + match_condition=MatchConditions.IfNotModified, + ) + except CosmosResourceNotFoundError: + pass + except CosmosHttpResponseError as exc: + if exc.status_code not in (409, 412): + raise + raise RuntimeError(_CONFLICT) from exc + self._etags.pop(session_id, None) + + +class LocalSessionStore(SessionStore): + """Request-local concurrency tokens over scoped, process-local snapshots.""" + + def __init__(self, snapshots: dict[tuple[str, str], tuple[str, dict[str, Any]]], *, scope_key: str) -> None: + super().__init__() + self._snapshots = snapshots + self._scope_key = scope_key + self._etags: dict[str, str | None] = {} + + def _key(self, session_id: str) -> tuple[str, str]: self.validate_session_id(session_id) - with suppress(CosmosResourceNotFoundError): - await self._container.delete_item(item=session_id, partition_key=self._user_id) + return self._scope_key, session_id + + async def get(self, session_id: str) -> AgentSession | None: + item = self._snapshots.get(self._key(session_id)) + self._etags[session_id] = item[0] if item else None + return AgentSession.from_dict(deepcopy(item[1])) if item else None + + async def set(self, session_id: str, session: AgentSession) -> None: + key = self._key(session_id) + item = self._snapshots.get(key) + if (item[0] if item else None) != self._etags.get(session_id): + raise RuntimeError(_CONFLICT) + etag = uuid.uuid4().hex + self._snapshots[key] = etag, deepcopy(session.to_dict()) + self._etags[session_id] = etag + + async def delete(self, session_id: str) -> None: + key = self._key(session_id) + if session_id not in self._etags: + await self.get(session_id) + item = self._snapshots.get(key) + if item is not None and item[0] != self._etags[session_id]: + raise RuntimeError(_CONFLICT) + self._snapshots.pop(key, None) + self._etags.pop(session_id, None) class CustomSessionStoreProvider(StoreProvider[SessionStore]): - """Provide in-memory storage locally and Cosmos-backed storage when hosted.""" + """Reuse only the backend; each request gets an isolated ETag/working-copy view.""" def __init__(self) -> None: - self._local_store: SessionStore | None = None + self._local_snapshots: dict[tuple[str, str], tuple[str, dict[str, Any]]] = {} self._cosmos_client: CosmosClient | None = None self._cosmos_container: ContainerProxy | None = None + self._cosmos_credential: ManagedIdentityCredential | None = None def get_store(self, *, config: AgentConfig, platform_context: FoundryAgentRequestContext) -> SessionStore: - """Return the session store for the current hosting environment.""" + scope = FoundryRequestScope.from_context(config, platform_context, local_session_id="local-development") if not config.is_hosted: - if self._local_store is None: - self._local_store = SessionStore() - return self._local_store - - if not platform_context.user_id: - raise RuntimeError("Foundry-hosted session storage requires a user ID in the platform context.") + return LocalSessionStore(self._local_snapshots, scope_key=scope.storage_key) + if not scope.user_id or not scope.user_id.strip(): + raise RuntimeError("Foundry-hosted session storage requires a trusted user ID.") if self._cosmos_container is None: - self._cosmos_client = CosmosClient.from_connection_string(os.environ["COSMOS_CONNECTION_STRING"]) - database = self._cosmos_client.get_database_client(os.environ["COSMOS_DATABASE_NAME"]) - self._cosmos_container = database.get_container_client(os.environ["COSMOS_CONTAINER_NAME"]) + endpoint = os.environ["AZURE_COSMOS_ENDPOINT"] + database_name = os.environ["COSMOS_DATABASE_NAME"] + container_name = os.environ["COSMOS_CONTAINER_NAME"] + self._cosmos_credential = ManagedIdentityCredential( + client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID") + ) + self._cosmos_client = CosmosClient(url=endpoint, credential=self._cosmos_credential) + database = self._cosmos_client.get_database_client(database_name) + self._cosmos_container = database.get_container_client(container_name) + return CosmosSessionStore(container=self._cosmos_container, scope=scope) - return CosmosSessionStore(container=self._cosmos_container, user_id=platform_context.user_id) + async def close(self) -> None: + """Close only the backend client and credential created by this provider.""" + client, credential = self._cosmos_client, self._cosmos_credential + self._cosmos_client = self._cosmos_container = self._cosmos_credential = None + async with AsyncExitStack() as cleanup: + if credential is not None: + cleanup.push_async_callback(credential.close) + if client is not None: + cleanup.push_async_callback(client.close) -def main() -> None: - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=DefaultAzureCredential(), +def create_agent() -> Agent: + """Create and clean up the request's own Foundry transports and credential.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() ) - agent = Agent( - client=client, + + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) + + return Agent( + client=RequestClient( + project_endpoint=endpoint, + model=model, + credential=credential, + default_headers=get_request_context().platform_headers(), + ), instructions="You are a friendly assistant. Keep your answers brief.", - default_options={"store": False}, ) + +async def main() -> None: + load_dotenv() + provider = CustomSessionStoreProvider() server = ResponsesHostServer( - agent, - agent_session_store_provider=CustomSessionStoreProvider(), + agent=create_agent, history_source="agent_server", agent_session_store_provider=provider ) - server.run() + server.shutdown_handler(provider.close) + try: + await server.run_async() + finally: + await provider.close() if __name__ == "__main__": - main() + asyncio.run(main()) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests/test_storage.py b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests/test_storage.py new file mode 100644 index 00000000000..d3164c47329 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests/test_storage.py @@ -0,0 +1,154 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Focused, account-free checks of scoped Cosmos snapshots and conditional writes.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType +from unittest.mock import AsyncMock, MagicMock + +import pytest +from agent_framework import AgentSession +from agent_framework_foundry_hosting import FoundryRequestScope +from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext +from azure.core import MatchConditions +from azure.cosmos.aio import ContainerProxy +from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceNotFoundError + + +@pytest.fixture +def sample() -> ModuleType: + spec = importlib.util.spec_from_file_location("sample_custom_storage", Path(__file__).parents[1] / "main.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _scope(user: str = "user", sandbox: str = "sandbox") -> FoundryRequestScope: + return FoundryRequestScope(session_id=sandbox, user_id=user, call_id="call", is_hosted=True) + + +async def test_cosmos_canonical_keys_conditional_writes_and_delete(sample: ModuleType) -> None: + container = MagicMock(spec=ContainerProxy) + container.read_item = AsyncMock(side_effect=CosmosResourceNotFoundError(message="Missing.")) + container.create_item = AsyncMock(return_value={"_etag": "v1"}) + container.replace_item = AsyncMock(side_effect=[{"_etag": "v2"}, {"_etag": "v3"}]) + container.delete_item = AsyncMock() + store = sample.CosmosSessionStore(container=container, scope=_scope()) + snapshot = AgentSession(session_id="inner-maf-id") + + assert await store.get("conversation") is None + await store.set("conversation", snapshot) + await store.set("conversation", snapshot) + await store.set("response", snapshot) + await store.set("conversation", snapshot) + body = container.create_item.await_args_list[0].kwargs["body"] + assert body["id"] != snapshot.session_id + assert body["scope_key"] == _scope().storage_key + assert body["session"]["session_id"] == snapshot.session_id + writes = container.replace_item.await_args_list + assert [call.kwargs["etag"] for call in writes] == ["v1", "v2"] + assert all(call.kwargs["match_condition"] is MatchConditions.IfNotModified for call in writes) + + container.read_item.side_effect = None + container.read_item.return_value = {**body, "_etag": "v3"} + loaded = await store.get("conversation") + assert loaded is not None and loaded.session_id == "inner-maf-id" + await store.delete("conversation") + assert container.delete_item.await_args is not None + assert container.delete_item.await_args.kwargs["etag"] == "v3" + assert container.delete_item.await_args.kwargs["partition_key"] == _scope().storage_key + await store.set("conversation", snapshot) + container.upsert_item.assert_not_called() + + +async def test_cosmos_conflicts_missing_etags_and_scope_mismatch_fail_closed(sample: ModuleType) -> None: + container = MagicMock(spec=ContainerProxy) + snapshot = AgentSession(session_id="inner") + store = sample.CosmosSessionStore(container=container, scope=_scope()) + container.create_item = AsyncMock(side_effect=CosmosHttpResponseError(status_code=409, message="Exists.")) + with pytest.raises(RuntimeError, match="Another request advanced"): + await store.set("key", snapshot) + + body = {"id": store._item_id("key"), "scope_key": _scope().storage_key, "session": snapshot.to_dict()} + container.read_item = AsyncMock(return_value=body) + with pytest.raises(RuntimeError, match="missing its ETag"): + await store.get("key") + container.read_item.return_value = {**body, "_etag": "stale"} + assert await store.get("key") is not None + container.replace_item = AsyncMock(side_effect=CosmosHttpResponseError(status_code=412, message="Stale.")) + with pytest.raises(RuntimeError, match="Another request advanced"): + await store.set("key", snapshot) + container.delete_item = AsyncMock(side_effect=CosmosHttpResponseError(status_code=412, message="Stale.")) + with pytest.raises(RuntimeError, match="Another request advanced"): + await store.delete("key") + for scope in (_scope(user="other-user"), _scope(sandbox="other-sandbox")): + container.read_item.return_value = {**body, "scope_key": scope.storage_key, "_etag": "v1"} + with pytest.raises(RuntimeError, match="trusted user and sandbox"): + await store.get("key") + container.upsert_item.assert_not_called() + + +async def test_local_snapshots_preserve_user_sandbox_and_cas_semantics(sample: ModuleType) -> None: + provider = sample.CustomSessionStoreProvider() + config = MagicMock(spec=AgentConfig, is_hosted=False) + context = FoundryAgentRequestContext(user_id="user", session_id="sandbox") + first = provider.get_store(config=config, platform_context=context) + second = provider.get_store(config=config, platform_context=context) + await first.set("key", AgentSession(session_id="inner")) + loaded = await second.get("key") + assert loaded is not None and loaded.session_id == "inner" + await first.set("key", AgentSession(session_id="newer")) + with pytest.raises(RuntimeError, match="Another request advanced"): + await second.set("key", loaded) + for isolated in ( + FoundryAgentRequestContext(user_id="other-user", session_id="sandbox"), + FoundryAgentRequestContext(user_id="user", session_id="other-sandbox"), + ): + store = provider.get_store(config=config, platform_context=isolated) + assert await store.get("key") is None + await first.delete("key") + assert await second.get("key") is None + + +def test_provider_rejects_missing_or_mismatched_trusted_identity( + sample: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + config = MagicMock(spec=AgentConfig, is_hosted=True, session_id="sandbox") + constructor = MagicMock() + monkeypatch.setattr(sample, "CosmosClient", constructor) + for context in ( + FoundryAgentRequestContext(user_id="user", session_id="other", call_id="call"), + FoundryAgentRequestContext(session_id="sandbox", call_id="call"), + FoundryAgentRequestContext(user_id="user", session_id="sandbox"), + ): + with pytest.raises(RuntimeError): + sample.CustomSessionStoreProvider().get_store(config=config, platform_context=context) + constructor.assert_not_called() + + +async def test_provider_owns_only_its_managed_identity_backend( + sample: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AZURE_COSMOS_ENDPOINT", "https://cosmos.test") + monkeypatch.setenv("COSMOS_DATABASE_NAME", "database") + monkeypatch.setenv("COSMOS_CONTAINER_NAME", "container") + credential, client = MagicMock(), MagicMock() + credential.close, client.close = AsyncMock(), AsyncMock() + identity, constructor = MagicMock(return_value=credential), MagicMock(return_value=client) + monkeypatch.setattr(sample, "ManagedIdentityCredential", identity) + monkeypatch.setattr(sample, "CosmosClient", constructor) + provider = sample.CustomSessionStoreProvider() + config = MagicMock(spec=AgentConfig, is_hosted=True, session_id="sandbox") + first = provider.get_store(config=config, platform_context=FoundryAgentRequestContext(user_id="user", call_id="a")) + second = provider.get_store(config=config, platform_context=FoundryAgentRequestContext(user_id="user", call_id="b")) + assert first is not second + assert first._scope_key == second._scope_key == _scope().storage_key + constructor.assert_called_once_with(url="https://cosmos.test", credential=credential) + await provider.close() + await provider.close() + client.close.assert_awaited_once() + credential.close.assert_awaited_once() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md index cf13482f705..ec85bb4a092 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md @@ -1,127 +1,114 @@ -# What this sample demonstrates - -An [Agent Framework](https://github.com/microsoft/agent-framework) agent that uses a local shell tool and a code interpreter tool for working with files, and hosted using the **Responses protocol**. - -## How It Works - -### Model Integration - -The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes. - -See [main.py](main.py) for the full implementation. - -### Agent Hosting - -The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. - -### Tools - -This agent uses four tools: - -1. **Get Current Working Directory Tool (`get_cwd`)** – Returns the current working directory of the agent host process. -2. **List Files Tool (`list_files`)** – Lists the files in a specified directory. -3. **Read File Tool (`read_file`)** – Reads the contents of a specified file. -4. **Code Interpreter Tool (`code_interpreter`)** – Allows the agent to execute Python code in a safe sandboxed environment. -5. **Web Search Tool (`web_search`)** – Allows the agent to perform web searches using the Bing Search API. - -> In this sample, the filesystem tools are function tools defined in Python using the `@tool` decorator from the Agent Framework. The code interpreter tool and web search tool are managed tools provided by [Foundry Toolbox](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox). Learn more about foundry toolbox integration with hosted agents with this [sample](../foundry_toolbox/). - -## Running the Agent Host - -Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. - -An extra environment variable must be set to point to the toolbox MCP endpoint. You can provide it in one of two ways: - -**Option A – Set `FOUNDRY_TOOLBOX_ENDPOINT` directly** (recommended for local development): +# Session files (Responses protocol) + +This agent reads **only explicitly uploaded UTF-8 files in `$HOME/sample_files`** +inside the current Foundry hosted sandbox. It does not expose the working +directory, accept arbitrary directories, or read the sample's packaged resources +automatically. `list_files()` lists regular uploads; `read_file(filename)` takes a +single filename, not a path. + +The reader opens every directory component and the file without following +symlinks, using directory descriptors rather than a check-then-open pathname. +Replacing a directory or file with a symlink cannot redirect a read outside the +upload directory. It rejects traversal, absolute paths, Windows-style paths, +control characters, directory/file symlinks, non-regular files, invalid UTF-8 and +files larger than **1,000,000 bytes**. It checks size before reading, then uses a +bounded read to catch growth during the read. POSIX descriptor-relative, +`O_NOFOLLOW` and `O_DIRECTORY` support are required; unsupported platforms fail +closed rather than falling back to an unsafe reader. + +## Prerequisites and lifecycle + +Set `FOUNDRY_PROJECT_ENDPOINT` and `AZURE_AI_MODEL_DEPLOYMENT_NAME`, plus either +`TOOLBOX_ENDPOINT` or `TOOLBOX_NAME`. The Toolbox needs a code-interpreter tool; +see the [Toolbox sample](../foundry_toolbox/). Authenticate local runs with +`az login`. Deployed runs use the sandbox's managed identity. + +`ResponsesHostServer(agent=create_agent, history_source="agent_server")` creates +fresh clients and a Toolbox MCP connection for each request and closes their +transports afterward. The MCP writer therefore inherits **this** request's +platform call ID, not an earlier caller's. The outer Responses service supplies +history; the host disables downstream model storage. An outer `store=false` +request writes no host-managed state, but does not undo external tool side +effects or delete uploads. + +Foundry session files and Toolbox code-interpreter container files are different +resources. Reading an upload returns its text; it does not mount that upload into +the Toolbox container. This sample does not implement native generated-file +citations or automatically close microsoft/agent-framework#7916. + +## Upload and read locally + +Run the host using the [parent instructions](../../README.md#running-the-agent-host-locally). +In the same environment and with the **same `HOME`**, explicitly stage the +packaged report: ```bash -export FOUNDRY_TOOLBOX_ENDPOINT="https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" -``` - -Or in PowerShell: - -```powershell -$env:FOUNDRY_TOOLBOX_ENDPOINT="https://.services.ai.azure.com/api/projects//toolboxes//mcp?api-version=v1" -``` - -**Option B – Set `TOOLBOX_NAME`** (used automatically by the Foundry hosting scaffolding after `azd provision`): - -The agent derives the endpoint at runtime as: -``` -{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1 +uv run python upload_file.py resources/contoso_q1_2026_report.txt --local +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input":"Read contoso_q1_2026_report.txt and compare Q1 revenue."}' ``` -When deployed via `azd provision`, the scaffolding injects `TOOLBOX_NAME=agent-tools` and `FOUNDRY_PROJECT_ENDPOINT` automatically from the provisioned resources declared in [`agent.manifest.yaml`](agent.manifest.yaml). +The helper applies the same bounded-read and symlink checks to the selected +source and destination. `--local` never calls Azure. Local query/body session IDs +do **not** create separate filesystem sandboxes: a local server is a single-user +development process. To simulate two sandboxes, run hosts with separate `HOME` +directories and upload only to the first. The second must list no uploads and +must not be able to read the first host's file. Do not expose this local server +as an authenticated multi-user production service. -## Interacting with the agent +## Upload to a hosted sandbox -> Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Use this README for sample queries you can send to the agent. - -Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. For example: +Deploy using the [parent instructions](../../README.md#deploying-the-agent-to-foundry), +then create/select a Foundry hosted session and set `FOUNDRY_AGENT_NAME`. +Use the **Foundry `agent_session_id`**, not an outer `response.id`, +`previous_response_id`, conversation ID or MAF `AgentSession.session_id`. ```bash -curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Find the quarterly report under `{cwd}/resources` and tell me the difference of revenue between q1 2026 and q1 2025?"}' +uv run python upload_file.py resources/contoso_q1_2026_report.txt \ + --session-id "" ``` -> When ruuning locally, it runs within the project directory, which contains the entire sample, so the `{cwd}/resources` path in the query above will allow the agent to locate the `resources` folder included with this sample and read the `contoso_q1_2026_report.txt` file from that folder. - -The server will respond with a JSON object containing the response text and a response ID. You can use this response ID to continue the conversation in subsequent requests. - -## Deploying the Agent to Foundry - -To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. - -## Uploading a file to a session - -Deploying the agent won't automatically upload the files included with this sample to Foundry. To make these files available to the agent at runtime, you must upload them to a [hosted agent session](https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions). Files are tied to a specific hosted agent session, so each time you start a new session you will need to upload the files again if the agent needs access to them during that session. +The SDK uploads to `sample_files/contoso_q1_2026_report.txt`, relative to that +sandbox's home directory. A portal/CLI upload to the home directory's root will +not be visible to these tools; specify the `sample_files/` destination, or use +this helper. No real upload is performed by the sample's offline tests. -After you deploy the agent to Foundry, you have two ways to interact with the agent: +Send the request to the deployed agent's Responses endpoint, routing to the same +sandbox. Responses supports the query selector: -1. Using `azd ai agent invoke`. -2. Through the Foundry portal. - -### Using `azd ai agent invoke` - -After successfully deploying the agent to Foundry, run the following command: - -> You must remain in the directory where your `azd` project is initialized so that the CLI can locate the deployed agent configuration. - -```bash -azd ai agent invoke "Hi!" +```text +POST ?agent_session_id= +{"input":"Read contoso_q1_2026_report.txt and compare Q1 revenue."} ``` -The command will invoke the agent and the server will create a new session if one does not already exist for this interaction, returning the agent's response from the hosted agent session. Run the following if you want to force a new session: +The equivalent body selector is: -```bash -azd ai agent invoke --new-session "Hi!" +```json +{ + "agent_session_id": "", + "input": "Read contoso_q1_2026_report.txt and compare Q1 revenue." +} ``` -Run the following command to upload a file to the hosted agent session: +Use **one** selector. Foundry routes it to the sandbox; the host validates the +resolved request identity against the platform-configured +`FOUNDRY_AGENT_SESSION_ID`. A mismatch fails closed. Neither a caller option nor +a filename can select another sandbox's home directory. -```bash -azd ai agent files upload -f -``` +For a hosted isolation check, upload only to sandbox A and read there. Send the +same prompt to a fresh sandbox B without uploading: its `list_files()` must be +empty and the named read must fail. Upload separately to B if it needs the file. +Do not claim this live check ran unless those resources and uploads were +explicitly authorized. -> The above command will automatically detect the last active session and upload the file to that session without requiring you to explicitly provide a session ID. It is also possible to specify a particular session ID to upload the file to a specific hosted agent session by using the `--session-id` flag. Run `azd ai agent files upload -h` to see the full list of options and flags available for the `upload` command. +## Offline checks -Once the file is uploaded to the hosted agent session, the agent will be able to access it during that session and use it to respond to queries that reference the uploaded file. - -Invoke the agent again with a query that references the uploaded file to see how it can now use the file in its responses. For example: +From `python/`, run: ```bash -azd ai agent invoke "Find the quarterly report under the home directory and tell me the difference of revenue between q1 2026 and q1 2025?" +uv run pytest samples/04-hosting/foundry-hosted-agents/responses/files/tests -q ``` -### Using the Foundry Portal - -Similar to using the `azd` CLI, you must invoke the agent first to create a session: - -![alt text](./resources/start-a-session.png) - -Once the session is created, you can grab the session ID and use `azd ai agent files upload --session-id ` to upload files to that specific hosted agent session. - -![alt text](./resources/session-started.png) - -Or you can upload files directly through the Foundry portal by navigating to Files tab in the agent playground: - -![alt text](./resources/file-upload-portal.png) +The tests use temporary home directories, including descriptor-replacement +checks. They require no Foundry project, credentials, deployment or real files. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/file_access.py b/python/samples/04-hosting/foundry-hosted-agents/responses/files/file_access.py new file mode 100644 index 00000000000..6dbd56b2dee --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/file_access.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Bounded, descriptor-relative access to this sandbox's explicit uploads.""" + +from __future__ import annotations + +import os +import stat +from contextlib import suppress +from pathlib import Path + +UPLOAD_DIRECTORY = "sample_files" +MAX_FILE_BYTES = 1_000_000 + + +def validate_filename(filename: str) -> None: + """Accept one portable filename, never a path supplied by the model.""" + if ( + not isinstance(filename, str) + or not filename + or filename in (".", "..") + or filename != filename.strip() + or any(character in filename for character in ("/", "\\", ":")) + or any(ord(character) < 32 or ord(character) == 127 for character in filename) + or len(os.fsencode(filename)) > 255 + ): + raise ValueError("filename must be a single file name in sample_files, not a path.") + + +def _directory_flags() -> int: + if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY") or os.open not in os.supports_dir_fd: + raise RuntimeError("Secure file access requires POSIX no-follow, descriptor-relative directory opens.") + return os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + + +def _open_directory(path: Path) -> int: + """Walk from the filesystem root without following any directory symlink.""" + flags = _directory_flags() + if not path.is_absolute() or ".." in path.parts: + raise ValueError("The upload directory must be under an absolute, non-traversing HOME.") + directory = os.open(path.anchor, flags) + try: + for component in path.parts[1:]: + child = os.open(component, flags, dir_fd=directory) + os.close(directory) + directory = child + return directory + except BaseException: + os.close(directory) + raise + + +def _open_upload_directory(*, create: bool = False) -> int: + home = _open_directory(Path.home()) + try: + if create: + with suppress(FileExistsError): + os.mkdir(UPLOAD_DIRECTORY, mode=0o700, dir_fd=home) + return os.open(UPLOAD_DIRECTORY, _directory_flags(), dir_fd=home) + finally: + os.close(home) + + +def _read_file(directory: int, filename: str) -> bytes: + validate_filename(filename) + # O_NONBLOCK prevents a substituted FIFO/device from blocking before fstat rejects it. + descriptor = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory) + try: + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + raise ValueError("Only regular uploaded files can be read.") + if metadata.st_size > MAX_FILE_BYTES: + raise ValueError("Only UTF-8 files of at most 1,000,000 bytes can be read.") + except BaseException: + os.close(descriptor) + raise + with os.fdopen(descriptor, "rb") as file: + # Recheck the bound in case the file grew after fstat. + data = file.read(MAX_FILE_BYTES + 1) + if len(data) > MAX_FILE_BYTES: + raise ValueError("Only UTF-8 files of at most 1,000,000 bytes can be read.") + data.decode("utf-8") + return data + + +def list_uploaded_files() -> list[str]: + """List regular files only; a session with no uploads has an empty list.""" + try: + directory = _open_upload_directory() + except FileNotFoundError: + return [] + try: + with os.scandir(directory) as entries: + return sorted(entry.name for entry in entries if entry.is_file(follow_symlinks=False)) + finally: + os.close(directory) + + +def read_uploaded_file(filename: str) -> str: + """Read only one file in the current sandbox's HOME/sample_files directory.""" + validate_filename(filename) + directory = _open_upload_directory() + try: + return _read_file(directory, filename).decode("utf-8") + finally: + os.close(directory) + + +def read_upload_source(source: Path) -> bytes: + """Read an explicitly selected local upload, with the same size and symlink checks.""" + source = source.expanduser().absolute() + validate_filename(source.name) + directory = _open_directory(source.parent) + try: + return _read_file(directory, source.name) + finally: + os.close(directory) + + +def write_local_upload(filename: str, data: bytes) -> None: + """Stage an explicit local upload under HOME, not in the code directory.""" + validate_filename(filename) + if len(data) > MAX_FILE_BYTES: + raise ValueError("Only UTF-8 files of at most 1,000,000 bytes can be uploaded.") + data.decode("utf-8") + directory = _open_upload_directory(create=True) + try: + descriptor = os.open( + filename, + os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK, + mode=0o600, + dir_fd=directory, + ) + try: + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + raise ValueError("Only regular uploaded files can be written.") + except BaseException: + os.close(descriptor) + raise + with os.fdopen(descriptor, "wb") as file: + file.truncate() + file.write(data) + finally: + os.close(directory) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/files/main.py index a3378661d99..2eb7a213423 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/files/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/main.py @@ -1,76 +1,92 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. +"""Expose only explicitly uploaded session files, with request-owned Toolbox connections.""" + +from __future__ import annotations + import asyncio import os +from contextlib import AsyncExitStack +from types import TracebackType from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient, FoundryToolbox, ResponsesHostServer -from azure.identity import DefaultAzureCredential +from agent_framework.foundry import FoundryChatClient, FoundryToolbox +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv +from file_access import list_uploaded_files, read_uploaded_file -# Load environment variables from .env file -load_dotenv() +@tool(description="List regular files uploaded to this session's sample_files folder.", approval_mode="never_require") +def list_files() -> list[str]: + """List this sandbox's uploads without accepting an arbitrary directory.""" + return list_uploaded_files() -@tool(description="Get the current working directory.", approval_mode="never_require") -def get_cwd() -> str: - """Get the current working directory.""" - try: - return os.getcwd() - except Exception as e: - return f"Error getting current working directory: {e}" +@tool( + description="Read one named UTF-8 file uploaded to this session's sample_files folder.", + approval_mode="never_require", +) +def read_file(filename: str) -> str: + """Read a bounded upload, rejecting paths, symlinks and non-regular files.""" + return read_uploaded_file(filename) -@tool(description="List files in a directory.", approval_mode="never_require") -def list_files(directory: str) -> list[str]: - """List files in a directory.""" - try: - return os.listdir(directory) - except Exception as e: - return [f"Error listing files in {directory}: {e}"] +def create_agent() -> Agent: + """Create the client and MCP connection inside the current request context.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() + ) -@tool(description="Read the contents of a file.", approval_mode="never_require") -def read_file(file_path: str) -> str: - """Read the contents of a file.""" - try: - with open(file_path) as f: - return f.read() - except Exception as e: - return f"Error reading file {file_path}: {e}" - + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self -async def main(): - credential = DefaultAzureCredential() + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) - # FoundryToolbox resolves the toolbox endpoint from the environment - # (TOOLBOX_ENDPOINT, or FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME), authenticates - # every request with the credential, and transparently forwards the platform - # per-request call-id to the toolbox. The hosting server enters the agent, which - # connects the toolbox on first use and closes it at shutdown. toolbox = FoundryToolbox(credential) - - # Create the chat client - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=credential, - ) - - agent = Agent( - client=client, + return Agent( + client=RequestClient( + project_endpoint=endpoint, + model=model, + credential=credential, + default_headers=get_request_context().platform_headers(), + ), instructions=( - "You are a friendly assistant. Keep your answers brief. " - "Make sure all mathematical calculations are performed using the code interpreter " - "instead of mental arithmetic." + "Use list_files and read_file only for explicitly uploaded files in sample_files. " + "Pass a single filename, never a directory or absolute path. " + "Use the code interpreter for calculations on the returned text." ), - tools=[get_cwd, list_files, read_file, toolbox], - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, + tools=[list_files, read_file, toolbox], ) - server = ResponsesHostServer(agent) + + +async def main() -> None: + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") await server.run_async() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/tests/test_file_access.py b/python/samples/04-hosting/foundry-hosted-agents/responses/files/tests/test_file_access.py new file mode 100644 index 00000000000..ad7b7d3978e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/tests/test_file_access.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Focused negative checks for the sandbox file-access boundary.""" + +from __future__ import annotations + +import importlib.util +import os +import stat +from pathlib import Path +from types import ModuleType, SimpleNamespace +from typing import Any + +import pytest + +pytestmark = pytest.mark.skipif( + not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"), + reason="This hosted sample requires POSIX no-follow directory opens.", +) + + +@pytest.fixture +def access(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ModuleType: + monkeypatch.setenv("HOME", str(tmp_path)) + spec = importlib.util.spec_from_file_location("sample_file_access", Path(__file__).parents[1] / "file_access.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_local_upload_round_trip_and_cross_sandbox_isolation( + access: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "report.txt" + source.write_text("Explicit upload.", encoding="utf-8") + access.write_local_upload(source.name, access.read_upload_source(source)) + assert access.list_uploaded_files() == ["report.txt"] + assert access.read_uploaded_file("report.txt") == "Explicit upload." + other = tmp_path / "other-sandbox" + other.mkdir() + monkeypatch.setenv("HOME", str(other)) + assert access.list_uploaded_files() == [] + with pytest.raises(FileNotFoundError): + access.read_uploaded_file("report.txt") + + +def test_paths_symlinks_and_nonregular_files_are_rejected(access: ModuleType, tmp_path: Path) -> None: + root = tmp_path / "sample_files" + root.mkdir() + private = tmp_path / "private.txt" + private.write_text("Private data.", encoding="utf-8") + (root / "link.txt").symlink_to(private) + (root / "directory").mkdir() + os.mkfifo(root / "pipe") + for filename in ("", "..", "../private.txt", str(private), r"C:\private.txt", "file\x00.txt", "file\n.txt"): + with pytest.raises(ValueError, match="single file name"): + access.read_uploaded_file(filename) + with pytest.raises(OSError): + access.read_uploaded_file("link.txt") + for filename in ("directory", "pipe"): + with pytest.raises(ValueError, match="regular uploaded files"): + access.read_uploaded_file(filename) + with pytest.raises(OSError): + access.write_local_upload("link.txt", b"Do not overwrite private data.") + assert private.read_text(encoding="utf-8") == "Private data." + assert access.list_uploaded_files() == [] + + +def test_directory_symlinks_and_missing_no_follow_fail_closed( + access: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + private = tmp_path / "private" + private.mkdir() + (tmp_path / "sample_files").symlink_to(private, target_is_directory=True) + with pytest.raises(OSError): + access.list_uploaded_files() + home_link = tmp_path / "home-link" + home_link.symlink_to(private, target_is_directory=True) + monkeypatch.setenv("HOME", str(home_link)) + with pytest.raises(OSError): + access.read_uploaded_file("report.txt") + monkeypatch.delattr(access.os, "O_NOFOLLOW") + with pytest.raises(RuntimeError, match="no-follow"): + access.read_uploaded_file("report.txt") + + +def test_byte_limit_is_checked_before_read_and_after_growth( + access: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path / "sample_files" + root.mkdir() + (root / "limit.txt").write_bytes(b"x" * 1_000_000) + assert len(access.read_uploaded_file("limit.txt")) == 1_000_000 + (root / "large.txt").write_bytes(b"x" * 1_000_001) + original_fdopen = os.fdopen + reads: list[int] = [] + + class Reader: + def __init__(self, descriptor: int) -> None: + self.file = original_fdopen(descriptor, "rb") + + def __enter__(self) -> Reader: + return self + + def __exit__(self, *args: Any) -> None: + self.file.close() + + def read(self, size: int) -> bytes: + reads.append(size) + return self.file.read(size) + + monkeypatch.setattr(access.os, "fdopen", lambda descriptor, mode: Reader(descriptor)) + with pytest.raises(ValueError, match="1,000,000 bytes"): + access.read_uploaded_file("large.txt") + assert reads == [] + monkeypatch.setattr(access.os, "fstat", lambda descriptor: SimpleNamespace(st_mode=stat.S_IFREG, st_size=1)) + with pytest.raises(ValueError, match="1,000,000 bytes"): + access.read_uploaded_file("large.txt") + assert reads == [1_000_001] + + +@pytest.mark.parametrize("replacement", ["directory", "file"]) +def test_replacement_cannot_redirect_a_read( + access: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, replacement: str +) -> None: + root, private = tmp_path / "sample_files", tmp_path / "private" + root.mkdir() + private.mkdir() + (root / "report.txt").write_text("Original upload.", encoding="utf-8") + (private / "report.txt").write_text("Private data.", encoding="utf-8") + original_open = os.open + + def replace(path: Any, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: + if path == "report.txt": + if replacement == "directory": + root.rename(tmp_path / "original-uploads") + root.symlink_to(private, target_is_directory=True) + else: + (root / "report.txt").unlink() + (root / "report.txt").symlink_to(private / "report.txt") + return original_open(path, flags, mode, dir_fd=dir_fd) + + monkeypatch.setattr(access.os, "open", replace) + monkeypatch.setattr(access.os, "supports_dir_fd", {*os.supports_dir_fd, replace}) + if replacement == "directory": + assert access.read_uploaded_file("report.txt") == "Original upload." + else: + with pytest.raises(OSError): + access.read_uploaded_file("report.txt") diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/upload_file.py b/python/samples/04-hosting/foundry-hosted-agents/responses/files/upload_file.py new file mode 100644 index 00000000000..f04ff3fd044 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/upload_file.py @@ -0,0 +1,56 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "azure-ai-projects>=2.2.0,<2.8.0", +# "azure-identity", +# "python-dotenv", +# ] +# /// + +# Copyright (c) Microsoft. All rights reserved. + +"""Upload one bounded UTF-8 file into a selected sandbox's sample_files directory.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from azure.ai.projects import AIProjectClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from file_access import UPLOAD_DIRECTORY, read_upload_source, write_local_upload + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("file", type=Path, help="Explicitly selected local UTF-8 file, at most 1,000,000 bytes") + destination = parser.add_mutually_exclusive_group(required=True) + destination.add_argument("--session-id", help="Foundry agent_session_id, not a response ID or MAF session ID") + destination.add_argument("--local", action="store_true", help="Stage under this process's HOME for a local host") + args = parser.parse_args() + data = read_upload_source(args.file) + if args.local: + write_local_upload(args.file.name, data) + print(f"Staged {UPLOAD_DIRECTORY}/{args.file.name} for the local host.") + return + + load_dotenv() + with ( + AzureCliCredential() as credential, + AIProjectClient( + endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], credential=credential, allow_preview=True + ) as project, + ): + project.agents.upload_session_file( + agent_name=os.environ["FOUNDRY_AGENT_NAME"], + session_id=args.session_id, + content=data, + path=f"{UPLOAD_DIRECTORY}/{args.file.name}", + ) + print(f"Uploaded {UPLOAD_DIRECTORY}/{args.file.name} to the selected Foundry sandbox.") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/.env.example index 7ac02ecb2e3..769c40ef3c4 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/.env.example +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/.env.example @@ -4,3 +4,5 @@ AZURE_AI_MODEL_DEPLOYMENT_NAME="..." AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME="text-embedding-3-small" # Name of the Foundry Memory Store the agent should read/write to. MEMORY_STORE_NAME="agent_framework_memory" +# Explicit single-user local scope; never used as a fallback on hosted requests. +LOCAL_MEMORY_USER_ID="local-developer" diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md index 537bc943aa3..49ea7b029bc 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md @@ -16,7 +16,27 @@ The agent uses `FoundryChatClient` from the Agent Framework to create a Response 2. **Searches for contextual memories** matching the current user message and injects them into the model context. 3. **Updates the store** with new facts inferred from the conversation. -Crucially, the provider is constructed with `project_client=client.project_client` — i.e. it reuses the `AIProjectClient` that `FoundryChatClient` already created, instead of allocating a second one. This keeps a single authentication context and connection pool for both chat and memory operations. +The zero-argument `create_agent` factory builds a new agent, credential, project +client and Memory provider **inside each request**. Chat and Memory share that +request's project client, not a process-wide client. Its headers capture the +current platform call ID for both Memory and model calls; raw user IDs are not +forwarded. The request-owned client's context manager closes its own OpenAI and +project transports and credential on completion or cancellation. It does not +change ownership of developer-supplied clients elsewhere in the framework. + +The Memory namespace is a framed hash of the **trusted platform user ID**. It +intentionally excludes the sandbox, conversation, MAF session and call IDs: the +same user shares long-term memories across their hosted sessions, while other +users have different namespaces. The host still validates the platform sandbox +and requires a trusted user and call ID before constructing the integration. +Caller model options cannot choose that namespace, and literal template strings +such as `{{$userId}}` are **not** substituted by the framework. + +`history_source="agent_server"` supplies conversation history from the outer +Responses service and disables inner model storage. This is separate from +application-owned long-term Memory: outer `store=false` disables host-managed +state, **not** the Memory provider's deliberate read/write side effects. Apply +your application's consent and retention policy before enabling Memory. See [main.py](main.py) for the full implementation. @@ -33,7 +53,12 @@ The agent is hosted using the [Agent Framework](https://github.com/microsoft/age ### Required RBAC -Your identity (or the Managed Identity running the container in production) needs **Azure AI User** on the Foundry project scope. This single role covers both provisioning the memory store with `provision_memory_store.py` and reading/writing memories from `main.py`. +Your provisioning identity and the deployed agent's managed identity need +**Foundry User** (formerly **Azure AI User**) on the **Foundry project scope** +for the Memory operations used here. Grant at that project, not merely at an +unrelated resource or only at a model deployment. Scope hashes are application +namespaces, not independent RBAC grants: a principal with project-wide access +must be trusted to enforce the application's user mapping. ## Provisioning the memory store (one time) @@ -86,20 +111,30 @@ $env:MEMORY_STORE_NAME="agent_framework_memory" You can also place these in a `.env` file next to `main.py` — see [`.env.example`](.env.example). +Local development also requires an explicit `LOCAL_MEMORY_USER_ID`, for example +`local-developer`. It is a **single-user** developer namespace configured by the +host operator, never a hosted fallback. Local `x-agent-user-id`/call-ID headers +are rejected rather than trusted. Local model/Memory calls use +`AzureCliCredential` (`az login`); hosted calls use managed identity. Do not +expose the local mode as a multi-user authenticated service. + ## Interacting with the agent > Depending on how you run the agent host, you can invoke the agent using `curl` (`Invoke-WebRequest` in PowerShell) or `azd`. Please refer to the [parent README](../../README.md) for more details. Send a POST request to the server with a JSON body containing an `"input"` field to interact with the agent. The first request seeds a memory; subsequent requests (especially in new sessions) should be able to recall it because memories are persisted across Foundry Hosted Agents sessions. -> In this sample, the memory is scoped to the user by specifying `scope="{{$userId}}"`, thus memories are isolated across different users but shared across different sessions from the same user. +> Hosted memory uses the trusted user-wide hash produced by `memory_scope`. +> Changing a sandbox or starting a fresh conversation does not clear that user's +> long-term memories. Changing the user selects a different namespace. ```bash # 1. Tell the agent something to remember. curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \ -d '{"input": "I prefer dark roast coffee and I am allergic to nuts."}' -# Wait a few seconds for the memory to be stored, then start a fresh conversation: +# Wait for the asynchronous Memory update (the default debounce is 300 seconds), +# then start a fresh conversation; immediate recall is not guaranteed: curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \ -d '{"input": "Can you recommend a coffee and a snack for me?"}' @@ -119,4 +154,19 @@ azd env set MEMORY_STORE_NAME "agent_framework_memory" If these are not set, running `azd ai agent init -m ` will prompt you to enter them interactively. -The deployed agent's Managed Identity needs **Azure AI User** on the Foundry project to read and write memories at runtime. Make sure you have run `provision_memory_store.py` against the same Foundry project before deploying — otherwise the agent will fail on the first turn when it tries to read from a non-existent store. +Provision the Memory Store in the **same project** and grant the deployed managed +identity the project-scoped role above. Provisioning, live Memory calls and +deployment need separately configured resources and are not exercised by the +offline tests. + +## Offline scope checks + +From `python/`: + +```bash +uv run pytest samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests -q +``` + +These focused checks cover user-wide versus cross-user namespaces, missing or +mismatched trusted context, fresh provider/call-ID binding, and local fallback +rejection without a Memory Store or real credential. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/main.py index f9300977cc5..a996940f7ea 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/main.py @@ -1,68 +1,98 @@ -# Copyright (c) Microsoft. All rights reserved. +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-ai-projects>=2.2.0,<2.8.0", +# "azure-identity", +# "python-dotenv", +# ] +# /// -"""Foundry Memory hosted agent sample. +# Copyright (c) Microsoft. All rights reserved. -This agent uses :class:`FoundryMemoryProvider` to give an otherwise stateless -hosted agent persistent, semantic memory backed by a Microsoft Foundry -Memory Store. The store itself is provisioned once via -``provision_memory_store.py`` and its name is passed in through the -``MEMORY_STORE_NAME`` environment variable. +"""Request-owned Foundry Memory with intentional, trusted user-wide sharing.""" -Unlike the standalone ``azure_ai_foundry_memory.py`` sample, here we construct -the :class:`FoundryChatClient` first and then reuse its underlying -``AIProjectClient`` for the memory provider, so both share a single client -instance and authentication context. -""" +from __future__ import annotations import asyncio +import hashlib +import json import os +from contextlib import AsyncExitStack +from types import TracebackType from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient, FoundryMemoryProvider, ResponsesHostServer -from azure.identity.aio import DefaultAzureCredential +from agent_framework.foundry import FoundryChatClient, FoundryMemoryProvider +from agent_framework_foundry_hosting import FoundryRequestScope, ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext, get_request_context +from azure.ai.projects.aio import AIProjectClient +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv -load_dotenv() +def memory_scope(config: AgentConfig, context: FoundryAgentRequestContext) -> str: + """Hash the trusted user, deliberately excluding sandbox, conversation and call IDs.""" + if config.is_hosted: + scope = FoundryRequestScope.from_context(config, context) + user_id = scope.user_id + if not user_id or not user_id.strip(): + raise RuntimeError("Foundry Memory requires a trusted platform user ID.") + else: + if context.user_id is not None or context.call_id is not None: + raise RuntimeError("Local Memory does not trust platform identity headers; use LOCAL_MEMORY_USER_ID.") + user_id = os.environ.get("LOCAL_MEMORY_USER_ID") + if not user_id or not user_id.strip(): + raise RuntimeError("Set LOCAL_MEMORY_USER_ID explicitly for single-user local Memory.") + identity = json.dumps(["foundry-memory-user-v1", user_id], ensure_ascii=False, separators=(",", ":")) + return hashlib.sha256(identity.encode("utf-8")).hexdigest() -async def main() -> None: - # The chat client owns the AIProjectClient. ``allow_preview=True`` is required - # so the same client can call the preview ``beta.memory_stores`` API used by - # FoundryMemoryProvider. - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=DefaultAzureCredential(), - allow_preview=True, - ) - # Reuse the project_client that FoundryChatClient just created, instead of - # constructing a second one for the memory provider. - memory_provider = FoundryMemoryProvider( - project_client=client.project_client, - memory_store_name=os.environ["MEMORY_STORE_NAME"], - # Scope memories by user id, so each user that interacts with the agent - # has their own isolated memories in the store (assuming those users are - # granted access). `{{userId}}` is a special placeholder that the hosting - # infrastructure will replace with the actual user id at runtime. - scope="{{$userId}}", +def create_agent() -> Agent: + """Bind a fresh provider and first-party project client to this request.""" + config, context = AgentConfig.from_env(), get_request_context() + scope = memory_scope(config, context) + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + store_name = os.environ["MEMORY_STORE_NAME"] + headers = context.platform_headers() + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if config.is_hosted + else AzureCliCredential() ) + # Project-level Memory calls need the captured call ID too, not just model calls. + project = AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True, headers=headers) + + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self - agent = Agent( + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(project.close) + cleanup.push_async_callback(self.client.close) + + client = RequestClient(project_client=project, model=model, default_headers=headers) + provider = FoundryMemoryProvider(project_client=project, memory_store_name=store_name, scope=scope) + return Agent( client=client, instructions=( - "You are a helpful assistant that remembers facts the user has shared " - "across conversations. Relevant memories from previous interactions are " - "automatically provided to you in the system context. Use them when " - "answering, and acknowledge when you are relying on remembered facts." + "You remember facts this user has shared across conversations. " + "Use relevant retrieved memories when answering and acknowledge when relying on them." ), - context_providers=[memory_provider], - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, + context_providers=[provider], ) - server = ResponsesHostServer(agent) + + +async def main() -> None: + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") await server.run_async() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/provision_memory_store.py b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/provision_memory_store.py index 19f734b55fe..7fe42215d20 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/provision_memory_store.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/provision_memory_store.py @@ -1,3 +1,12 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "azure-ai-projects>=2.2.0,<2.8.0", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. """Provision the Microsoft Foundry Memory Store used by this sample. @@ -19,7 +28,7 @@ AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME Embedding model deployment used by the memory store MEMORY_STORE_NAME Name of the memory store to create -Your identity needs ``Azure AI User`` on the Foundry project scope. +Your identity needs ``Foundry User`` (formerly ``Azure AI User``) on the project scope. """ import asyncio @@ -31,7 +40,7 @@ MemoryStoreDefaultOptions, ) from azure.core.exceptions import ResourceNotFoundError -from azure.identity.aio import DefaultAzureCredential +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv load_dotenv() @@ -44,7 +53,7 @@ async def main() -> None: embedding_model = os.environ["AZURE_AI_EMBEDDING_MODEL_DEPLOYMENT_NAME"] async with ( - DefaultAzureCredential() as credential, + AzureCliCredential() as credential, AIProjectClient(endpoint=endpoint, credential=credential, allow_preview=True) as project, ): try: diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests/test_memory_scope.py b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests/test_memory_scope.py new file mode 100644 index 00000000000..158f11d3a35 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests/test_memory_scope.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Focused checks of the trusted Memory namespace and per-request binding.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType +from unittest.mock import AsyncMock, MagicMock + +import pytest +from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext +from openai import AsyncOpenAI + + +@pytest.fixture +def sample() -> ModuleType: + spec = importlib.util.spec_from_file_location("sample_foundry_memory", Path(__file__).parents[1] / "main.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_memory_is_user_wide_but_never_shared_between_users(sample: ModuleType) -> None: + first = sample.memory_scope( + MagicMock(spec=AgentConfig, is_hosted=True, session_id="a"), + FoundryAgentRequestContext(user_id="user", session_id="a", call_id="call-1"), + ) + second = sample.memory_scope( + MagicMock(spec=AgentConfig, is_hosted=True, session_id="b"), + FoundryAgentRequestContext(user_id="user", session_id="b", call_id="call-2"), + ) + other = sample.memory_scope( + MagicMock(spec=AgentConfig, is_hosted=True, session_id="a"), + FoundryAgentRequestContext(user_id="other-user", session_id="a", call_id="call-3"), + ) + assert first == second and first != other and len(first) == 64 + assert "user" not in first + + +def test_missing_or_spoofed_context_cannot_select_a_memory_scope( + sample: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("LOCAL_MEMORY_USER_ID", "configured-local-user") + hosted = MagicMock(spec=AgentConfig, is_hosted=True, session_id="sandbox") + for context in ( + FoundryAgentRequestContext(call_id="call", session_id="sandbox"), + FoundryAgentRequestContext(user_id="user", session_id="sandbox"), + FoundryAgentRequestContext(user_id="user", call_id="call", session_id="other"), + ): + with pytest.raises(RuntimeError): + sample.memory_scope(hosted, context) + local = MagicMock(spec=AgentConfig, is_hosted=False) + assert sample.memory_scope(local, FoundryAgentRequestContext()) + with pytest.raises(RuntimeError, match="does not trust"): + sample.memory_scope(local, FoundryAgentRequestContext(user_id="caller-controlled-user")) + monkeypatch.delenv("LOCAL_MEMORY_USER_ID") + with pytest.raises(RuntimeError, match="Set LOCAL_MEMORY_USER_ID"): + sample.memory_scope(local, FoundryAgentRequestContext()) + + +async def test_each_provider_captures_its_own_call_id_and_owns_cleanup( + sample: ModuleType, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://project.test") + monkeypatch.setenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "model") + monkeypatch.setenv("MEMORY_STORE_NAME", "memory") + projects: list[MagicMock] = [] + credentials: list[MagicMock] = [] + request_headers: list[dict[str, str]] = [] + + def project(*, headers: dict[str, str], **kwargs: object) -> MagicMock: + client = MagicMock() + openai = MagicMock(spec=AsyncOpenAI) + openai.close = AsyncMock() + client.get_openai_client.return_value = openai + client.close = AsyncMock() + projects.append(client) + request_headers.append(dict(headers)) + return client + + def credential(**kwargs: object) -> MagicMock: + created = MagicMock() + created.close = AsyncMock() + credentials.append(created) + return created + + monkeypatch.setattr(sample, "AIProjectClient", project) + monkeypatch.setattr(sample, "ManagedIdentityCredential", credential) + providers = [] + for sandbox, call_id in (("a", "call-1"), ("b", "call-2")): + config = MagicMock(spec=AgentConfig, is_hosted=True, session_id=sandbox) + monkeypatch.setattr(sample.AgentConfig, "from_env", lambda config=config: config) + context = FoundryAgentRequestContext(user_id="user", session_id=sandbox, call_id=call_id) + monkeypatch.setattr(sample, "get_request_context", lambda context=context: context) + agent = sample.create_agent() + providers.append(agent.context_providers[0]) + async with agent: + assert agent.client.project_client is projects[-1] + await agent.close() + assert providers[0] is not providers[1] + assert providers[0].scope == providers[1].scope + assert request_headers == [{"x-agent-foundry-call-id": "call-1"}, {"x-agent-foundry-call-id": "call-2"}] + for client, identity in zip(projects, credentials): + client.close.assert_awaited_once() + client.get_openai_client.return_value.close.assert_awaited_once() + identity.close.assert_awaited_once() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md index 0eab3777fb2..fa8f9925ee8 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/README.md @@ -95,6 +95,31 @@ The agent uses `FoundryChatClient` from the Agent Framework to create an OpenAI- See [main.py](main.py) for the full implementation. +### Request-owned connections + +The host receives `agent=create_agent`, not a process-wide agent instance. +Every request creates a fresh client and Toolbox MCP connection **under that +request's platform context**, then closes them afterward. The MCP HTTP writer +captures context when connecting; reusing one connection across hosted requests +would retain an earlier caller's `x-agent-foundry-call-id`. This pattern avoids +that reuse without changing the semantics of long-lived Toolbox instances in +other applications. + +The sample client closes only its own model/project transports and credential, +including when MCP entry fails or the request is cancelled. Local authentication +uses `AzureCliCredential`; deployed authentication uses managed identity. +`history_source="agent_server"` reconstructs the outer Responses transcript and +disables inner model storage. Caller `store=false` prevents host-managed state, +not external tool side effects. + +Toolbox connections remain explicitly configured in the Foundry project. +PAT-backed tools act as the configured external account; user-identity tools +require the appropriate pass-through connection and the current call ID. A fresh +Python object does not by itself grant external permissions. GitHub PAT/OAuth +connections, project roles and consent are separate setup, not offline coverage. +Native code-interpreter file citations are a separate tracked behavior +(microsoft/agent-framework#7916), not implemented by this lifecycle change. + ## Running the agent ### Option 1: Azure Developer CLI (`azd`) diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/main.py index d11f7aba72e..0af3721f6e3 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox/main.py @@ -1,45 +1,73 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. +"""Request-owned Foundry Toolbox connections for the Responses protocol.""" + +from __future__ import annotations + import asyncio import os +from contextlib import AsyncExitStack +from types import TracebackType from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient, FoundryToolbox, ResponsesHostServer -from azure.identity import DefaultAzureCredential +from agent_framework.foundry import FoundryChatClient, FoundryToolbox +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv -# Load environment variables from .env file -load_dotenv() +def create_agent() -> Agent: + """Create tools inside this request so the MCP writer captures its call ID.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() + ) + + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self -async def main(): - credential = DefaultAzureCredential() + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) - # FoundryToolbox resolves the toolbox endpoint from the environment - # (TOOLBOX_ENDPOINT, or FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME), authenticates - # every request with the credential, and transparently forwards the platform - # per-request call-id to the toolbox. The hosting server enters the agent, which - # connects the toolbox on first use and closes it at shutdown. toolbox = FoundryToolbox(credential) - - # Create the chat client - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + client = RequestClient( + project_endpoint=endpoint, + model=model, credential=credential, + default_headers=get_request_context().platform_headers(), ) - - agent = Agent( + return Agent( client=client, instructions="You are a friendly assistant. Keep your answers brief.", tools=toolbox, - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, ) - server = ResponsesHostServer(agent) + +async def main() -> None: + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") await server.run_async() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills/README.md index 3d23ab09516..e6443b7ab48 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills/README.md @@ -31,6 +31,21 @@ The `FoundryToolbox` is attached to the agent and its skills are exposed through The agent is hosted with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol on `http://localhost:8088`. +`agent=create_agent` builds a new Toolbox, skills provider/cache, credential and +client for **each request**. The host enters and exits that agent, so the +streamable-HTTP writer and skill-resource reads inherit the current platform +call ID instead of the first request's context. `tools=` still connects the +Toolbox and `context_providers=` still reads skills from that same session; both +are required. Only loading the vetted sample skill body is auto-approved, not +arbitrary skill scripts or external actions. + +The factory's client context closes its owned SDK transports and credential +after completion, failed tool entry or cancellation. Local runs use +`AzureCliCredential`; hosted runs use managed identity. +`history_source="agent_server"` owns conversation history and disables inner +model storage. Scope the configured Toolbox/skill resources and RBAC to the +intended project; a fresh provider is not an external authorization boundary. + ## The bundled skills | Skill | Purpose | @@ -144,6 +159,10 @@ Make sure the skills and toolbox exist in the **same** Foundry project you deplo azd env set TOOLBOX_ENDPOINT "" ``` -The deployed agent's Managed Identity needs the **Foundry User** role on the Foundry project to discover skills over MCP at startup. +The deployed agent's Managed Identity needs the **Foundry User** role on the +Foundry project to read skill resources during each request. Discovery and +resource access are distinct permission checks; successful discovery alone does +not prove a skill body can be loaded. Provisioning skills, Toolbox resources and +permissions requires external setup and is not exercised by offline checks. > The bundled `skills/` folder and `toolbox.yaml` are authoring inputs only; they are excluded from the deployed container via [`.azdignore`](.azdignore) / [`.dockerignore`](.dockerignore). The running agent discovers everything it needs from the toolbox MCP endpoint. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills/main.py index ee544932da0..b2384b0c3d4 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_toolbox_mcp_skills/main.py @@ -1,55 +1,77 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. +"""Discover Toolbox skills with a fresh MCP session and provider for every request.""" + +from __future__ import annotations + import asyncio import os +from contextlib import AsyncExitStack +from types import TracebackType from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient, FoundryToolbox, ResponsesHostServer -from azure.identity import DefaultAzureCredential +from agent_framework.foundry import FoundryChatClient, FoundryToolbox +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv -# Load environment variables from .env file -load_dotenv() +def create_agent() -> Agent: + """Keep skill caches, credentials and the MCP writer within this request.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() + ) -async def main() -> None: - credential = DefaultAzureCredential() - - # FoundryToolbox resolves the toolbox endpoint from the environment - # (TOOLBOX_ENDPOINT, or FOUNDRY_PROJECT_ENDPOINT + TOOLBOX_NAME), authenticates - # every request with the credential, and forwards the platform per-request - # call-id. ``load_tools=False`` keeps the toolbox's tools hidden so only its - # Agent Skills (SEP-2640) are surfaced; passing it via ``tools=`` connects the - # MCP session that ``as_skills_provider()`` reads from. - toolbox = FoundryToolbox(credential, load_tools=False) + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self - # as_skills_provider() discovers skills from skill://index.json on the toolbox - # MCP session and exposes them as an agent context provider; SKILL.md bodies are - # fetched on demand via resources/read. disable_load_skill_approval=True registers - # the load_skill tool with approval_mode="never_require" so this unattended agent - # can load skills without an approval round-trip -- the Responses host runs the - # agent without an AgentSession, which the default approval flow requires. - skills_provider = toolbox.as_skills_provider(disable_load_skill_approval=True) + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + # tools= connects the MCP session; context_providers= reads skills from that same session. + toolbox = FoundryToolbox(credential, load_tools=False) + skills_provider = toolbox.as_skills_provider(disable_load_skill_approval=True) + client = RequestClient( + project_endpoint=endpoint, + model=model, credential=credential, + default_headers=get_request_context().platform_headers(), ) - - agent = Agent( + return Agent( client=client, name=os.environ.get("AGENT_NAME", "hosted-toolbox-mcp-skills"), instructions="You are a helpful assistant.", tools=toolbox, context_providers=[skills_provider], - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, ) - server = ResponsesHostServer(agent) + +async def main() -> None: + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") await server.run_async() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/README.md index b8b2bc137d5..a8526d16c88 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/README.md @@ -10,6 +10,24 @@ The agent uses `FoundryChatClient` from the Agent Framework to create an OpenAI- See [main.py](main.py) for the full implementation. +`agent=create_agent` creates fresh client and hosted-MCP tool configuration for +each request and closes the request's own SDK transports and credential. +Local model authentication uses `AzureCliCredential`; deployed calls use managed +identity. `history_source="agent_server"` supplies the outer transcript and +disables inner model storage. + +**Configure `GITHUB_PAT` separately.** A missing/empty PAT fails explicitly; +the sample does not silently run an agent with its GitHub integration disabled. +The PAT is deployment-owned and selects a single external GitHub account, +**not** the Foundry calling user's GitHub identity. Use a least-privilege, +read-only token for this demonstration and restrict who can call the agent. +For per-user GitHub access, use an appropriately configured user-authenticated +Toolbox connection instead. Do not pass a PAT in model options or log it; +platform user/call headers are not forwarded to GitHub. + +GitHub MCP access is credential-gated and is not exercised by credential-free +checks. Outer `store=false` does not undo actions taken in the external account. + ### Agent Hosting The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/main.py index 4cc499b98cd..3e4cfcbfb48 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/main.py @@ -1,53 +1,80 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. -import logging +"""Request-owned hosted MCP configuration with a deployment-owned GitHub PAT.""" + +from __future__ import annotations + import os +from contextlib import AsyncExitStack +from types import TracebackType -from agent_framework import Agent, ToolTypes -from agent_framework.foundry import FoundryChatClient, ResponsesHostServer -from azure.identity import DefaultAzureCredential +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv -# Load environment variables from .env file -load_dotenv() -logger = logging.getLogger(__name__) +def create_agent() -> Agent: + """Build fresh tool configuration without accepting caller-supplied credentials.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + github_pat = os.environ.get("GITHUB_PAT") + if not github_pat or not github_pat.strip(): + raise RuntimeError("Configure GITHUB_PAT separately before using the GitHub MCP sample.") + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() + ) + + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) -def main(): - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=DefaultAzureCredential(), + client = RequestClient( + project_endpoint=endpoint, + model=model, + credential=credential, + default_headers=get_request_context().platform_headers(), ) - - github_pat = os.environ["GITHUB_PAT"] - tools: list[ToolTypes] = [] - if not github_pat: - logger.warning("GITHUB_PAT environment variable is not set. The GitHub MCP tool will not get registered.") - else: - tools.append( - client.get_mcp_tool( - name="GitHub", - url="https://api.githubcopilot.com/mcp/", - headers={ - "Authorization": f"Bearer {github_pat}", - }, - approval_mode="never_require", - ) - ) - - agent = Agent( + github_tool = client.get_mcp_tool( + name="GitHub", + url="https://api.githubcopilot.com/mcp/", + headers={"Authorization": f"Bearer {github_pat}"}, + approval_mode="never_require", + ) + return Agent( client=client, instructions="You are a friendly assistant. Keep your answers brief.", - tools=tools, - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, + tools=[github_tool], ) - server = ResponsesHostServer(agent) + +def main() -> None: + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") server.run() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/README.md index 21ebd294fd9..1d5751c7d6a 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/README.md @@ -8,9 +8,8 @@ tools (`compute`, `fetch_data`) are only reachable from inside the sandbox via typed `await compute(...)` calls or the generic `call_tool(...)` fallback. > [!NOTE] -> `agent-framework-monty` is a **beta** package, so the `pyproject.toml` -> sets `[tool.uv] prerelease = "allow"` to let `uv sync` pick up the -> `1.0.0b*` release from PyPI. +> `agent-framework-monty` is a **beta** package. Its dependency is declared in +> `main.py`'s PEP 723 script metadata; use `--prerelease=allow` when running it. ## How It Works @@ -22,6 +21,19 @@ events) and non-streaming (JSON) response modes. See [main.py](main.py) for the full implementation. +`ResponsesHostServer(agent=create_agent, history_source="agent_server")` +constructs a fresh CodeAct provider, client and credential for each request. +No interpreter/provider instance, tool connection or model client is reused +from an earlier caller. The factory's client context closes its owned SDK +transports and credential after the request. Local calls use +`AzureCliCredential`; hosted calls use managed identity. + +The host supplies conversation history and disables inner model storage. The +registered host tools operate only on the explicit **shared illustrative data** +in this sample, not user-private external records. Real tools must enforce +their own trusted user/sandbox authorization; Monty's interpreter isolation +does not scope an external database or token. + ### CodeAct context provider `MontyCodeActProvider` is added to the agent via `context_providers=[...]`. On @@ -45,35 +57,31 @@ sandbox; the registered host tools retain full Python access. Agent Framework's [native OpenTelemetry instrumentation](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) is enabled by setting these env vars in `agent.yaml` / `agent.manifest.yaml`: - `ENABLE_INSTRUMENTATION=true` — turns on the framework's span/metric/log emitters. -- `ENABLE_SENSITIVE_DATA=true` — includes prompts, tool inputs, tool outputs, and completions in telemetry. **Dev/test only.** +- `ENABLE_SENSITIVE_DATA=false` — the default; sensitive payload capture is opt-in for approved debugging only. -`main.py` wires Azure Monitor at startup: - -1. Reads `APPLICATIONINSIGHTS_CONNECTION_STRING` (Foundry hosting injects this automatically for the project's attached Application Insights resource; set it yourself when running locally). -2. Calls `azure.monitor.opentelemetry.configure_azure_monitor(connection_string=...)` to register Azure Monitor exporters with the global OTel tracer/meter/logger providers. -3. Calls `agent_framework.observability.enable_instrumentation()` so Agent Framework emits its `invoke_agent`, `chat`, `execute_tool`, and `execute_code` spans on those providers. +The Foundry runtime manages exporters when hosted or launched through +`azd ai agent run`. This entry point does **not** install Azure Monitor exporters +at startup. A direct local `python main.py` run needs separately configured +exporters if exported telemetry is desired. Do not log platform identities or +tokens, and do not enable sensitive payloads in a shared production sample. Trace linking happens automatically: the Foundry hosting layer's incoming `Responses` request becomes the **parent span**, and every framework / tool span (including the `execute_code` invocation that runs Monty) becomes a child via OpenTelemetry context propagation since both layers share the same global tracer provider. In Application Insights you can click any operation and see the full tree from inbound HTTP all the way down to individual `compute(...)` / `fetch_data(...)` calls inside the Monty sandbox. ## Running the Agent Host -This sample uses `pyproject.toml` + `uv sync` rather than the parent -README's `requirements.txt` flow. To run locally: - -1. Install dependencies into a local virtual environment: +Set the environment variables described in the +[parent README](../../README.md#running-the-agent-host-locally), then run using +the dependencies declared in the script: - ```bash - uv sync - ``` - -2. Set the environment variables described in the - [parent README](../../README.md#running-the-agent-host-locally) (Foundry - project endpoint, model deployment, optional Application Insights), then - start the host: +```bash +uv run --script --prerelease=allow main.py +``` - ```bash - uv run python main.py - ``` +Additional sample imports belong in inline script metadata, not a workspace, +package or sample `pyproject.toml`. Before the coordinated hosting beta is +published, use the current workspace's installed packages +(`uv run --no-sync python main.py`) rather than assuming an older PyPI wheel +contains the current hosting API. Refer to the parent README for the shared `azd` / Docker / invocation / deployment guidance. @@ -114,3 +122,8 @@ print(result) To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory. + +Monty does not require KVM. The separate +[Hyperlight container example](../../../container/hyperlight_codeact/) requires +KVM/hypervisor access unavailable in the default Foundry runtime; this sample +does not validate or claim that Hyperlight deployment works. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/agent.manifest.yaml index 1ea4287ea2b..cf678721b52 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/agent.manifest.yaml +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/agent.manifest.yaml @@ -21,7 +21,7 @@ template: - name: ENABLE_INSTRUMENTATION value: "true" - name: ENABLE_SENSITIVE_DATA - value: "true" + value: "false" resources: - kind: model id: gpt-4.1-mini diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/agent.yaml index 1b32156fcd9..041f5a5745a 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/agent.yaml +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/agent.yaml @@ -12,4 +12,4 @@ environment_variables: - name: ENABLE_INSTRUMENTATION value: "true" - name: ENABLE_SENSITIVE_DATA - value: "true" + value: "false" diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/main.py index 3b63f169157..6eca781d123 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/monty_codeact/main.py @@ -1,18 +1,36 @@ +# /// script +# requires-python = ">=3.12,<3.14" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "agent-framework-monty", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. +"""Request-owned Monty CodeAct and Foundry clients.""" + +from __future__ import annotations + import os +from contextlib import AsyncExitStack +from types import TracebackType from typing import Annotated, Any, Literal from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient, ResponsesHostServer +from agent_framework.foundry import FoundryChatClient from agent_framework.monty import MontyCodeActProvider -from azure.identity import DefaultAzureCredential +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv from pydantic import Field -# Load environment variables from .env file (no-op when injected by Foundry). -load_dotenv() - @tool(approval_mode="never_require") def compute( @@ -52,12 +70,33 @@ def fetch_data( return data.get(table, []) -def main() -> None: - """Host a Monty CodeAct agent over the Responses protocol.""" - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=DefaultAzureCredential(), +def create_agent() -> Agent: + """Create a new interpreter provider and client for the current request.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() + ) + + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) + + client = RequestClient( + project_endpoint=endpoint, + model=model, + credential=credential, + default_headers=get_request_context().platform_headers(), ) # MontyCodeActProvider injects a sandboxed `execute_code` tool into every @@ -69,7 +108,7 @@ def main() -> None: approval_mode="never_require", ) - agent = Agent( + return Agent( client=client, instructions=( "You are a friendly assistant. Use `execute_code` to combine " @@ -77,13 +116,13 @@ def main() -> None: "task requires lookups, transformations, or computation." ), context_providers=[codeact], - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, ) - server = ResponsesHostServer(agent) + +def main() -> None: + """Host a Monty CodeAct agent over the Responses protocol.""" + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") server.run() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/observability/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/observability/README.md index 5a6216a4920..39d0893edf9 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/observability/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/observability/README.md @@ -16,9 +16,28 @@ The agent is hosted using the [Agent Framework](https://github.com/microsoft/age ### Instrumentation -Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution. Instrumentation is enabled by default. To also capture sensitive event payloads (prompts, tool arguments, etc.) set `ENABLE_SENSITIVE_DATA=true`. This sample demonstrates how to manage these settings via environment variables in `agent.manifest.yaml` and `agent.yaml`. - -Foundry Hosted Agent has built-in observability thus you don't need to set up exporters manually to capture telemetry from your code. The traces, metrics, and logs generated by the agent are automatically collected and made available through Foundry's observability stack via Azure Monitor/Application Insights. The `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable is injected when the agent is deployed to Foundry, however it is still required to be set in your environment if you want to run the agent host locally and have telemetry sent to Application Insights from your local environment. +Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) +to capture diagnostics and telemetry for agent execution. Instrumentation is +enabled by default. The manifests default `ENABLE_SENSITIVE_DATA=false`; enabling +prompt/tool payload capture is an explicit opt-in for approved debugging, not +a production default. Never log platform identity values or tokens. + +The host receives `agent=create_agent` and builds a new client/credential for +each request, capturing only that request's first-party platform headers. Native +OpenTelemetry context propagation links spans to the incoming request rather +than a preceding caller. The context-managed sample client closes its own SDK +transports and credential after the request. Local authentication uses +`AzureCliCredential`; hosted authentication uses managed identity. +`history_source="agent_server"` owns the outer transcript and disables inner +model storage. + +Foundry Hosted Agent has built-in observability, so the hosted runtime manages +exporters and its Application Insights configuration. This entry point does +not configure an exporter itself: setting a connection-string variable alone +does not cause a direct local Python process to export telemetry. Use the +Foundry local runner or separately configure local exporters. Live telemetry +requires the configured project/Application Insights resources and is not +exercised by credential-free checks. ## Running the Agent Host diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/observability/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/observability/agent.manifest.yaml index bde49c6821a..2d5f20c3d86 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/observability/agent.manifest.yaml +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/observability/agent.manifest.yaml @@ -18,7 +18,7 @@ template: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}" - name: ENABLE_SENSITIVE_DATA - value: true + value: "false" resources: - kind: model id: gpt-4.1-mini diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/observability/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/observability/agent.yaml index f4d08fe2589..6ce2a78704a 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/observability/agent.yaml +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/observability/agent.yaml @@ -11,4 +11,4 @@ environment_variables: - name: AZURE_AI_MODEL_DEPLOYMENT_NAME value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME} - name: ENABLE_SENSITIVE_DATA - value: true + value: "false" diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/observability/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/observability/main.py index 0d9f49fe239..a8f788760ad 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/observability/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/observability/main.py @@ -1,19 +1,36 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. +"""Native request-scoped telemetry without retaining a preceding caller's client.""" + +from __future__ import annotations + import asyncio import os +from contextlib import AsyncExitStack from random import randint +from types import TracebackType from typing import Annotated from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient, ResponsesHostServer -from azure.identity import DefaultAzureCredential +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv from pydantic import Field -# Load environment variables from .env file -load_dotenv() - @tool(approval_mode="never_require", description="Get the current location of the user.") def get_current_location() -> str: @@ -31,24 +48,45 @@ def get_weather( return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." -async def main(): - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=DefaultAzureCredential(), +def create_agent() -> Agent: + """Let incoming telemetry context parent each freshly constructed agent.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() ) - agent = Agent( + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) + + client = RequestClient( + project_endpoint=endpoint, + model=model, + credential=credential, + default_headers=get_request_context().platform_headers(), + ) + + return Agent( client=client, instructions="You are a friendly assistant. Keep your answers brief.", tools=[get_weather, get_current_location], - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, ) - server = ResponsesHostServer(agent) + +async def main() -> None: + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") await server.run_async() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/tools/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/tools/README.md index 8ab3f7a0acc..82da154db63 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/tools/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/tools/README.md @@ -24,6 +24,20 @@ When a tool is set to `always_require`, the agent host emits an `mcp_approval_re The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol. +The host uses `agent=create_agent` and `history_source="agent_server"`. Each +request receives its own client and credential; their owned transports are +closed afterward. History and approval continuation remain in the host's +trusted user/sandbox-scoped stores, not on a reused agent instance. A fresh +factory does not remove pending approvals: return the approval response with +the same outer continuation and sandbox. `store=false` cannot establish a +persistent approval continuation. + +The weather tool is illustrative. The approved shell tool still executes on +the host and is not made safe by a factory or an approval alone; use this only +in an isolated development sandbox. The safer dedicated-upload file example is +[here](../files/). Local model calls use `AzureCliCredential`; deployed calls +use managed identity. + ## Running the Agent Host Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/tools/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/tools/main.py index 4fcc2c59ff6..d2763cef95d 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/tools/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/tools/main.py @@ -1,19 +1,36 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-core", +# "agent-framework-foundry", +# "agent-framework-foundry-hosting", +# "azure-ai-agentserver-core>=2.1.0,<3", +# "azure-identity", +# "python-dotenv", +# ] +# /// + # Copyright (c) Microsoft. All rights reserved. +"""Local tools on a request-owned Responses agent; approvals remain host-persisted.""" + +from __future__ import annotations + import os import subprocess +from contextlib import AsyncExitStack from random import randint +from types import TracebackType from typing import Annotated from agent_framework import Agent, tool -from agent_framework.foundry import FoundryChatClient, ResponsesHostServer -from azure.identity import DefaultAzureCredential +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.ai.agentserver.core import AgentConfig, get_request_context +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv from pydantic import Field -# Load environment variables from .env file -load_dotenv() - @tool(approval_mode="never_require") def get_weather( @@ -48,24 +65,45 @@ def run_bash(command: str) -> str: return f"Error executing command: {e}" -def main(): - client = FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], - credential=DefaultAzureCredential(), +def create_agent() -> Agent: + """Create client state per request rather than keeping history on the agent.""" + endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + model = os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + credential = ( + ManagedIdentityCredential(client_id=os.environ.get("FOUNDRY_AGENT_INSTANCE_CLIENT_ID")) + if AgentConfig.from_env().is_hosted + else AzureCliCredential() ) - agent = Agent( + class RequestClient(FoundryChatClient): + async def __aenter__(self) -> RequestClient: + return self + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + async with AsyncExitStack() as cleanup: + cleanup.push_async_callback(credential.close) + cleanup.push_async_callback(self.project_client.close) + cleanup.push_async_callback(self.client.close) + + client = RequestClient( + project_endpoint=endpoint, + model=model, + credential=credential, + default_headers=get_request_context().platform_headers(), + ) + + return Agent( client=client, instructions="You are a friendly assistant. Keep your answers brief.", tools=[get_weather, run_bash], - # History will be managed by the hosting infrastructure, thus there - # is no need to store history by the service. Learn more at: - # https://developers.openai.com/api/reference/resources/responses/methods/create - default_options={"store": False}, ) - server = ResponsesHostServer(agent) + +def main() -> None: + load_dotenv() + server = ResponsesHostServer(agent=create_agent, history_source="agent_server") server.run() From 0a4796786a58266ee47c8fd6b91952b478955483 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 30 Sep 2026 14:59:40 +0200 Subject: [PATCH 2/7] chore(python): resolve standalone sample helper imports --- python/.github/skills/python-code-quality/SKILL.md | 6 +++--- python/ty.samples.toml | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/python/.github/skills/python-code-quality/SKILL.md b/python/.github/skills/python-code-quality/SKILL.md index e7e92ab128a..92715d13fc3 100644 --- a/python/.github/skills/python-code-quality/SKILL.md +++ b/python/.github/skills/python-code-quality/SKILL.md @@ -99,9 +99,9 @@ Following the "too many type checkers" approach, type checkers are split by targ `# type: ignore[code]`. Suppress relaxed-pyright friction with `# pyright: ignore[rule]`. - **Samples** add `pyright` to `pyrefly` + `ty` — mypy/zuban can't resolve script-style sample layouts (numeric-prefixed dirs, duplicate `main.py`), but pyright handles them. -- `pyrefly.samples.toml` enables fallback lookup for standalone sibling imports. For a - targeted `ty` check of the excluded hosted claw harness, add its deployment directory - with `--extra-search-path samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready`. +- `pyrefly.samples.toml` enables fallback lookup for standalone sibling imports. + The ty sample profile includes each PEP 723 script's own directory, so targeted + checks of standalone entrypoints resolve their deployment-folder helpers too. - The strict source-pyright (`[tool.pyright]`) enforces `reportUnnecessaryTypeIgnoreComment` and excludes tests/samples; the relaxed test/sample pyright configs do not flag unnecessary ignores. diff --git a/python/ty.samples.toml b/python/ty.samples.toml index bf297701d7e..91394aa95ea 100644 --- a/python/ty.samples.toml +++ b/python/ty.samples.toml @@ -1,5 +1,9 @@ # Basic-mode ty profile for samples. Mirrors pyrefly.samples.toml: catch real mistakes # in teaching code without forcing casts/overload workarounds for third-party SDK stubs. +[environment] +# For PEP 723 entrypoints, ty resolves this relative to each script's directory. +extra-paths = ["."] + [rules] # Signature/cast/overload noise -- off for samples. invalid-argument-type = "ignore" From bbd3f0e520564d6e20403a2a6c6698808b58d964 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 30 Sep 2026 15:09:50 +0200 Subject: [PATCH 3/7] test(python): remove unrequested Cosmos sample tests --- .../responses/custom_storage/README.md | 14 +- .../custom_storage/tests/test_storage.py | 154 ------------------ 2 files changed, 2 insertions(+), 166 deletions(-) delete mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests/test_storage.py diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md index e08c9dc1184..4ef68596421 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md @@ -89,15 +89,5 @@ The provider closes the Cosmos client and its own credential at shutdown; request clients are closed after their request, including failed tool entry and cancellation. -## Offline checks - -From `python/`: - -```bash -uv run pytest samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests -q -``` - -These checks use a fake Cosmos container, not an account. They cover create -races, stale updates/deletes, per-key ETags, independent snapshots, canonical -lookup IDs, trusted-scope rejection, and user/sandbox isolation. Live Cosmos -access and deployment need separately approved resources and permissions. +Live Cosmos access and deployment need separately approved resources and +permissions. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests/test_storage.py b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests/test_storage.py deleted file mode 100644 index d3164c47329..00000000000 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/tests/test_storage.py +++ /dev/null @@ -1,154 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Focused, account-free checks of scoped Cosmos snapshots and conditional writes.""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import AsyncMock, MagicMock - -import pytest -from agent_framework import AgentSession -from agent_framework_foundry_hosting import FoundryRequestScope -from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext -from azure.core import MatchConditions -from azure.cosmos.aio import ContainerProxy -from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceNotFoundError - - -@pytest.fixture -def sample() -> ModuleType: - spec = importlib.util.spec_from_file_location("sample_custom_storage", Path(__file__).parents[1] / "main.py") - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def _scope(user: str = "user", sandbox: str = "sandbox") -> FoundryRequestScope: - return FoundryRequestScope(session_id=sandbox, user_id=user, call_id="call", is_hosted=True) - - -async def test_cosmos_canonical_keys_conditional_writes_and_delete(sample: ModuleType) -> None: - container = MagicMock(spec=ContainerProxy) - container.read_item = AsyncMock(side_effect=CosmosResourceNotFoundError(message="Missing.")) - container.create_item = AsyncMock(return_value={"_etag": "v1"}) - container.replace_item = AsyncMock(side_effect=[{"_etag": "v2"}, {"_etag": "v3"}]) - container.delete_item = AsyncMock() - store = sample.CosmosSessionStore(container=container, scope=_scope()) - snapshot = AgentSession(session_id="inner-maf-id") - - assert await store.get("conversation") is None - await store.set("conversation", snapshot) - await store.set("conversation", snapshot) - await store.set("response", snapshot) - await store.set("conversation", snapshot) - body = container.create_item.await_args_list[0].kwargs["body"] - assert body["id"] != snapshot.session_id - assert body["scope_key"] == _scope().storage_key - assert body["session"]["session_id"] == snapshot.session_id - writes = container.replace_item.await_args_list - assert [call.kwargs["etag"] for call in writes] == ["v1", "v2"] - assert all(call.kwargs["match_condition"] is MatchConditions.IfNotModified for call in writes) - - container.read_item.side_effect = None - container.read_item.return_value = {**body, "_etag": "v3"} - loaded = await store.get("conversation") - assert loaded is not None and loaded.session_id == "inner-maf-id" - await store.delete("conversation") - assert container.delete_item.await_args is not None - assert container.delete_item.await_args.kwargs["etag"] == "v3" - assert container.delete_item.await_args.kwargs["partition_key"] == _scope().storage_key - await store.set("conversation", snapshot) - container.upsert_item.assert_not_called() - - -async def test_cosmos_conflicts_missing_etags_and_scope_mismatch_fail_closed(sample: ModuleType) -> None: - container = MagicMock(spec=ContainerProxy) - snapshot = AgentSession(session_id="inner") - store = sample.CosmosSessionStore(container=container, scope=_scope()) - container.create_item = AsyncMock(side_effect=CosmosHttpResponseError(status_code=409, message="Exists.")) - with pytest.raises(RuntimeError, match="Another request advanced"): - await store.set("key", snapshot) - - body = {"id": store._item_id("key"), "scope_key": _scope().storage_key, "session": snapshot.to_dict()} - container.read_item = AsyncMock(return_value=body) - with pytest.raises(RuntimeError, match="missing its ETag"): - await store.get("key") - container.read_item.return_value = {**body, "_etag": "stale"} - assert await store.get("key") is not None - container.replace_item = AsyncMock(side_effect=CosmosHttpResponseError(status_code=412, message="Stale.")) - with pytest.raises(RuntimeError, match="Another request advanced"): - await store.set("key", snapshot) - container.delete_item = AsyncMock(side_effect=CosmosHttpResponseError(status_code=412, message="Stale.")) - with pytest.raises(RuntimeError, match="Another request advanced"): - await store.delete("key") - for scope in (_scope(user="other-user"), _scope(sandbox="other-sandbox")): - container.read_item.return_value = {**body, "scope_key": scope.storage_key, "_etag": "v1"} - with pytest.raises(RuntimeError, match="trusted user and sandbox"): - await store.get("key") - container.upsert_item.assert_not_called() - - -async def test_local_snapshots_preserve_user_sandbox_and_cas_semantics(sample: ModuleType) -> None: - provider = sample.CustomSessionStoreProvider() - config = MagicMock(spec=AgentConfig, is_hosted=False) - context = FoundryAgentRequestContext(user_id="user", session_id="sandbox") - first = provider.get_store(config=config, platform_context=context) - second = provider.get_store(config=config, platform_context=context) - await first.set("key", AgentSession(session_id="inner")) - loaded = await second.get("key") - assert loaded is not None and loaded.session_id == "inner" - await first.set("key", AgentSession(session_id="newer")) - with pytest.raises(RuntimeError, match="Another request advanced"): - await second.set("key", loaded) - for isolated in ( - FoundryAgentRequestContext(user_id="other-user", session_id="sandbox"), - FoundryAgentRequestContext(user_id="user", session_id="other-sandbox"), - ): - store = provider.get_store(config=config, platform_context=isolated) - assert await store.get("key") is None - await first.delete("key") - assert await second.get("key") is None - - -def test_provider_rejects_missing_or_mismatched_trusted_identity( - sample: ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - config = MagicMock(spec=AgentConfig, is_hosted=True, session_id="sandbox") - constructor = MagicMock() - monkeypatch.setattr(sample, "CosmosClient", constructor) - for context in ( - FoundryAgentRequestContext(user_id="user", session_id="other", call_id="call"), - FoundryAgentRequestContext(session_id="sandbox", call_id="call"), - FoundryAgentRequestContext(user_id="user", session_id="sandbox"), - ): - with pytest.raises(RuntimeError): - sample.CustomSessionStoreProvider().get_store(config=config, platform_context=context) - constructor.assert_not_called() - - -async def test_provider_owns_only_its_managed_identity_backend( - sample: ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("AZURE_COSMOS_ENDPOINT", "https://cosmos.test") - monkeypatch.setenv("COSMOS_DATABASE_NAME", "database") - monkeypatch.setenv("COSMOS_CONTAINER_NAME", "container") - credential, client = MagicMock(), MagicMock() - credential.close, client.close = AsyncMock(), AsyncMock() - identity, constructor = MagicMock(return_value=credential), MagicMock(return_value=client) - monkeypatch.setattr(sample, "ManagedIdentityCredential", identity) - monkeypatch.setattr(sample, "CosmosClient", constructor) - provider = sample.CustomSessionStoreProvider() - config = MagicMock(spec=AgentConfig, is_hosted=True, session_id="sandbox") - first = provider.get_store(config=config, platform_context=FoundryAgentRequestContext(user_id="user", call_id="a")) - second = provider.get_store(config=config, platform_context=FoundryAgentRequestContext(user_id="user", call_id="b")) - assert first is not second - assert first._scope_key == second._scope_key == _scope().storage_key - constructor.assert_called_once_with(url="https://cosmos.test", credential=credential) - await provider.close() - await provider.close() - client.close.assert_awaited_once() - credential.close.assert_awaited_once() From 515b57de002ba18b974734f302b141ae9e008a7e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 30 Sep 2026 15:13:02 +0200 Subject: [PATCH 4/7] test(python): remove unrequested Memory sample tests --- .../responses/foundry_memory/README.md | 14 +-- .../foundry_memory/tests/test_memory_scope.py | 109 ------------------ 2 files changed, 1 insertion(+), 122 deletions(-) delete mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests/test_memory_scope.py diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md index 49ea7b029bc..c6e01d2f0ba 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md @@ -157,16 +157,4 @@ If these are not set, running `azd ai agent init -m ` will Provision the Memory Store in the **same project** and grant the deployed managed identity the project-scoped role above. Provisioning, live Memory calls and deployment need separately configured resources and are not exercised by the -offline tests. - -## Offline scope checks - -From `python/`: - -```bash -uv run pytest samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests -q -``` - -These focused checks cover user-wide versus cross-user namespaces, missing or -mismatched trusted context, fresh provider/call-ID binding, and local fallback -rejection without a Memory Store or real credential. +credential-free checks. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests/test_memory_scope.py b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests/test_memory_scope.py deleted file mode 100644 index 158f11d3a35..00000000000 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/tests/test_memory_scope.py +++ /dev/null @@ -1,109 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Focused checks of the trusted Memory namespace and per-request binding.""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path -from types import ModuleType -from unittest.mock import AsyncMock, MagicMock - -import pytest -from azure.ai.agentserver.core import AgentConfig, FoundryAgentRequestContext -from openai import AsyncOpenAI - - -@pytest.fixture -def sample() -> ModuleType: - spec = importlib.util.spec_from_file_location("sample_foundry_memory", Path(__file__).parents[1] / "main.py") - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_memory_is_user_wide_but_never_shared_between_users(sample: ModuleType) -> None: - first = sample.memory_scope( - MagicMock(spec=AgentConfig, is_hosted=True, session_id="a"), - FoundryAgentRequestContext(user_id="user", session_id="a", call_id="call-1"), - ) - second = sample.memory_scope( - MagicMock(spec=AgentConfig, is_hosted=True, session_id="b"), - FoundryAgentRequestContext(user_id="user", session_id="b", call_id="call-2"), - ) - other = sample.memory_scope( - MagicMock(spec=AgentConfig, is_hosted=True, session_id="a"), - FoundryAgentRequestContext(user_id="other-user", session_id="a", call_id="call-3"), - ) - assert first == second and first != other and len(first) == 64 - assert "user" not in first - - -def test_missing_or_spoofed_context_cannot_select_a_memory_scope( - sample: ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("LOCAL_MEMORY_USER_ID", "configured-local-user") - hosted = MagicMock(spec=AgentConfig, is_hosted=True, session_id="sandbox") - for context in ( - FoundryAgentRequestContext(call_id="call", session_id="sandbox"), - FoundryAgentRequestContext(user_id="user", session_id="sandbox"), - FoundryAgentRequestContext(user_id="user", call_id="call", session_id="other"), - ): - with pytest.raises(RuntimeError): - sample.memory_scope(hosted, context) - local = MagicMock(spec=AgentConfig, is_hosted=False) - assert sample.memory_scope(local, FoundryAgentRequestContext()) - with pytest.raises(RuntimeError, match="does not trust"): - sample.memory_scope(local, FoundryAgentRequestContext(user_id="caller-controlled-user")) - monkeypatch.delenv("LOCAL_MEMORY_USER_ID") - with pytest.raises(RuntimeError, match="Set LOCAL_MEMORY_USER_ID"): - sample.memory_scope(local, FoundryAgentRequestContext()) - - -async def test_each_provider_captures_its_own_call_id_and_owns_cleanup( - sample: ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setenv("FOUNDRY_PROJECT_ENDPOINT", "https://project.test") - monkeypatch.setenv("AZURE_AI_MODEL_DEPLOYMENT_NAME", "model") - monkeypatch.setenv("MEMORY_STORE_NAME", "memory") - projects: list[MagicMock] = [] - credentials: list[MagicMock] = [] - request_headers: list[dict[str, str]] = [] - - def project(*, headers: dict[str, str], **kwargs: object) -> MagicMock: - client = MagicMock() - openai = MagicMock(spec=AsyncOpenAI) - openai.close = AsyncMock() - client.get_openai_client.return_value = openai - client.close = AsyncMock() - projects.append(client) - request_headers.append(dict(headers)) - return client - - def credential(**kwargs: object) -> MagicMock: - created = MagicMock() - created.close = AsyncMock() - credentials.append(created) - return created - - monkeypatch.setattr(sample, "AIProjectClient", project) - monkeypatch.setattr(sample, "ManagedIdentityCredential", credential) - providers = [] - for sandbox, call_id in (("a", "call-1"), ("b", "call-2")): - config = MagicMock(spec=AgentConfig, is_hosted=True, session_id=sandbox) - monkeypatch.setattr(sample.AgentConfig, "from_env", lambda config=config: config) - context = FoundryAgentRequestContext(user_id="user", session_id=sandbox, call_id=call_id) - monkeypatch.setattr(sample, "get_request_context", lambda context=context: context) - agent = sample.create_agent() - providers.append(agent.context_providers[0]) - async with agent: - assert agent.client.project_client is projects[-1] - await agent.close() - assert providers[0] is not providers[1] - assert providers[0].scope == providers[1].scope - assert request_headers == [{"x-agent-foundry-call-id": "call-1"}, {"x-agent-foundry-call-id": "call-2"}] - for client, identity in zip(projects, credentials): - client.close.assert_awaited_once() - client.get_openai_client.return_value.close.assert_awaited_once() - identity.close.assert_awaited_once() From 5fae9772d984b7d681765ee0ed1fd2a851500d00 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 30 Sep 2026 15:36:22 +0200 Subject: [PATCH 5/7] fix(python): preserve sample import lookup in ty --- python/.github/skills/python-code-quality/SKILL.md | 4 ++-- python/ty.samples.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/python/.github/skills/python-code-quality/SKILL.md b/python/.github/skills/python-code-quality/SKILL.md index 92715d13fc3..526a8557e93 100644 --- a/python/.github/skills/python-code-quality/SKILL.md +++ b/python/.github/skills/python-code-quality/SKILL.md @@ -100,8 +100,8 @@ Following the "too many type checkers" approach, type checkers are split by targ - **Samples** add `pyright` to `pyrefly` + `ty` — mypy/zuban can't resolve script-style sample layouts (numeric-prefixed dirs, duplicate `main.py`), but pyright handles them. - `pyrefly.samples.toml` enables fallback lookup for standalone sibling imports. - The ty sample profile includes each PEP 723 script's own directory, so targeted - checks of standalone entrypoints resolve their deployment-folder helpers too. + The ty sample profile uses first-party `root = ["."]` for PEP 723 sibling helpers, + preserving automatic extra-path lookup for the existing samples. - The strict source-pyright (`[tool.pyright]`) enforces `reportUnnecessaryTypeIgnoreComment` and excludes tests/samples; the relaxed test/sample pyright configs do not flag unnecessary ignores. diff --git a/python/ty.samples.toml b/python/ty.samples.toml index 91394aa95ea..f6f8d2bb4f9 100644 --- a/python/ty.samples.toml +++ b/python/ty.samples.toml @@ -1,8 +1,8 @@ # Basic-mode ty profile for samples. Mirrors pyrefly.samples.toml: catch real mistakes # in teaching code without forcing casts/overload workarounds for third-party SDK stubs. [environment] -# For PEP 723 entrypoints, ty resolves this relative to each script's directory. -extra-paths = ["."] +# PEP 723 scripts need first-party roots for siblings; keep automatic extra-path lookup. +root = ["."] [rules] # Signature/cast/overload noise -- off for samples. From 72f4435f793a6bee71fbc5fdc73140715b96b88e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 30 Sep 2026 16:37:35 +0200 Subject: [PATCH 6/7] fix(python): align hosted session guidance and bound teardown --- .../claw_step04_production_ready/README.md | 12 ++++++---- .../claw_step04_production_ready/hosted.py | 2 +- .../responses/files/README.md | 24 +++++++++---------- .../responses/files/upload_file.py | 2 +- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md index 315dfbd1c60..c3df56c2c2a 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md @@ -118,11 +118,13 @@ instances. Local hosts keep their original builder defaults. Request-lifetime middleware releases only this agent's background-provider tasks for the active MAF session, in a `finally` path on success, failure or cancellation. Streaming teardown wraps **consumption**, not construction of a -lazy stream. Outstanding research is cancelled and joined before the request's -transports close; it cannot keep running with an obsolete call context after -the turn. Complete/collect research within a turn; unfinished runtime tasks -cannot be resumed by a later factory-created agent. Cleanup failure is logged -without identity values and does not replace an existing run failure. +lazy stream. Outstanding research is cancelled and joined before the request's transports +close, using the provider's finite **30-second** default. A child that ignores +cancellation is abandoned and logged when that bound expires, so it cannot +hold transport teardown open indefinitely. Complete/collect research within a +turn; unfinished runtime tasks cannot be resumed by a later factory-created +agent. Cleanup failure is logged without identity values and does not replace +an existing run failure. Local runs use `AzureCliCredential` and are single-user development only. Hosted runs use managed identity; project/Toolbox/Purview permissions and diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py index b589c09c872..2969821a23a 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py @@ -171,7 +171,7 @@ async def release_background() -> AsyncIterator[None]: finally: try: for provider in background_providers: - await provider.release_session(session, timeout=None) + await provider.release_session(session) except BaseException as cleanup_error: logger.error("Failed to release request-owned background tasks (%s).", type(cleanup_error).__name__) if not run_failed: diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md index ec85bb4a092..b08b61784de 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md @@ -43,7 +43,7 @@ In the same environment and with the **same `HOME`**, explicitly stage the packaged report: ```bash -uv run python upload_file.py resources/contoso_q1_2026_report.txt --local +uv run --script upload_file.py resources/contoso_q1_2026_report.txt --local curl -X POST http://localhost:8088/responses \ -H "Content-Type: application/json" \ -d '{"input":"Read contoso_q1_2026_report.txt and compare Q1 revenue."}' @@ -65,24 +65,19 @@ Use the **Foundry `agent_session_id`**, not an outer `response.id`, `previous_response_id`, conversation ID or MAF `AgentSession.session_id`. ```bash -uv run python upload_file.py resources/contoso_q1_2026_report.txt \ +uv run --script upload_file.py resources/contoso_q1_2026_report.txt \ --session-id "" ``` -The SDK uploads to `sample_files/contoso_q1_2026_report.txt`, relative to that +The helper's script metadata requires `azure-ai-projects>=2.3.0`, the documented +SDK prerequisite for hosted-session file operations. The SDK uploads to +`sample_files/contoso_q1_2026_report.txt`, relative to that sandbox's home directory. A portal/CLI upload to the home directory's root will not be visible to these tools; specify the `sample_files/` destination, or use this helper. No real upload is performed by the sample's offline tests. Send the request to the deployed agent's Responses endpoint, routing to the same -sandbox. Responses supports the query selector: - -```text -POST ?agent_session_id= -{"input":"Read contoso_q1_2026_report.txt and compare Q1 revenue."} -``` - -The equivalent body selector is: +sandbox with the **request body** selector: ```json { @@ -91,7 +86,12 @@ The equivalent body selector is: } ``` -Use **one** selector. Foundry routes it to the sandbox; the host validates the +The hosted platform's query-string `?agent_session_id=...` selector belongs to +the **Invocations** protocol, not Responses. Do not rely on a local SDK accepting +a query selector as proof that the deployed Responses endpoint routes it. +See the [protocol binding contract](https://learn.microsoft.com/azure/foundry/agents/how-to/manage-hosted-sessions#how-each-protocol-binds-an-invocation-to-a-session). + +Foundry routes the body selector to the sandbox; the host validates the resolved request identity against the platform-configured `FOUNDRY_AGENT_SESSION_ID`. A mismatch fails closed. Neither a caller option nor a filename can select another sandbox's home directory. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/upload_file.py b/python/samples/04-hosting/foundry-hosted-agents/responses/files/upload_file.py index f04ff3fd044..ac5243c4767 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/files/upload_file.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/upload_file.py @@ -1,7 +1,7 @@ # /// script # requires-python = ">=3.10" # dependencies = [ -# "azure-ai-projects>=2.2.0,<2.8.0", +# "azure-ai-projects>=2.3.0,<2.8.0", # "azure-identity", # "python-dotenv", # ] From 2a13c746db9372ca9ed6c9042264ceed7e5c7ec8 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 1 Oct 2026 11:10:16 +0200 Subject: [PATCH 7/7] fix(python): address hosted integration lifecycle review --- .../claw_step04_production_ready/README.md | 8 +- .../claw_step04_production_ready/hosted.py | 39 +++-- .../responses/custom_storage/README.md | 9 ++ .../responses/custom_storage/main.py | 9 ++ .../responses/files/README.md | 29 ++-- .../responses/files/file_access.py | 35 ++-- .../responses/files/tests/test_file_access.py | 150 ------------------ .../responses/foundry_memory/README.md | 6 + .../responses/mcp/README.md | 5 + .../responses/mcp/main.py | 1 + 10 files changed, 103 insertions(+), 188 deletions(-) delete mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/files/tests/test_file_access.py diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md index c3df56c2c2a..069cfa6492b 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/README.md @@ -117,9 +117,11 @@ instances. Local hosts keep their original builder defaults. Request-lifetime middleware releases only this agent's background-provider tasks for the active MAF session, in a `finally` path on success, failure or -cancellation. Streaming teardown wraps **consumption**, not construction of a -lazy stream. Outstanding research is cancelled and joined before the request's transports -close, using the provider's finite **30-second** default. A child that ignores +cancellation. An idempotent cleanup hook on the outer stream also handles a +stream closed **before its first update**, while the iterator's `finally` +handles partial consumption and run errors. Outstanding research is cancelled +and joined before the request's transports close, using the provider's finite +**30-second** default. A child that ignores cancellation is abandoned and logged when that bound expires, so it cannot hold transport teardown open indefinitely. Complete/collect research within a turn; unfinished runtime tasks cannot be resumed by a later factory-created diff --git a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py index 2969821a23a..7192ed02b57 100644 --- a/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py +++ b/python/samples/02-agents/harness/build_your_own_claw/claw_step04_production_ready/hosted.py @@ -186,18 +186,39 @@ async def release_background() -> AsyncIterator[None]: raise inner = context.result if not isinstance(inner, ResponseStream): - raise RuntimeError("The streaming hosted harness must return a ResponseStream.") + async with release_background(): + raise RuntimeError("The streaming hosted harness must return a ResponseStream.") - async def updates() -> AsyncIterator[AgentResponseUpdate]: + cleaned_up = False + + async def cleanup() -> None: + nonlocal cleaned_up + if cleaned_up: + return + cleaned_up = True async with release_background(): - try: - async for update in inner: - yield update - await inner.get_final_response() - finally: - await inner.close() + await inner.close() - context.result = ResponseStream(updates(), finalizer=lambda _: inner.get_final_response()) + async def updates() -> AsyncIterator[AgentResponseUpdate]: + run_failed = False + try: + async for update in inner: + yield update + await inner.get_final_response() + except BaseException: + run_failed = True + raise + finally: + try: + await cleanup() + except BaseException as cleanup_error: + logger.error("Failed to clean up the request stream (%s).", type(cleanup_error).__name__) + if not run_failed: + raise + + context.result = ResponseStream( + updates(), finalizer=lambda _: inner.get_final_response(), cleanup_hooks=[cleanup] + ) else: async with release_background(): await call_next() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md index 4ef68596421..06499456c6d 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/README.md @@ -22,6 +22,15 @@ snapshot under a response ID and a conversation ID; each key has its own ETag. The snapshot's inner `session_id` is preserved when loading. Keys are not built from caller model options or an inner model-service session ID. +Cosmos items have a 2 MB service limit. Before a create or replacement, this +sample checks the complete compact, escaped-JSON snapshot against a conservative +**2,000,000-byte budget**, leaving room for service metadata. Oversized state +fails explicitly before a backend write; a service 413 also becomes an +actionable size error. Reduce the state or start a new conversation instead of +retrying an oversized snapshot. This per-item check is not a retention policy +or aggregate storage quota; those remain tracked in +microsoft/agent-framework#8901. + **Existing unscoped data is not migrated.** The old `/user_id` container layout and connection-string configuration are intentionally replaced. Use a new container partitioned by `/scope_key` and start fresh conversations; do not fall diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/main.py index 349a5257c90..59ce7b7a6be 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/custom_storage/main.py @@ -26,6 +26,7 @@ import asyncio import hashlib +import json import os import uuid from contextlib import AsyncExitStack @@ -44,6 +45,10 @@ from dotenv import load_dotenv _CONFLICT = "Another request advanced this agent session; reload before writing." +_MAX_COSMOS_ITEM_BYTES = 2_000_000 +_SESSION_TOO_LARGE = ( + "Agent session exceeds the 2,000,000-byte Cosmos snapshot budget; reduce session state or start a new conversation." +) class CosmosSessionStore(SessionStore): @@ -91,6 +96,8 @@ async def set(self, session_id: str, session: AgentSession) -> None: "scope_key": self._scope_key, "session": session.to_dict(), } + if len(json.dumps(item, separators=(",", ":")).encode("utf-8")) > _MAX_COSMOS_ITEM_BYTES: + raise ValueError(_SESSION_TOO_LARGE) try: etag = self._etags.get(session_id) if etag is None: @@ -103,6 +110,8 @@ async def set(self, session_id: str, session: AgentSession) -> None: match_condition=MatchConditions.IfNotModified, ) except CosmosHttpResponseError as exc: + if exc.status_code == 413: + raise ValueError(_SESSION_TOO_LARGE) from exc if exc.status_code not in (409, 412): raise raise RuntimeError(_CONFLICT) from exc diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md index b08b61784de..dfb81edf78e 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/README.md @@ -10,11 +10,12 @@ The reader opens every directory component and the file without following symlinks, using directory descriptors rather than a check-then-open pathname. Replacing a directory or file with a symlink cannot redirect a read outside the upload directory. It rejects traversal, absolute paths, Windows-style paths, -control characters, directory/file symlinks, non-regular files, invalid UTF-8 and +control characters, directory/file symlinks, hard links, non-regular files, invalid UTF-8 and files larger than **1,000,000 bytes**. It checks size before reading, then uses a bounded read to catch growth during the read. POSIX descriptor-relative, -`O_NOFOLLOW` and `O_DIRECTORY` support are required; unsupported platforms fail -closed rather than falling back to an unsafe reader. +`O_NOFOLLOW` and `O_DIRECTORY` support are required **inside the sandbox** and +for local staging; unsupported platforms fail closed rather than falling back +to an unsafe sandbox reader. The hosted-upload helper can run on Windows. ## Prerequisites and lifecycle @@ -49,8 +50,12 @@ curl -X POST http://localhost:8088/responses \ -d '{"input":"Read contoso_q1_2026_report.txt and compare Q1 revenue."}' ``` -The helper applies the same bounded-read and symlink checks to the selected -source and destination. `--local` never calls Azure. Local query/body session IDs +The explicitly selected developer source is read portably: symlinked source +directories are resolved, the opened file must be regular UTF-8, and the byte +limit is checked before and after a bounded read. That source is operator-chosen, +not a path supplied by the model. The `--local` destination retains the strict +descriptor-relative, no-follow and hard-link checks. `--local` never calls Azure. +Local query/body session IDs do **not** create separate filesystem sandboxes: a local server is a single-user development process. To simulate two sandboxes, run hosts with separate `HOME` directories and upload only to the first. The second must list no uploads and @@ -74,7 +79,8 @@ SDK prerequisite for hosted-session file operations. The SDK uploads to `sample_files/contoso_q1_2026_report.txt`, relative to that sandbox's home directory. A portal/CLI upload to the home directory's root will not be visible to these tools; specify the `sample_files/` destination, or use -this helper. No real upload is performed by the sample's offline tests. +this helper. Live uploads require separately configured credentials and an +explicitly selected agent/session. Send the request to the deployed agent's Responses endpoint, routing to the same sandbox with the **request body** selector: @@ -101,14 +107,3 @@ same prompt to a fresh sandbox B without uploading: its `list_files()` must be empty and the named read must fail. Upload separately to B if it needs the file. Do not claim this live check ran unless those resources and uploads were explicitly authorized. - -## Offline checks - -From `python/`, run: - -```bash -uv run pytest samples/04-hosting/foundry-hosted-agents/responses/files/tests -q -``` - -The tests use temporary home directories, including descriptor-replacement -checks. They require no Foundry project, credentials, deployment or real files. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/file_access.py b/python/samples/04-hosting/foundry-hosted-agents/responses/files/file_access.py index 6dbd56b2dee..6642aecf81a 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/files/file_access.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/files/file_access.py @@ -69,6 +69,8 @@ def _read_file(directory: int, filename: str) -> bytes: metadata = os.fstat(descriptor) if not stat.S_ISREG(metadata.st_mode): raise ValueError("Only regular uploaded files can be read.") + if metadata.st_nlink != 1: + raise ValueError("Uploaded files must not have hard links.") if metadata.st_size > MAX_FILE_BYTES: raise ValueError("Only UTF-8 files of at most 1,000,000 bytes can be read.") except BaseException: @@ -91,7 +93,11 @@ def list_uploaded_files() -> list[str]: return [] try: with os.scandir(directory) as entries: - return sorted(entry.name for entry in entries if entry.is_file(follow_symlinks=False)) + return sorted( + entry.name + for entry in entries + if entry.is_file(follow_symlinks=False) and entry.stat(follow_symlinks=False).st_nlink == 1 + ) finally: os.close(directory) @@ -107,14 +113,22 @@ def read_uploaded_file(filename: str) -> str: def read_upload_source(source: Path) -> bytes: - """Read an explicitly selected local upload, with the same size and symlink checks.""" - source = source.expanduser().absolute() + """Read the operator-selected source portably; sandbox access remains descriptor-relative.""" validate_filename(source.name) - directory = _open_directory(source.parent) - try: - return _read_file(directory, source.name) - finally: - os.close(directory) + source = source.expanduser().resolve(strict=True) + if not source.is_file(): + raise ValueError("The upload source must be a regular UTF-8 file.") + with source.open("rb") as file: + metadata = os.fstat(file.fileno()) + if not stat.S_ISREG(metadata.st_mode): + raise ValueError("The upload source must be a regular UTF-8 file.") + if metadata.st_size > MAX_FILE_BYTES: + raise ValueError("Only UTF-8 files of at most 1,000,000 bytes can be uploaded.") + data = file.read(MAX_FILE_BYTES + 1) + if len(data) > MAX_FILE_BYTES: + raise ValueError("Only UTF-8 files of at most 1,000,000 bytes can be uploaded.") + data.decode("utf-8") + return data def write_local_upload(filename: str, data: bytes) -> None: @@ -132,8 +146,11 @@ def write_local_upload(filename: str, data: bytes) -> None: dir_fd=directory, ) try: - if not stat.S_ISREG(os.fstat(descriptor).st_mode): + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): raise ValueError("Only regular uploaded files can be written.") + if metadata.st_nlink != 1: + raise ValueError("Uploaded files must not have hard links.") except BaseException: os.close(descriptor) raise diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/files/tests/test_file_access.py b/python/samples/04-hosting/foundry-hosted-agents/responses/files/tests/test_file_access.py deleted file mode 100644 index ad7b7d3978e..00000000000 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/files/tests/test_file_access.py +++ /dev/null @@ -1,150 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""Focused negative checks for the sandbox file-access boundary.""" - -from __future__ import annotations - -import importlib.util -import os -import stat -from pathlib import Path -from types import ModuleType, SimpleNamespace -from typing import Any - -import pytest - -pytestmark = pytest.mark.skipif( - not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"), - reason="This hosted sample requires POSIX no-follow directory opens.", -) - - -@pytest.fixture -def access(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> ModuleType: - monkeypatch.setenv("HOME", str(tmp_path)) - spec = importlib.util.spec_from_file_location("sample_file_access", Path(__file__).parents[1] / "file_access.py") - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def test_local_upload_round_trip_and_cross_sandbox_isolation( - access: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - source = tmp_path / "report.txt" - source.write_text("Explicit upload.", encoding="utf-8") - access.write_local_upload(source.name, access.read_upload_source(source)) - assert access.list_uploaded_files() == ["report.txt"] - assert access.read_uploaded_file("report.txt") == "Explicit upload." - other = tmp_path / "other-sandbox" - other.mkdir() - monkeypatch.setenv("HOME", str(other)) - assert access.list_uploaded_files() == [] - with pytest.raises(FileNotFoundError): - access.read_uploaded_file("report.txt") - - -def test_paths_symlinks_and_nonregular_files_are_rejected(access: ModuleType, tmp_path: Path) -> None: - root = tmp_path / "sample_files" - root.mkdir() - private = tmp_path / "private.txt" - private.write_text("Private data.", encoding="utf-8") - (root / "link.txt").symlink_to(private) - (root / "directory").mkdir() - os.mkfifo(root / "pipe") - for filename in ("", "..", "../private.txt", str(private), r"C:\private.txt", "file\x00.txt", "file\n.txt"): - with pytest.raises(ValueError, match="single file name"): - access.read_uploaded_file(filename) - with pytest.raises(OSError): - access.read_uploaded_file("link.txt") - for filename in ("directory", "pipe"): - with pytest.raises(ValueError, match="regular uploaded files"): - access.read_uploaded_file(filename) - with pytest.raises(OSError): - access.write_local_upload("link.txt", b"Do not overwrite private data.") - assert private.read_text(encoding="utf-8") == "Private data." - assert access.list_uploaded_files() == [] - - -def test_directory_symlinks_and_missing_no_follow_fail_closed( - access: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - private = tmp_path / "private" - private.mkdir() - (tmp_path / "sample_files").symlink_to(private, target_is_directory=True) - with pytest.raises(OSError): - access.list_uploaded_files() - home_link = tmp_path / "home-link" - home_link.symlink_to(private, target_is_directory=True) - monkeypatch.setenv("HOME", str(home_link)) - with pytest.raises(OSError): - access.read_uploaded_file("report.txt") - monkeypatch.delattr(access.os, "O_NOFOLLOW") - with pytest.raises(RuntimeError, match="no-follow"): - access.read_uploaded_file("report.txt") - - -def test_byte_limit_is_checked_before_read_and_after_growth( - access: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "sample_files" - root.mkdir() - (root / "limit.txt").write_bytes(b"x" * 1_000_000) - assert len(access.read_uploaded_file("limit.txt")) == 1_000_000 - (root / "large.txt").write_bytes(b"x" * 1_000_001) - original_fdopen = os.fdopen - reads: list[int] = [] - - class Reader: - def __init__(self, descriptor: int) -> None: - self.file = original_fdopen(descriptor, "rb") - - def __enter__(self) -> Reader: - return self - - def __exit__(self, *args: Any) -> None: - self.file.close() - - def read(self, size: int) -> bytes: - reads.append(size) - return self.file.read(size) - - monkeypatch.setattr(access.os, "fdopen", lambda descriptor, mode: Reader(descriptor)) - with pytest.raises(ValueError, match="1,000,000 bytes"): - access.read_uploaded_file("large.txt") - assert reads == [] - monkeypatch.setattr(access.os, "fstat", lambda descriptor: SimpleNamespace(st_mode=stat.S_IFREG, st_size=1)) - with pytest.raises(ValueError, match="1,000,000 bytes"): - access.read_uploaded_file("large.txt") - assert reads == [1_000_001] - - -@pytest.mark.parametrize("replacement", ["directory", "file"]) -def test_replacement_cannot_redirect_a_read( - access: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, replacement: str -) -> None: - root, private = tmp_path / "sample_files", tmp_path / "private" - root.mkdir() - private.mkdir() - (root / "report.txt").write_text("Original upload.", encoding="utf-8") - (private / "report.txt").write_text("Private data.", encoding="utf-8") - original_open = os.open - - def replace(path: Any, flags: int, mode: int = 0o777, *, dir_fd: int | None = None) -> int: - if path == "report.txt": - if replacement == "directory": - root.rename(tmp_path / "original-uploads") - root.symlink_to(private, target_is_directory=True) - else: - (root / "report.txt").unlink() - (root / "report.txt").symlink_to(private / "report.txt") - return original_open(path, flags, mode, dir_fd=dir_fd) - - monkeypatch.setattr(access.os, "open", replace) - monkeypatch.setattr(access.os, "supports_dir_fd", {*os.supports_dir_fd, replace}) - if replacement == "directory": - assert access.read_uploaded_file("report.txt") == "Original upload." - else: - with pytest.raises(OSError): - access.read_uploaded_file("report.txt") diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md index c6e01d2f0ba..8c7ed49af51 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/foundry_memory/README.md @@ -32,6 +32,12 @@ and requires a trusted user and call ID before constructing the integration. Caller model options cannot choose that namespace, and literal template strings such as `{{$userId}}` are **not** substituted by the framework. +Sharing is user-wide **within the configured project Memory Store**, including +different agents that use that same store and user-scope algorithm. The agent +name is deliberately not part of the hash. Configure separate Memory Stores +when agents must not share a user's long-term memories; do not assume sandbox +or agent names provide that boundary. + `history_source="agent_server"` supplies conversation history from the outer Responses service and disables inner model storage. This is separate from application-owned long-term Memory: outer `store=false` disables host-managed diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/README.md index a8526d16c88..479f548be6c 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/README.md @@ -21,6 +21,11 @@ the sample does not silently run an agent with its GitHub integration disabled. The PAT is deployment-owned and selects a single external GitHub account, **not** the Foundry calling user's GitHub identity. Use a least-privilege, read-only token for this demonstration and restrict who can call the agent. +The MCP configuration additionally permits only `get_me`, +`search_repositories` and `get_file_contents`. All other tools, including +write operations, are excluded even if the PAT has broader permissions. +Auto-approval applies only to those listed read tools; changing the allowlist +is an explicit operator code change, not a caller option. For per-user GitHub access, use an appropriately configured user-authenticated Toolbox connection instead. Do not pass a PAT in model options or log it; platform user/call headers are not forwarded to GitHub. diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/main.py index 3e4cfcbfb48..f954619af72 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/mcp/main.py @@ -63,6 +63,7 @@ async def __aexit__( name="GitHub", url="https://api.githubcopilot.com/mcp/", headers={"Authorization": f"Bearer {github_pat}"}, + allowed_tools=["get_me", "search_repositories", "get_file_contents"], approval_mode="never_require", ) return Agent(