From 6371a4df1b1ae6d76476d91837b307e916e2c5c8 Mon Sep 17 00:00:00 2001 From: vaibhav-patel Date: Thu, 9 Jul 2026 13:17:56 +0400 Subject: [PATCH 1/2] Python: Add hosted agent sample for the agent harness --- .../samples/04-hosting/af-hosting/README.md | 1 + .../local_responses_harness/README.md | 96 +++++++ .../af-hosting/local_responses_harness/app.py | 236 ++++++++++++++++++ .../local_responses_harness/call_server.py | 61 +++++ .../local_responses_harness/pyproject.toml | 26 ++ .../storage/.gitignore | 2 + 6 files changed, 422 insertions(+) create mode 100644 python/samples/04-hosting/af-hosting/local_responses_harness/README.md create mode 100644 python/samples/04-hosting/af-hosting/local_responses_harness/app.py create mode 100644 python/samples/04-hosting/af-hosting/local_responses_harness/call_server.py create mode 100644 python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml create mode 100644 python/samples/04-hosting/af-hosting/local_responses_harness/storage/.gitignore diff --git a/python/samples/04-hosting/af-hosting/README.md b/python/samples/04-hosting/af-hosting/README.md index 23ee48cadfd..825d8fef190 100644 --- a/python/samples/04-hosting/af-hosting/README.md +++ b/python/samples/04-hosting/af-hosting/README.md @@ -10,6 +10,7 @@ clients, authentication, response construction, and deployment shape. | Sample | What it shows | Packaging | |---|---|---| | [`local_responses/`](./local_responses) | One agent + one `@tool` + native FastAPI route + Responses helper functions + `AgentState` / `SessionStore`. | **Local only.** Start here to learn the helper seam. | +| [`local_responses_harness/`](./local_responses_harness) | The same helper seam as `local_responses/`, but the hosted target is a batteries-included harness agent built with `create_harness_agent` (function-invocation loop, per-service-call persistence, compaction, todo management). | **Local only.** | | [`local_responses_workflow/`](./local_responses_workflow) | A workflow target behind a native FastAPI route using Responses helper functions, `WorkflowState`, explicit `CheckpointStorage`, and an app-owned checkpoint cursor. | **Local only.** | Each sample is self-contained with its own `pyproject.toml`, server `app.py`, diff --git a/python/samples/04-hosting/af-hosting/local_responses_harness/README.md b/python/samples/04-hosting/af-hosting/local_responses_harness/README.md new file mode 100644 index 00000000000..4957c6e3a3f --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_harness/README.md @@ -0,0 +1,96 @@ +# local_responses_harness — hosting a harness agent behind Responses routes + +The sibling of [`local_responses/`](../local_responses), with one change: the +hosted target is a batteries-included **harness agent** built with +[`create_harness_agent`](../../../02-agents/harness/README.md) instead of a plain +`Agent`. + +Everything else is the same helper-first Responses hosting shape: one native +FastAPI route, a small `SessionStore` via `AgentState`, and the Responses helper +functions: + +- `responses_to_run(...)` +- `responses_session_id(...)` +- `create_response_id(...)` +- `responses_from_run(...)` +- `responses_from_streaming_run(...)` + +The takeaway is that a harness agent is just an `Agent`, so it drops straight +into the same `AgentState` / Responses-helper seam as any other target. The +harness supplies the function-invocation loop, per-service-call history +persistence, context-window compaction, todo management, and heuristic tool +approval on top of a single `@tool`. + +What the sample does with the harness: + +- Turns off the interactive-only features (plan/execute mode and the Textual + console) because a one-shot HTTP request has no console to drive. +- Turns off web search to keep the sample self-contained. +- Keeps todo management and compaction enabled, so the target is a genuine + harness agent and not just a relabelled plain `Agent`. +- Registers `lookup_weather` with `approval_mode="never_require"` so a headless + run never blocks waiting for a human to approve a tool call. + +What the route demonstrates (identical to `local_responses/`): + +- Uses an explicit request-option allowlist. This sample only allows + `max_tokens` and `reasoning`; all other caller-supplied options, including + `model`, `temperature`, `store`, `tools`, and `tool_choice`, are denied by + default. Your app decides the exact allowed, altered, and denied options. +- Produces the AF messages, options, and session id that the route passes to + `agent.run(...)`. +- **Stores** each newly minted response id for the session it was just resolved + from, via `state.set_session(response_id, session)` after `agent.run(...)` has + updated the session. OpenAI's `previous_response_id` rotates every turn *by + design* — it lets a caller continue from any earlier response, not just the + latest one — so every response id needs to stay independently resolvable. +- Treats an unknown `conversation_id` as a request to create a new local + session. Your app can choose a stricter policy. + +`app:app` is a module-level FastAPI ASGI app; recommended local launch is +Hypercorn. + +## Production readiness + +This is not a full-fledged production deployment. Before exposing this pattern +to callers, add authentication and authorization at the infrastructure layer, +the FastAPI app layer, or inside the route body. + +Session continuation deserves particular care: treat `previous_response_id` and +`conversation_id` as untrusted request values, authorize the caller before +loading or storing a session for those ids, and partition any durable session +store by tenant/user as appropriate for your application. + +## Run + +```bash +export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com +export FOUNDRY_MODEL=gpt-5-nano +az login + +uv sync +uv run hypercorn app:app --bind 0.0.0.0:8000 +``` + +Single-process for quick iteration: + +```bash +uv run python app.py +``` + +## Call locally + +```bash +uv sync --group dev + +# Plain OpenAI SDK call: +uv run python call_server.py +``` + +The client intentionally omits `model`; the app chooses the backing deployment +from `FOUNDRY_MODEL`. The script then sends two more turns, each continuing from +the previous turn's `response.id` as `previous_response_id`. The third turn asks +about the first turn's city, so it only succeeds if the harness agent behind the +route still remembers that far back in the chain. + +> This sample is **local-only** — no Dockerfile, no Foundry packaging. diff --git a/python/samples/04-hosting/af-hosting/local_responses_harness/app.py b/python/samples/04-hosting/af-hosting/local_responses_harness/app.py new file mode 100644 index 00000000000..51e2c5ed041 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_harness/app.py @@ -0,0 +1,236 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Responses-only hosting sample that serves a harness agent. + +This sample is the sibling of ``local_responses/``. It keeps the exact same +helper-first hosting shape but swaps the plain ``Agent`` target for a +batteries-included harness agent built with ``create_harness_agent``: + +1. ``create_harness_agent`` (from core ``agent-framework``) assembles the full + agent pipeline from a chat client: the function-invocation loop, + per-service-call history persistence, context-window compaction, a todo + provider, and heuristic tool approval. +2. ``agent-framework-hosting-responses`` converts Responses request/response + payloads to and from Agent Framework run values. +3. ``agent-framework-hosting`` owns shared execution state via ``AgentState`` + and its ``SessionStore``. +4. FastAPI owns the route, request parsing, policy decisions, and response + object. + +The point of the sample is that a harness agent is just an ``Agent``, so it +drops straight into the same ``AgentState`` / Responses-helper seam as any +other target. The interactive-only harness features (plan/execute mode and the +Textual console) are turned off because a one-shot HTTP request has no console +to drive; web search is turned off to keep the sample self-contained. Todo +management and compaction stay on so the target is a genuine harness agent and +not just a relabelled plain ``Agent``. + +Because the server runs headless, the ``lookup_weather`` tool is registered +with ``approval_mode="never_require"`` so a run never blocks waiting for a human +to approve a tool call. + +Production readiness +--- +This sample is not a full-fledged production deployment. Before exposing this +route to callers, add authentication and authorization at the infrastructure +layer, the FastAPI app layer, or inside the route body. + +Session continuation deserves particular care: treat ``previous_response_id`` +and ``conversation_id`` as untrusted request values, authorize the caller +before loading or storing a session for those ids, and partition durable session +storage by tenant/user as appropriate for your application. See +``README.md#production-readiness``. + +Unknown ``conversation_id`` values create a new local session in this sample. +Your app can choose a different policy, such as requiring a separate API to +create new conversations before callers can continue them. + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint URL + FOUNDRY_MODEL — Model deployment name + +Run +--- +``app`` is a module-level FastAPI ASGI app. Recommended local launch:: + + uv sync + az login + export FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com + export FOUNDRY_MODEL=gpt-5-nano + uv run hypercorn app:app --bind 0.0.0.0:8000 + +Or use the ``__main__`` block (single-process Hypercorn) for quick +iteration:: + + uv run python app.py + +Then call it:: + + uv run python call_server.py "What is the weather in Tokyo?" +""" + +from __future__ import annotations + +import asyncio +import os +from collections.abc import AsyncIterator +from typing import Annotated, Any, cast + +from agent_framework import Agent, ResponseStream, create_harness_agent, tool +from agent_framework_foundry import FoundryChatClient +from agent_framework_hosting import AgentState +from agent_framework_hosting_responses import ( + create_response_id, + responses_from_run, + responses_from_streaming_run, + responses_session_id, + responses_to_run, +) +from azure.identity import AzureCliCredential +from fastapi import Body, FastAPI, HTTPException +from fastapi.responses import JSONResponse, StreamingResponse +from hypercorn.asyncio import serve +from hypercorn.config import Config + +# Token budget for the harness compaction feature. +MAX_CONTEXT_WINDOW_TOKENS = 128_000 +MAX_OUTPUT_TOKENS = 16_384 + +WEATHER_INSTRUCTIONS = ( + "You are a friendly weather assistant. Use the lookup_weather tool for any " + "weather question and answer in one short sentence." +) + + +@tool(approval_mode="never_require") +def lookup_weather( + location: Annotated[str, "The city to look up weather for."], +) -> str: + """Return a deterministic weather report for a city.""" + high_temp = 5 + (sum(location.encode("utf-8")) % 21) + reports = { + "Seattle": f"Seattle is rainy with a high of {high_temp}°C.", + "Amsterdam": f"Amsterdam is cloudy with a high of {high_temp}°C.", + "Tokyo": f"Tokyo is clear with a high of {high_temp}°C.", + } + return reports.get(location, f"{location} is sunny with a high of {high_temp}°C.") + + +def create_agent() -> Agent: + """Create the sample harness-based weather agent. + + ``create_harness_agent`` returns a plain ``Agent``, so the resulting target + plugs into ``AgentState`` and the Responses helpers exactly like the + ``local_responses`` sample's agent does. The harness supplies function + invocation, per-service-call persistence, compaction, and todo management on + top of the ``lookup_weather`` tool. + """ + return create_harness_agent( + # For authentication, run `az login` in a terminal or replace + # AzureCliCredential with your preferred authentication option. + client=FoundryChatClient(credential=AzureCliCredential()), + name="HarnessWeatherAgent", + description="A batteries-included harness agent that answers weather questions.", + agent_instructions=WEATHER_INSTRUCTIONS, + tools=[lookup_weather], + max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS, + max_output_tokens=MAX_OUTPUT_TOKENS, + # Turn off the interactive-only and provider-specific features so the + # agent is a good fit for a headless, one-shot HTTP endpoint. Todo + # management and compaction stay enabled. + disable_mode=True, + disable_web_search=True, + # The app owns session state locally, so do not also persist server-side + # Responses conversations. + default_options={"store": False}, + ) + + +app = FastAPI() +state = AgentState(create_agent) + +ALLOWED_REQUEST_OPTIONS = frozenset({"max_tokens", "reasoning"}) + + +@app.post("/responses", response_model=None) +async def responses(body: dict[str, Any] = Body(...)) -> JSONResponse | StreamingResponse: # noqa: B008 + """Handle one OpenAI Responses-shaped request.""" + try: + run = responses_to_run(body) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + session_id = responses_session_id(body) + response_id = create_response_id() + + # App-specific policy: allow only the request options this route is willing + # to honor. This denies tools, tool_choice, deployment/persistence fields, + # and all other caller-supplied options by default. Your app decides which + # options are allowed, altered, or denied. + options = {key: value for key, value in run["options"].items() if key in ALLOWED_REQUEST_OPTIONS} + options_for_run = cast(Any, options) + + target = await state.get_target() + lookup_id = session_id or response_id + # An unknown `conversation_id` becomes a new session here. Production apps + # can choose to require a separate "create conversation" API instead. + session = await state.get_or_create_session(lookup_id) + if run["stream"]: + stream = target.run( + run["messages"], + stream=True, + session=session, + options=options_for_run, + ) + if not isinstance(stream, ResponseStream): + raise HTTPException(status_code=500, detail="agent did not return a response stream") + + async def stream_events() -> AsyncIterator[str]: + async for event in responses_from_streaming_run( + stream, + response_id=response_id, + session_id=session_id, + ): + yield event + # `agent.run(..., stream=True)` updates the session while the stream + # is consumed/finalized. Store it under the newly minted response id + # after finalization so a later `previous_response_id` can restore + # this exact continuation point. + await state.set_session(response_id, session) + + return StreamingResponse( + stream_events(), + media_type="text/event-stream", + ) + + result = await target.run( + run["messages"], + session=session, + options=options_for_run, + ) + # `agent.run(...)` updates the session. Store it under the newly minted + # response id after the run so `previous_response_id=response_id` continues + # from this exact point. + await state.set_session(response_id, session) + return JSONResponse( + responses_from_run( + result, + response_id=response_id, + session_id=session_id, + ) + ) + + +async def main() -> None: + """Run the sample with Hypercorn for local development.""" + config = Config() + config.bind = [f"0.0.0.0:{int(os.environ.get('PORT', '8000'))}"] + await serve(cast(Any, app), config) + + +if __name__ == "__main__": + asyncio.run(main()) + +# Sample output: +# User: What is the weather in Tokyo? +# Agent: Tokyo is clear with a high of 18°C. +# Response ID: resp_... diff --git a/python/samples/04-hosting/af-hosting/local_responses_harness/call_server.py b/python/samples/04-hosting/af-hosting/local_responses_harness/call_server.py new file mode 100644 index 00000000000..df1c5863a14 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_harness/call_server.py @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Local client for the local_responses_harness sample. + +Posts to ``/responses`` using the standard ``openai`` SDK. + +Start the server first (in another shell):: + + uv run python app.py + +Then:: + + uv run python call_server.py + +The script sends two follow-up turns, each continuing from the previous turn's +``response.id`` as ``previous_response_id``. The third turn asks about +information from the *first* turn only, so it also exercises session continuity +across a rotating response id chain, not just a single hop. Session state is +managed by the harness agent behind the hosted route. +""" + +from __future__ import annotations + +from openai import OpenAI + +BASE_URL = "http://127.0.0.1:8000" +PROMPT = "What is the weather in Tokyo?" +FOLLOW_UP_PROMPT = "And what about Amsterdam?" +THIRD_PROMPT = "Which of the two cities we just discussed is warmer?" + + +def main() -> None: + client = OpenAI(base_url=BASE_URL, api_key="not-needed") + response = client.responses.create( + input=PROMPT, + ) + print(f"User: {PROMPT}") + print(f"Agent: {response.output_text}") + print(f"Response ID: {response.id}") + + follow_up = client.responses.create( + input=FOLLOW_UP_PROMPT, + previous_response_id=response.id, + ) + print() + print(f"User: {FOLLOW_UP_PROMPT}") + print(f"Agent: {follow_up.output_text}") + print(f"Response ID: {follow_up.id}") + + third = client.responses.create( + input=THIRD_PROMPT, + previous_response_id=follow_up.id, + ) + print() + print(f"User: {THIRD_PROMPT}") + print(f"Agent: {third.output_text}") + print(f"Response ID: {third.id}") + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml b/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml new file mode 100644 index 00000000000..0a00e82d248 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_harness/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "agent-framework-hosting-sample-local-responses-harness" +version = "0.0.1" +description = "Local Responses hosting sample that serves a harness agent behind native FastAPI routes." +requires-python = ">=3.10" +dependencies = [ + "agent-framework-foundry", + "agent-framework-hosting", + "agent-framework-hosting-responses", + "azure-identity", + "aiohttp>=3.13.5", + "fastapi>=0.115.0,<0.138.1", + "hypercorn>=0.17", +] + +[dependency-groups] +dev = [ + "openai>=1.99", +] + +[tool.uv] +package = false + +[tool.uv.sources] +agent-framework-hosting = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting" } +agent-framework-hosting-responses = { git = "https://github.com/microsoft/agent-framework.git", branch = "main", subdirectory = "python/packages/hosting-responses" } diff --git a/python/samples/04-hosting/af-hosting/local_responses_harness/storage/.gitignore b/python/samples/04-hosting/af-hosting/local_responses_harness/storage/.gitignore new file mode 100644 index 00000000000..d6b7ef32c84 --- /dev/null +++ b/python/samples/04-hosting/af-hosting/local_responses_harness/storage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore From b36eb7f9bdcf1c41fa9e17d8bb611b0523c5c72a Mon Sep 17 00:00:00 2001 From: vaibhav-patel Date: Thu, 9 Jul 2026 14:18:26 +0400 Subject: [PATCH 2/2] Disable file providers and fix call_server usage in hosted harness sample Addresses PR review: disable the harness file-memory and file-access providers so the headless sample doesn't expose file tools or write outside storage/, and correct the app.py docstring to match call_server.py (which takes no prompt argument). --- .../04-hosting/af-hosting/local_responses_harness/app.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/samples/04-hosting/af-hosting/local_responses_harness/app.py b/python/samples/04-hosting/af-hosting/local_responses_harness/app.py index 51e2c5ed041..23f2b0a71f3 100644 --- a/python/samples/04-hosting/af-hosting/local_responses_harness/app.py +++ b/python/samples/04-hosting/af-hosting/local_responses_harness/app.py @@ -66,7 +66,7 @@ Then call it:: - uv run python call_server.py "What is the weather in Tokyo?" + uv run python call_server.py """ from __future__ import annotations @@ -140,6 +140,11 @@ def create_agent() -> Agent: # management and compaction stay enabled. disable_mode=True, disable_web_search=True, + # Disable the file-memory and file-access providers. In a headless HTTP + # sample their read/write file tools would expose unintended tools, create + # on-disk side effects outside storage/, and could block on tool approval. + disable_file_memory=True, + disable_file_access=True, # The app owns session state locally, so do not also persist server-side # Responses conversations. default_options={"store": False},