Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
084ce7b
Python: Bump azure-ai-agentserver-* to b7/b6/b8 pre-release wheels
alliscode Jun 26, 2026
b2ee149
Python: Add steerable_conversations support to ResponsesHostServer
alliscode Jun 29, 2026
bb37af0
Python: Fix _handle_response async generator + steering test
alliscode Jun 29, 2026
bc48896
Python: Update steering test to use real FoundryChatClient agent
alliscode Jun 29, 2026
d101df2
Python: Make resilient_background opt-in, remove auto-enable
alliscode Jul 1, 2026
e7e2692
Python: Add client.py steering demo to 01_basic sample
alliscode Jul 1, 2026
d2053a4
Python: Move steering and resilience to dedicated samples (13, 14)
alliscode Jul 1, 2026
d7b5f5c
Python: Replace manual kill steps with automated demo.py in 14_resili…
alliscode Jul 1, 2026
ec0ba56
Fix samples
TaoChenOSU Jul 8, 2026
d9e718e
Add workflow sample
TaoChenOSU Jul 9, 2026
d28d9c7
Add workflow context aware checkpoint storage and recovery path
TaoChenOSU Jul 10, 2026
072b8dd
Detect if checkpoint is supported via option and flag
TaoChenOSU Jul 9, 2026
ce8aa4b
Remove non-streaming in invoking the inner agent
TaoChenOSU Jul 10, 2026
de33b75
Code cleanup
TaoChenOSU Jul 13, 2026
fca1bce
Commit response state at checkpoint creation
TaoChenOSU Jul 13, 2026
fa10c3f
fix: update agentserver wheel sources and fix 2 failing tests
alliscode Jul 13, 2026
8936d71
Add durable workflow response recovery
alliscode Jul 14, 2026
9208c65
Address durable workflow review feedback
alliscode Jul 15, 2026
094f365
Fix durable workflow recovery boundaries
alliscode Jul 15, 2026
1bfe269
Document private preview wheel setup
alliscode Jul 15, 2026
e139ad2
Fail fast without durability preview
alliscode Jul 15, 2026
00a08c5
Use released core with preview hosting wheel
alliscode Jul 15, 2026
596538f
Fix hosted workflow checkpoint persistence
alliscode Jul 15, 2026
216ae1d
Removing intentional sleep from samples as intentional crashes now ma…
alliscode Jul 15, 2026
b9f23c7
Address durable workflow review feedback
alliscode Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,220 changes: 876 additions & 344 deletions python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py

Large diffs are not rendered by default.

1,120 changes: 926 additions & 194 deletions python/packages/foundry_hosting/tests/test_responses.py

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion python/pyrightconfig.samples.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@
"**/05-end-to-end/**",
"**/harness/**",
"**/agent_with_foundry_tracing.py",
"**/azure_responses_client_with_foundry.py"
"**/azure_responses_client_with_foundry.py",
"**/monty_code_act.py",
"**/monty_code_interpreter.py",
"**/monty_code_interpreter_manual_wiring.py",
"**/11_monty_codeact/**",
"**/local_shell_with_allowlist.py",
"**/local_shell_with_environment_provider.py",
"**/client_with_local_shell.py"
],
"typeCheckingMode": "basic",
"reportMissingImports": "error",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ To have a multi-turn conversation with the agent, include the previous response
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "How are you?", "previous_response_id": "REPLACE_WITH_PREVIOUS_RESPONSE_ID"}'
```

## Next steps

- [13_steering](../13_steering) — steerable conversations: queue a new turn while the previous one is still running
- [15_workflow_resilience](../15_workflow_resilience) — durable workflow recovery across host restarts

## 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.
Comment thread
alliscode marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT="..."
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM python:3.12-slim

WORKDIR /app

COPY . user_agent/
WORKDIR /app/user_agent

RUN if [ -f requirements.txt ]; then \
pip install -r requirements.txt; \
else \
echo "No requirements.txt found"; \
fi

EXPOSE 8088

CMD ["python", "main.py"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# What this sample demonstrates

How to enable **steerable conversations** with `ResponsesHostServer`.

When `steerable_conversations=True`, a client can send a new turn to the same
conversation while the previous turn is still running. Instead of receiving
HTTP 409 `conversation_locked`, the new turn is queued and the running handler
receives a cooperative cancellation signal. Once the current turn terminates,
the queued turn runs.

## How It Works

1. The server starts with `steerable_conversations=True`.
2. Turn 1 is sent as a foreground streaming request on a shared conversation.
3. Two seconds later, while turn 1 is still streaming, turn 2 arrives on the
same conversation. The server immediately returns `status=queued`.
4. The running handler observes `cancellation_signal.is_set()` (with
`context.client_cancelled=False`, distinguishing steering from a real cancel)
and emits `response.completed` with partial output.
5. Once turn 1 finishes the framework drains the queue and invokes the handler
again for turn 2 (with `context.is_steered_turn=True`).

See [main.py](main.py) for the server and [client.py](client.py) for the
interactive demo.

## Running the Sample

**Terminal 1 — start the server:**

```bash
uv run python main.py
```

**Terminal 2 — run the demo client:**

```bash
uv run python client.py
```

You will see turn 1 streaming, turn 2 arriving as `status=queued`, turn 1
cutting off, and then turn 2's answer appearing. The server terminal shows
the steering signal and handler cancellation in real time.

You can also point the client at a deployed instance:

```bash
uv run python client.py https://your-deployed-agent-url
```

## Environment Variables

Copy `.env.example` to `.env` and fill in your values:

```
FOUNDRY_PROJECT_ENDPOINT=https://...
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
```

Run `az login` before starting the server.

## 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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Copyright (c) Microsoft. All rights reserved.

"""Steerable conversations demo client.

Demonstrates steerable_conversations by sending two concurrent turns to a
running ResponsesHostServer. Run this while main.py is running in a second
terminal to see steering in action.

Usage
-----
Terminal 1 — start the server:
uv run python main.py

Terminal 2 — run this client:
uv run python client.py [SERVER_URL]

SERVER_URL defaults to http://localhost:8088.
Set it to your deployed agent URL to test against a hosted instance.

What you will see
-----------------
Turn 1 starts streaming a long response (counting to 50). Two seconds later,
turn 2 arrives on the same conversation with a different question. Because the
server has steerable_conversations=True, turn 2 is accepted immediately with
status=queued rather than rejected with 409. The server then cancels turn 1's
handler, which emits a partial response.completed event. Once turn 1 finishes,
turn 2 runs and streams its answer.

Watch the server terminal to see the steering signal, cancellation, and drain
logged in real time.
"""

from __future__ import annotations

import asyncio
import json
import os
import sys
from typing import Any

import httpx
from dotenv import load_dotenv

load_dotenv()

_DEFAULT_BASE = "http://localhost:8088"
_MODEL = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")


def _parse_sse(chunk: str) -> dict[str, Any] | None:
for line in chunk.splitlines():
if line.startswith("data: "):
try:
return json.loads(line[6:]) # type: ignore[no-any-return]
except json.JSONDecodeError:
pass
return None


async def demo(base: str) -> None:
"""Run the steering demonstration against *base* URL."""
import uuid

conv_id = f"demo-{uuid.uuid4().hex[:8]}"
turn1_text: list[str] = []
turn1_terminal: str | None = None

limits = httpx.Limits(max_keepalive_connections=5, max_connections=5)
async with httpx.AsyncClient(base_url=base, timeout=120, limits=limits) as client:
print(f"\nConnected to {base}")
print(f"Conversation ID: {conv_id}\n")

# ------------------------------------------------------------------
# Turn 1: ask the agent to count slowly. Stream the response in
# real-time so we can see the partial output before steering.
# ------------------------------------------------------------------
async def stream_turn1() -> None:
nonlocal turn1_terminal
print(">>> Turn 1: 'Count from 1 to 50, one number per line.'")
async with client.stream(
"POST",
"/responses",
json={
"model": _MODEL,
"input": "Count from 1 to 50, one number per line.",
"stream": True,
"store": True,
# "conversation" is the b8 field for a shared conversation ID.
# Both turns must carry the same value to share the same
# multi-turn steerable task.
"conversation": conv_id,
},
) as resp:
assert resp.status_code == 200, f"Turn 1 failed: {resp.status_code}"
async for chunk in resp.aiter_text():
event = _parse_sse(chunk)
if event is None:
continue
et = event.get("type", "")
if et == "response.output_item.delta":
delta = (
event.get("delta", {}).get("text", "")
or event.get("delta", {}).get("content", "")
or ""
)
if delta:
print(delta, end="", flush=True)
turn1_text.append(delta)
elif et in ("response.completed", "response.failed", "response.cancelled"):
turn1_terminal = et.split(".")[-1]
break

# ------------------------------------------------------------------
# Turn 2: sent 2 s later while turn 1 is still streaming.
# background=True returns status=queued immediately so we don't block.
# ------------------------------------------------------------------
async def send_turn2() -> dict[str, Any]:
await asyncio.sleep(2)
print("\n\n>>> Turn 2 (sent while turn 1 is still running): 'What is the capital of France?'")
r2 = await client.post(
"/responses",
json={
"model": _MODEL,
"input": "What is the capital of France?",
"background": True,
"store": True,
"conversation": conv_id,
},
)
assert r2.status_code == 200, f"Turn 2 failed: {r2.status_code} {r2.text}"
return r2.json() # type: ignore[no-any-return]

t1 = asyncio.create_task(stream_turn1())
r2 = await send_turn2()

r2_id = r2["id"]
r2_status = r2["status"]
print(f"\n Turn 2 response ID : {r2_id}")
print(f" Turn 2 status : {r2_status}")

if r2_status != "queued":
print(
f"\nUnexpected status {r2_status!r} — expected 'queued'.\n"
" If 'conflict': the server may not have steerable_conversations=True.\n"
" If 'in_progress': turn 1 completed before turn 2 arrived; "
"the model may be responding too quickly for this prompt."
)

# Wait for turn 1 to drain
await t1
print(f"\n Turn 1 terminated : response.{turn1_terminal}")
print(f" Turn 1 text so far : {len(''.join(turn1_text))} chars")

# Poll turn 2 until it completes, then print the agent's reply
print("\n>>> Waiting for turn 2 to complete...")
for _ in range(120):
await asyncio.sleep(0.5)
s2_resp = (await client.get(f"/responses/{r2_id}")).json()
s2 = s2_resp["status"]
print(f" status: {s2}")
if s2 in ("completed", "failed", "cancelled"):
if s2 == "completed":
output_text = ""
for item in s2_resp.get("output", []):
for part in item.get("content", []):
output_text += part.get("text", "") or part.get("content", "")
print(f"\n>>> Turn 2 answer: {output_text.strip()}")
break
else:
print("Turn 2 did not complete within the timeout.")


if __name__ == "__main__":
base_url = sys.argv[1] if len(sys.argv) > 1 else _DEFAULT_BASE
asyncio.run(demo(base_url))
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Copyright (c) Microsoft. All rights reserved.

import os

from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.ai.agentserver.responses import ResponsesServerOptions
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()


def main():
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=DefaultAzureCredential(),
)

agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
default_options={"store": False},
)

# steerable_conversations=True allows a client to send a new turn while
# the current one is still in progress. The new turn is queued and the
# running handler is cooperatively cancelled (via cancellation_signal)
# rather than the client receiving HTTP 409 conversation_locked.
# Once the current turn reaches a terminal event the queued turn runs.
server = ResponsesHostServer(
agent,
options=ResponsesServerOptions(steerable_conversations=True),
)
server.run()


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
agent-framework-foundry
agent-framework-foundry-hosting
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# This sample makes no external model calls and requires no credentials.
# The variables below are optional tuning knobs for main.py (both have
# sensible defaults); demo.py sets WORKFLOW_STATE_DIR and
# WORKFLOW_STAGE_DELAY_SECONDS itself for its isolated crash-recovery run,
# overriding whatever is set here.

# Directory for audit.jsonl and stage marker files.
# Default: a .workflow_state folder next to main.py.
# WORKFLOW_STATE_DIR="./.workflow_state"

# Seconds each pipeline stage sleeps to simulate work.
# Default: 4
# WORKFLOW_STAGE_DELAY_SECONDS="4"

# The sample disables Azure Monitor's internal Statsbeat metrics when no
# Application Insights connection is configured. This avoids a local probe of
# the Azure instance metadata endpoint without disabling normal telemetry.
# APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL="true"
Loading
Loading