-
Notifications
You must be signed in to change notification settings - Fork 2k
Python: Hosted LRA draft updates #7122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
alliscode
wants to merge
25
commits into
microsoft:main
Choose a base branch
from
alliscode:durable-ha
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
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 b2ee149
Python: Add steerable_conversations support to ResponsesHostServer
alliscode bb37af0
Python: Fix _handle_response async generator + steering test
alliscode bc48896
Python: Update steering test to use real FoundryChatClient agent
alliscode d101df2
Python: Make resilient_background opt-in, remove auto-enable
alliscode e7e2692
Python: Add client.py steering demo to 01_basic sample
alliscode d2053a4
Python: Move steering and resilience to dedicated samples (13, 14)
alliscode d7b5f5c
Python: Replace manual kill steps with automated demo.py in 14_resili…
alliscode ec0ba56
Fix samples
TaoChenOSU d9e718e
Add workflow sample
TaoChenOSU d28d9c7
Add workflow context aware checkpoint storage and recovery path
TaoChenOSU 072b8dd
Detect if checkpoint is supported via option and flag
TaoChenOSU ce8aa4b
Remove non-streaming in invoking the inner agent
TaoChenOSU de33b75
Code cleanup
TaoChenOSU fca1bce
Commit response state at checkpoint creation
TaoChenOSU fa10c3f
fix: update agentserver wheel sources and fix 2 failing tests
alliscode 8936d71
Add durable workflow response recovery
alliscode 9208c65
Address durable workflow review feedback
alliscode 094f365
Fix durable workflow recovery boundaries
alliscode 1bfe269
Document private preview wheel setup
alliscode e139ad2
Fail fast without durability preview
alliscode 00a08c5
Use released core with preview hosting wheel
alliscode 596538f
Fix hosted workflow checkpoint persistence
alliscode 216ae1d
Removing intentional sleep from samples as intentional crashes now ma…
alliscode b9f23c7
Address durable workflow review feedback
alliscode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
1,220 changes: 876 additions & 344 deletions
1,220
python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py
Large diffs are not rendered by default.
Oops, something went wrong.
1,120 changes: 926 additions & 194 deletions
1,120
python/packages/foundry_hosting/tests/test_responses.py
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
2 changes: 2 additions & 0 deletions
2
python/samples/04-hosting/foundry-hosted-agents/responses/13_steering/.env.example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| FOUNDRY_PROJECT_ENDPOINT="..." | ||
| AZURE_AI_MODEL_DEPLOYMENT_NAME="..." |
16 changes: 16 additions & 0 deletions
16
python/samples/04-hosting/foundry-hosted-agents/responses/13_steering/Dockerfile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
65 changes: 65 additions & 0 deletions
65
python/samples/04-hosting/foundry-hosted-agents/responses/13_steering/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
175 changes: 175 additions & 0 deletions
175
python/samples/04-hosting/foundry-hosted-agents/responses/13_steering/client.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)) |
42 changes: 42 additions & 0 deletions
42
python/samples/04-hosting/foundry-hosted-agents/responses/13_steering/main.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
2 changes: 2 additions & 0 deletions
2
python/samples/04-hosting/foundry-hosted-agents/responses/13_steering/requirements.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| agent-framework-foundry | ||
| agent-framework-foundry-hosting |
18 changes: 18 additions & 0 deletions
18
...on/samples/04-hosting/foundry-hosted-agents/responses/15_workflow_resilience/.env.example
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.