Python: Foundry Hosted Agent Resiliency Support (#7670)
* Migrate FHA to responses==2.0.0b1 and add Foundry state store * Fix session id error * Fix tests * Improve tests * Fix copilot comments * Address comments * Revert sample changes * Address comments * Add ContextScopedStoreProvider * Fix type check * Fix type check * LRA on top of state store * Temp disable state store user isolation * Simulate shutdown * Remove sim shutdown * Add sample * refine resiliency sample * Add steerable conversation support * Revert uv.lock * Add last_checkpoint_id and checkpoint existence check * Tighted resilient-recovery states * Tests for tightened resilient-recovery states * Make cancellation effective even when the iterator is stuck * Add more tests and fix sample * Small adjustment after review * Fix typing * Fix typing * Close driver background task in case of exceptions raised in the consumer * Handle usage content * xfail an integration test due to a known gap * Fix formatting --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
This commit is contained in:
@@ -22,7 +22,10 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew
|
||||
| 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. |
|
||||
| 12 | [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. |
|
||||
| 13 | [Custom Storage](responses/custom_storage/) | An agent demonstrating how to implement a custom storage provider for agent sessions (in-memory and Cosmos DB). |
|
||||
| 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 | [Steerable Long-Running Agent](responses/steerable_long_running_agent/) | A long-running, non-workflow agent demonstrating how `steerable_conversations=True` lets a new turn on the same conversation cancel and replace a still-running turn instead of waiting for it to finish. Steering is only supported for non-workflow agents. |
|
||||
| 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. |
|
||||
|
||||
## Session Identifiers
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.env
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
+16
@@ -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"]
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
A long-running, crash-resilient [Agent Framework](https://github.com/microsoft/agent-framework) workflow
|
||||
hosted using the **Responses protocol**. The workflow extracts a target number from the user's message and
|
||||
then counts down from it one step per second, 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.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Workflow
|
||||
|
||||
The workflow has three executors (see [main.py](main.py)):
|
||||
|
||||
- **`StartExecutor`** uses a `FoundryChatClient`-backed agent to extract a positive integer target from the
|
||||
user's message. If no valid target is found, the workflow yields an error message instead of counting down.
|
||||
- **`CountdownExecutor`** decrements the target through a self-loop, sleeping for a second and yielding an
|
||||
output on each tick, to simulate a long-running operation.
|
||||
- **`complete`** yields the workflow's final `"Countdown complete."` output once the countdown reaches zero.
|
||||
|
||||
### Agent Hosting
|
||||
|
||||
The workflow is hosted as an agent using the [Agent Framework](https://github.com/microsoft/agent-framework)
|
||||
`ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
|
||||
Setting `resilient_background=True` in `ResponsesServerOptions` enables the framework to checkpoint the
|
||||
workflow's progress and durably persist streamed output, so a background response can be recovered and
|
||||
resumed after a crash (see "Testing resiliency" below).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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. 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 with a positive integer target to count down from. For example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Count down from 5"}'
|
||||
```
|
||||
|
||||
The server will respond with a JSON object containing the response output (one item per countdown step) and a response ID. You can use this response ID to continue the conversation in subsequent requests.
|
||||
|
||||
|
||||
## Testing resiliency (crash recovery)
|
||||
|
||||
This sample enables `resilient_background=True`, so a long-running countdown survives a hard crash of the
|
||||
server process and resumes from its last checkpoint instead of restarting from scratch. Locally, the
|
||||
server persists responses, streams, and checkpoints under `${AGENTSERVER_STATE_ROOT:-~/.agentserver}/`, so
|
||||
this state survives a process restart as long as you run from the same working directory.
|
||||
|
||||
On startup, the server's task manager scans that persisted state for any tasks that were still in flight
|
||||
when the process died and automatically reclaims and resumes each one from its last checkpoint -- this
|
||||
happens for every incomplete resilient background response, not just the one a client happens to reconnect
|
||||
to. This is why a stale response from an earlier run can still be found "recovering" in the server logs
|
||||
long after you've moved on to a new test: every restart re-triggers the same scan, so the stale task keeps
|
||||
getting resumed until it either reaches a terminal state or the persisted state directory is cleared (as
|
||||
`verify_resiliency.py` does).
|
||||
|
||||
### Automated
|
||||
|
||||
[verify_resiliency.py](verify_resiliency.py) runs the whole scenario end to end: it clears any leftover
|
||||
`${AGENTSERVER_STATE_ROOT:-~/.agentserver}` state from a previous run, starts the server, kicks off a
|
||||
background+streaming countdown, force-kills the server once half the countdown has completed, restarts the
|
||||
server, and asserts the recovered response completes with the exact expected output (no lost or duplicated
|
||||
steps). Progress is printed as each countdown item completes, both before and after the crash, by reading
|
||||
the response's own `stream=true` SSE feed -- a plain (non-streaming) `GET` only ever reflects the response's
|
||||
initial or terminal snapshot, never anything in between.
|
||||
|
||||
```bash
|
||||
python verify_resiliency.py --target 20
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
To exercise crash recovery by hand:
|
||||
|
||||
1. Start the server, then kick off a long background+streaming countdown and note the response `id` from the
|
||||
first `response.created` event:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \
|
||||
-d '{"input": "Count down from 200", "stream": true, "store": true, "background": true}'
|
||||
```
|
||||
|
||||
2. While the countdown is still running, kill the server process abruptly — use `kill -9 <pid>`
|
||||
(`Stop-Process -Id <pid> -Force` on Windows), not `Ctrl+C`. A `Ctrl+C` triggers a graceful shutdown, which
|
||||
is handled differently than a crash; a hard kill is required to exercise crash recovery. The sample server
|
||||
prints its PID (`PID: <pid>`) on startup so you don't need to look it up separately.
|
||||
|
||||
3. Restart the server (`python main.py`) from the same working directory.
|
||||
|
||||
4. Reconnect to the response to observe recovery:
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8088/responses/REPLACE_WITH_RESPONSE_ID?stream=true"
|
||||
```
|
||||
|
||||
The recovered stream emits a fresh `response.in_progress` event first, then resumes the countdown from
|
||||
where it left off — the output already produced before the crash is neither lost nor duplicated.
|
||||
Alternatively, poll `GET /responses/REPLACE_WITH_RESPONSE_ID` (without `stream`) until `status` is
|
||||
`completed` and inspect the `output` array for a contiguous, non-duplicated sequence.
|
||||
|
||||
> **Windows note:** the local stream store falls back to a plain lock *file* (no `fcntl`), which isn't
|
||||
> cleaned up when the process is force-killed. If restart fails with `another process holds the lock-file
|
||||
> on ...jsonl`, delete the stale `<response-id>.jsonl.lock` file under
|
||||
> `%USERPROFILE%\.agentserver\streams\` before restarting 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.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
name: agent-framework-resilient-long-running-workflow
|
||||
description: >
|
||||
A hosted agent that demonstrates a resilient long-running workflow using the Responses protocol.
|
||||
metadata:
|
||||
tags:
|
||||
- Agent Framework
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Workflows
|
||||
- Resilience
|
||||
template:
|
||||
name: agent-framework-resilient-long-running-workflow
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: agent-framework-resilient-long-running-workflow
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: "0.5Gi"
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Host a three-executor workflow that extracts and counts down a target.
|
||||
|
||||
The start executor asks a Foundry-backed agent to extract a positive integer
|
||||
from the incoming message. The countdown executor repeatedly decrements that
|
||||
integer and sends it back to itself. At zero, it sends a completion message to
|
||||
the terminal executor, which yields the workflow output.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT: Microsoft Foundry project endpoint.
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: Model deployment name.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent, Executor, Message, WorkflowBuilder, WorkflowContext, executor, handler
|
||||
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
|
||||
from pydantic import BaseModel, Field
|
||||
from typing_extensions import Never
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class CounterTarget(BaseModel):
|
||||
"""The counter target extracted from the user's message."""
|
||||
|
||||
target: int | None = Field(
|
||||
description="The positive integer to count down from, or null when no valid target was provided."
|
||||
)
|
||||
|
||||
|
||||
class StartExecutor(Executor):
|
||||
"""Extract a valid counter target and start the countdown."""
|
||||
|
||||
def __init__(self, agent: Agent, id: str = "start") -> None:
|
||||
super().__init__(id=id)
|
||||
self._agent = agent
|
||||
|
||||
@handler
|
||||
async def extract_target(self, messages: list[Message], ctx: WorkflowContext[int, str]) -> None:
|
||||
"""Ask the model for a target and forward valid positive integers."""
|
||||
response = await self._agent.run(messages, options={"response_format": CounterTarget})
|
||||
extraction = response.value
|
||||
if not isinstance(extraction, CounterTarget) or extraction.target is None or extraction.target <= 0:
|
||||
await ctx.yield_output("The message must contain a positive integer counter target.")
|
||||
return
|
||||
|
||||
await ctx.send_message(extraction.target)
|
||||
|
||||
|
||||
class CountdownExecutor(Executor):
|
||||
def __init__(self, id: str = "countdown") -> None:
|
||||
super().__init__(id=id)
|
||||
|
||||
@handler
|
||||
async def countdown(self, target: int, ctx: WorkflowContext[int | str, str]) -> None:
|
||||
"""Decrement the target through a self-loop, then signal completion."""
|
||||
if target <= 0:
|
||||
await ctx.send_message("Countdown complete.", target_id="complete")
|
||||
return
|
||||
|
||||
await asyncio.sleep(1) # Simulate a long-running operation
|
||||
await ctx.yield_output(str(target))
|
||||
await ctx.send_message(target - 1, target_id=self.id)
|
||||
|
||||
|
||||
@executor(id="complete")
|
||||
async def complete(message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
"""Yield the workflow's completion output."""
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
def build_workflow():
|
||||
"""Build the target extraction, countdown, and completion workflow."""
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
target_agent = Agent(
|
||||
client=client,
|
||||
name="counter_target_extractor",
|
||||
instructions=(
|
||||
"Extract the counter target requested by the user. Return the target only when it is a positive integer. "
|
||||
"Return null for zero, negative numbers, fractions, or messages without a clear counter target."
|
||||
),
|
||||
)
|
||||
start = StartExecutor(target_agent)
|
||||
countdown = CountdownExecutor()
|
||||
|
||||
return (
|
||||
WorkflowBuilder(start_executor=start, output_from="all")
|
||||
.add_edge(start, countdown)
|
||||
.add_edge(countdown, countdown)
|
||||
.add_edge(countdown, complete)
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the workflow as a durable Responses API host."""
|
||||
print(f"PID: {os.getpid()}") # lets crash-recovery testing find and kill this process
|
||||
workflow_agent = build_workflow().as_agent(name="countdown-workflow")
|
||||
server = ResponsesHostServer(
|
||||
workflow_agent,
|
||||
options=ResponsesServerOptions(resilient_background=True),
|
||||
log_level="DEBUG",
|
||||
)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
agent-framework-foundry
|
||||
agent-framework-foundry-hosting
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""End-to-end crash-recovery test for the resilient countdown workflow sample.
|
||||
|
||||
Starts the server, kicks off a background countdown, force-kills the server mid-countdown to
|
||||
simulate a real crash, clears the stale Windows stream lock file, restarts the server, and
|
||||
verifies the countdown resumes and completes with the exact expected output (no loss, no
|
||||
duplication). Requires the same environment (.env) as running main.py directly.
|
||||
|
||||
Usage:
|
||||
python verify_resiliency.py [--target N] [--crash-after-count N]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import IO, Any
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8088
|
||||
BASE_URL = f"http://{HOST}:{PORT}"
|
||||
SAMPLE_DIR = Path(__file__).parent
|
||||
LOG_PATH = SAMPLE_DIR / "verify_resiliency.log"
|
||||
|
||||
|
||||
def _http_get(path: str, timeout: float = 5.0) -> tuple[int, dict[str, Any]]:
|
||||
with urllib.request.urlopen(urllib.request.Request(f"{BASE_URL}{path}"), timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
|
||||
|
||||
def _start_server(log_file: IO[str]) -> subprocess.Popen: # type: ignore
|
||||
return subprocess.Popen([sys.executable, "main.py"], cwd=SAMPLE_DIR, stdout=log_file, stderr=subprocess.STDOUT)
|
||||
|
||||
|
||||
def _wait_for_ready(timeout: float = 30.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status, _ = _http_get("/readiness", timeout=2.0)
|
||||
if status == 200:
|
||||
return
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("Server did not become ready in time.")
|
||||
|
||||
|
||||
def _kill(server: subprocess.Popen) -> None: # type: ignore
|
||||
# Popen.kill() maps to TerminateProcess on Windows -- an ungraceful hard kill, just like a real crash.
|
||||
if server.poll() is None:
|
||||
server.kill()
|
||||
server.wait(timeout=10)
|
||||
|
||||
|
||||
def _clear_stale_stream_lock(response_id: str) -> None:
|
||||
# On Windows, the local stream store falls back to a plain lock *file* (no `fcntl`), which isn't
|
||||
# cleaned up when the process is force-killed. If restart fails with `another process holds the
|
||||
# lock-file on ...jsonl`, delete the stale `<response-id>.jsonl.lock` file under
|
||||
lock_path = Path.home() / ".agentserver" / "streams" / f"{response_id}.jsonl.lock"
|
||||
if not lock_path.exists():
|
||||
return
|
||||
# Windows may not release the killed process's file handle immediately; retry briefly.
|
||||
for attempt in range(10):
|
||||
try:
|
||||
lock_path.unlink()
|
||||
print(f" removed stale lock file: {lock_path}")
|
||||
return
|
||||
except PermissionError:
|
||||
if attempt == 9:
|
||||
raise
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def _create_streaming_background_response(payload: dict[str, Any], progress: dict[str, Any]) -> None:
|
||||
"""POST a streaming background create-response request and track its progress.
|
||||
|
||||
Background responses only expose incremental output over SSE when the *creation*
|
||||
request itself sets ``stream=true`` (``ResponseExecution.replay_enabled`` requires it);
|
||||
a plain (non-streaming) GET only ever reflects the initial (empty output) or terminal
|
||||
(full output) snapshot, never anything in between. So the create call itself must be
|
||||
the streaming one, and its own response body is read here as the progress feed.
|
||||
"""
|
||||
data = json.dumps({**payload, "stream": True}).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"{BASE_URL}/responses", data=data, headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request) as resp:
|
||||
current_event: str | None = None
|
||||
for raw_line in resp:
|
||||
line = raw_line.decode("utf-8").rstrip("\n")
|
||||
if line.startswith("event:"):
|
||||
current_event = line[len("event:") :].strip()
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_obj = json.loads(line[len("data:") :].strip())
|
||||
if current_event == "response.created" and "id" not in progress:
|
||||
progress["id"] = data_obj["response"]["id"]
|
||||
progress["status"] = data_obj["response"]["status"]
|
||||
progress["ready"].set()
|
||||
elif current_event == "response.output_item.done":
|
||||
progress["count"] += 1
|
||||
for part in data_obj.get("item", {}).get("content", []):
|
||||
if part.get("type") == "output_text":
|
||||
print(f" output item {progress['count']}: {part['text']!r}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
progress["error"] = f"HTTP {exc.code}: {exc.read().decode('utf-8', errors='replace')}"
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as exc:
|
||||
progress["error"] = f"{type(exc).__name__}: {exc}"
|
||||
finally:
|
||||
progress["ready"].set() # Unblock a waiter even if response.created never arrived.
|
||||
|
||||
|
||||
def _watch_recovery_progress(response_id: str, progress: dict[str, Any]) -> None:
|
||||
"""Replay the response's SSE stream after recovery and print each item as it arrives.
|
||||
|
||||
``starting_after`` is omitted, so the replay starts from the beginning of the retained
|
||||
history -- pre-crash items are reprinted, then live items follow as the recovered
|
||||
workflow produces them.
|
||||
"""
|
||||
url = f"{BASE_URL}/responses/{response_id}?stream=true"
|
||||
try:
|
||||
with urllib.request.urlopen(url) as resp:
|
||||
current_event: str | None = None
|
||||
for raw_line in resp:
|
||||
line = raw_line.decode("utf-8").rstrip("\n")
|
||||
if line.startswith("event:"):
|
||||
current_event = line[len("event:") :].strip()
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_obj = json.loads(line[len("data:") :].strip())
|
||||
if current_event == "response.output_item.done":
|
||||
progress["count"] += 1
|
||||
for part in data_obj.get("item", {}).get("content", []):
|
||||
if part.get("type") == "output_text":
|
||||
print(f" output item {progress['count']}: {part['text']!r}")
|
||||
elif current_event in ("response.completed", "response.failed", "response.incomplete"):
|
||||
progress["done"].set()
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError):
|
||||
pass # Connection drops when the server crashes or exits; the caller already knows.
|
||||
finally:
|
||||
progress["done"].set()
|
||||
|
||||
|
||||
def _extract_message_texts(output_items: list[dict[str, Any]]) -> list[str]:
|
||||
texts: list[str] = []
|
||||
for item in output_items:
|
||||
if item.get("type") != "message":
|
||||
continue
|
||||
for part in item.get("content", []):
|
||||
if part.get("type") == "output_text":
|
||||
texts.append(part["text"])
|
||||
return texts
|
||||
|
||||
|
||||
def _clear_stale_state() -> None:
|
||||
"""Wipe ~/.agentserver so a prior run's incomplete response is never auto-recovered
|
||||
on startup and left competing with this run's request for the event loop.
|
||||
"""
|
||||
state_root = Path.home() / ".agentserver"
|
||||
if state_root.exists():
|
||||
shutil.rmtree(state_root, ignore_errors=True)
|
||||
print(f" cleared stale state: {state_root}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--target", type=int, default=20, help="Countdown starting value.")
|
||||
args = parser.parse_args()
|
||||
|
||||
crash_after_count = args.target // 2
|
||||
expected_texts = [str(n) for n in range(args.target, 0, -1)] + ["Countdown complete."]
|
||||
|
||||
_clear_stale_state()
|
||||
|
||||
log_file = LOG_PATH.open("w", encoding="utf-8")
|
||||
print(f"Server logs (DEBUG level) are redirected to {LOG_PATH}.")
|
||||
|
||||
print(f"[1/6] Starting server (target={args.target})...")
|
||||
server = _start_server(log_file) # type: ignore
|
||||
print(f" PID: {server.pid}")
|
||||
try:
|
||||
_wait_for_ready()
|
||||
|
||||
print("[2/6] Starting background countdown...")
|
||||
progress: dict[str, Any] = {"count": 0, "ready": threading.Event()}
|
||||
watcher = threading.Thread(
|
||||
target=_create_streaming_background_response,
|
||||
args=({"input": f"Count down from {args.target}", "store": True, "background": True}, progress),
|
||||
daemon=True,
|
||||
)
|
||||
watcher.start()
|
||||
if not progress["ready"].wait(timeout=60):
|
||||
raise SystemExit("FAIL: did not receive response.created in time.")
|
||||
if "id" not in progress:
|
||||
raise SystemExit(f"FAIL: streaming create request failed: {progress.get('error', 'unknown error')}")
|
||||
response_id = progress["id"]
|
||||
print(f" response id: {response_id}, status: {progress['status']}")
|
||||
|
||||
print(f"[3/6] Waiting for the countdown to reach {crash_after_count} completed item(s)...")
|
||||
while progress["count"] < crash_after_count:
|
||||
time.sleep(0.5)
|
||||
print(f" output items observed via SSE before crash: {progress['count']}")
|
||||
|
||||
print("[4/6] Force-killing the server (simulated crash)...")
|
||||
finally:
|
||||
_kill(server)
|
||||
|
||||
_clear_stale_stream_lock(response_id)
|
||||
|
||||
print("[5/6] Restarting the server...")
|
||||
server = _start_server(log_file) # type: ignore
|
||||
print(f" PID: {server.pid}")
|
||||
try:
|
||||
_wait_for_ready()
|
||||
|
||||
print("[6/6] Waiting for the recovered countdown to complete...")
|
||||
recovery: dict[str, Any] = {"count": 0, "done": threading.Event()}
|
||||
recovery_watcher = threading.Thread(target=_watch_recovery_progress, args=(response_id, recovery), daemon=True)
|
||||
recovery_watcher.start()
|
||||
recovery["done"].wait(timeout=args.target * 2 + 30)
|
||||
final = _http_get(f"/responses/{response_id}")[1]
|
||||
finally:
|
||||
_kill(server)
|
||||
log_file.close()
|
||||
|
||||
if final["status"] != "completed":
|
||||
raise SystemExit(
|
||||
f"FAIL: response did not complete in time; last status: {final['status']}. See {LOG_PATH} for server logs."
|
||||
)
|
||||
|
||||
texts = _extract_message_texts(final.get("output", []))
|
||||
print(f" final status: {final['status']}, output items: {len(final['output'])}")
|
||||
if texts != expected_texts:
|
||||
raise SystemExit(
|
||||
f"FAIL: recovered output mismatch.\n expected: {expected_texts}\n got: {texts}\n"
|
||||
f"See {LOG_PATH} for server logs."
|
||||
)
|
||||
|
||||
print("PASS: countdown crashed mid-flight and recovered with no lost or duplicated output.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
.env
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
FOUNDRY_PROJECT_ENDPOINT="..."
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
|
||||
+16
@@ -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"]
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# What this sample demonstrates
|
||||
|
||||
A steerable multi-turn [Agent Framework](https://github.com/microsoft/agent-framework) agent hosted using the
|
||||
**Responses protocol**. The agent is asked to count down from a target number, pacing its own output with a short
|
||||
remark before each number so a real response takes a while to fully generate. With `steerable_conversations=True`,
|
||||
sending a new turn on the same conversation while the countdown is still streaming **cancels the in-progress turn**
|
||||
and drains the new turn next.
|
||||
|
||||
Steering is only supported for non-workflow agents. Steering a workflow is conceptually undefined: a workflow's
|
||||
graph may have loops or parallel branches with no single well-defined "current point" to cancel and resume from,
|
||||
unlike an agent's strictly linear execution. `ResponsesHostServer` rejects `steerable_conversations=True` for a
|
||||
workflow agent with `RuntimeError`.
|
||||
|
||||
## How It Works
|
||||
|
||||
### Agent
|
||||
|
||||
The agent (see [main.py](main.py)) is a single `Agent` backed by `FoundryChatClient`, with no workflow and no custom
|
||||
agent class. Its instructions ask it to count down one integer per line, prefacing each with a brief remark, so a
|
||||
real streamed generation takes long enough for a second turn to arrive mid-stream. Compared to the
|
||||
[Basic](../basic/) sample, the only differences are the instructions and passing
|
||||
`ResponsesServerOptions(steerable_conversations=True)` -- steering needs no special agent-side code.
|
||||
|
||||
### Agent Hosting
|
||||
|
||||
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) `ResponsesHostServer`.
|
||||
Setting `steerable_conversations=True` in `ResponsesServerOptions` lets a new turn on the same conversation chain
|
||||
preempt a still-running one:
|
||||
|
||||
- The framework signals the in-progress turn's handler via the 3rd positional `cancellation_signal` argument.
|
||||
- The handler (here, the Agent Framework agent-hosting layer) checks that signal between streamed model updates and
|
||||
winds the turn down promptly, letting it complete with whatever partial output it had already produced.
|
||||
- The new turn is then drained with `context.is_steered_turn == True` and `context.pending_input_count` reflecting
|
||||
how many further turns are still queued behind it.
|
||||
- **A single, linear chain**: every turn after the first must reference the immediately preceding turn's `id` via
|
||||
`previous_response_id`, and a `previous_response_id` that doesn't point at the latest turn is rejected with HTTP
|
||||
409 (`conversation_fork_not_supported`). Chain identity in turn also depends on session continuity: without an
|
||||
explicit `conversation`, the server derives a session id per request, and it's only deterministic across turns
|
||||
when a client forwards the `x-agent-session-id` header from a prior response back as `agent_session_id` on the
|
||||
next one (see the `curl` walkthrough below) -- otherwise a later turn resolves to a different session and starts
|
||||
a brand new response instead of steering the earlier one. Sending the same explicit `conversation` value on every
|
||||
turn sidesteps this entirely: it makes the derived session id (and the chain itself) a deterministic function of
|
||||
that id, so no header needs to be echoed back, and `previous_response_id` becomes unnecessary for continuity.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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. Use this README for sample queries you
|
||||
> can send to the agent.
|
||||
|
||||
Start a long background countdown and note the response `id` from the JSON body and the `x-agent-session-id`
|
||||
response header:
|
||||
|
||||
```bash
|
||||
curl -i -X POST http://localhost:8088/responses -H "Content-Type: application/json" \
|
||||
-d '{"input": "Count down from 30, slowly and with commentary.", "store": true, "background": true}'
|
||||
```
|
||||
|
||||
While it is still generating, send a second turn on the same conversation with `previous_response_id` set to steer
|
||||
it to a new target. Without an explicit `conversation_id`, also forward the `x-agent-session-id` value from the
|
||||
first response as `agent_session_id` -- otherwise this turn resolves to a different session and starts a brand new
|
||||
response instead of steering the first one:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \
|
||||
-d '{"input": "Actually, count down from 3 instead.", "store": true, "background": true, "previous_response_id": "REPLACE_WITH_FIRST_RESPONSE_ID", "agent_session_id": "REPLACE_WITH_X-AGENT-SESSION-ID_HEADER"}'
|
||||
```
|
||||
|
||||
This second request returns immediately with `"status": "queued"`. Polling the *first* response's id will show it
|
||||
completed early, with fewer tokens than a full 30-count run. Polling the *second* response's id will show a fresh
|
||||
countdown from 3.
|
||||
|
||||
Alternatively, send an explicit `conversation` id on every turn instead of forwarding `x-agent-session-id`. This is
|
||||
simpler and also works without `previous_response_id` at all, since the `conversation` id alone identifies the chain:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \
|
||||
-d '{"input": "Count down from 30, slowly and with commentary.", "store": true, "background": true, "conversation": "my-conversation-id"}'
|
||||
|
||||
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" \
|
||||
-d '{"input": "Actually, count down from 3 instead.", "store": true, "background": true, "conversation": "my-conversation-id"}'
|
||||
```
|
||||
|
||||
## Testing steering
|
||||
|
||||
[verify_steering.py](verify_steering.py) runs the whole scenario end to end: it starts the server, kicks off a
|
||||
background streaming countdown, waits for it to stream a minimum number of tokens, sends a second turn with a new
|
||||
target via `previous_response_id`, and asserts that the second turn is accepted immediately as `"queued"`, that the
|
||||
first turn completes early, and that the second (steered) turn's output contains the new target's countdown in
|
||||
order. Because this sample calls a real model, the assertions here are intentionally loose rather than an exact
|
||||
output match.
|
||||
|
||||
```bash
|
||||
python verify_steering.py --first-target 30 --second-target 3
|
||||
```
|
||||
|
||||
## 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.
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
name: agent-framework-steerable-long-running-agent
|
||||
description: >
|
||||
A hosted agent that demonstrates steerable multi-turn conversations for a plain (non-workflow)
|
||||
agent using the Responses protocol.
|
||||
metadata:
|
||||
tags:
|
||||
- Agent Framework
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Steering
|
||||
- Multi-turn
|
||||
template:
|
||||
name: agent-framework-steerable-long-running-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4.1-mini
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: agent-framework-steerable-long-running-agent
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: "0.5Gi"
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Host a single (non-workflow) agent that counts down slowly, steerably.
|
||||
|
||||
The agent is asked to count down from a target number, pacing its own output with a short remark
|
||||
before each number so a real response takes a while to fully generate. With
|
||||
`steerable_conversations=True`, sending a new turn on the same conversation while the countdown is
|
||||
still streaming cancels the in-progress turn and drains the new turn next. Steering is only
|
||||
supported for non-workflow agents like this one.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT: Microsoft Foundry project endpoint.
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: Model deployment name.
|
||||
"""
|
||||
|
||||
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_dotenv()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
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 counting assistant. When asked to count down from a positive integer, count down one "
|
||||
"integer per line, and before each number add a brief, unique one-sentence remark, so your full "
|
||||
"response takes some time to generate. If no valid positive integer target is given, reply with "
|
||||
"'Please provide a positive integer to count down from.' and nothing else."
|
||||
),
|
||||
)
|
||||
|
||||
server = ResponsesHostServer(
|
||||
agent,
|
||||
options=ResponsesServerOptions(steerable_conversations=True),
|
||||
log_level="DEBUG",
|
||||
)
|
||||
server.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
agent-framework-foundry
|
||||
agent-framework-foundry-hosting
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""End-to-end steering test for the steerable single-agent countdown sample.
|
||||
|
||||
Starts the server, kicks off a background streaming countdown, then -- while the model is still
|
||||
generating -- sends a second turn on the same conversation with a new target. Verifies the second
|
||||
turn is accepted immediately as "queued", that the first turn is cancelled and completes early
|
||||
(fewer tokens than a full run), and that the second (steered) turn completes with a countdown for
|
||||
its own target. Because this sample uses a real model (no deterministic per-tick pacing),
|
||||
assertions here are necessarily looser than an exact output match. Requires the
|
||||
same environment (.env) as running main.py directly.
|
||||
|
||||
Usage:
|
||||
python verify_steering.py [--first-target N] [--second-target N] [--min-deltas-before-steering N]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import IO, Any
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8088
|
||||
BASE_URL = f"http://{HOST}:{PORT}"
|
||||
SAMPLE_DIR = Path(__file__).parent
|
||||
LOG_PATH = SAMPLE_DIR / "verify_steering.log"
|
||||
|
||||
|
||||
def _http_get(path: str, timeout: float = 5.0) -> tuple[int, dict[str, Any]]:
|
||||
with urllib.request.urlopen(urllib.request.Request(f"{BASE_URL}{path}"), timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
|
||||
|
||||
def _http_post(path: str, payload: dict[str, Any], timeout: float = 30.0) -> tuple[int, dict[str, Any]]:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"{BASE_URL}{path}", data=data, headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, json.loads(exc.read())
|
||||
|
||||
|
||||
def _poll_until_terminal(response_id: str, timeout: float) -> dict[str, Any]:
|
||||
"""Poll ``GET /responses/{id}`` until the response reaches a terminal status.
|
||||
|
||||
A ``stream=false`` create only guarantees a fast initial ack (e.g. ``"queued"``); this polls
|
||||
for the actual outcome instead of trying to replay it live (a ``?stream=true`` GET replay is
|
||||
only valid for a response that was itself created with ``stream=true``).
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
snapshot: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
snapshot = _http_get(f"/responses/{response_id}")[1]
|
||||
if snapshot.get("status") in ("completed", "failed", "incomplete", "cancelled"):
|
||||
return snapshot
|
||||
time.sleep(0.5)
|
||||
return snapshot
|
||||
|
||||
|
||||
def _start_server(log_file: IO[str]) -> subprocess.Popen: # type: ignore
|
||||
return subprocess.Popen(
|
||||
[sys.executable, "main.py"],
|
||||
cwd=SAMPLE_DIR,
|
||||
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_ready(timeout: float = 30.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
status, _ = _http_get("/readiness", timeout=2.0)
|
||||
if status == 200:
|
||||
return
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
raise RuntimeError("Server did not become ready in time.")
|
||||
|
||||
|
||||
def _kill(server: subprocess.Popen) -> None: # type: ignore
|
||||
if server.poll() is None:
|
||||
server.kill()
|
||||
server.wait(timeout=10)
|
||||
|
||||
|
||||
def _watch_sse(request: "urllib.request.Request | str", progress: dict[str, Any]) -> None:
|
||||
"""Read an SSE stream from a streaming create POST and track its progress.
|
||||
|
||||
Tracks the response id (on ``response.created``), a running count of text delta events (a
|
||||
single-agent response streams as one message, not discrete output items), and signals
|
||||
``progress["done"]`` on any terminal event.
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(request) as resp:
|
||||
# Without an explicit conversation_id, the session id (which scopes the conversation
|
||||
# chain id used to attach a steered turn to the same task) must be forwarded by the
|
||||
# caller on later turns -- otherwise each turn derives a different session id locally.
|
||||
session_id = resp.headers.get("x-agent-session-id")
|
||||
if session_id:
|
||||
progress["session_id"] = session_id
|
||||
current_event: str | None = None
|
||||
for raw_line in resp:
|
||||
line = raw_line.decode("utf-8").rstrip("\n")
|
||||
if line.startswith("event:"):
|
||||
current_event = line[len("event:") :].strip()
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data_obj = json.loads(line[len("data:") :].strip())
|
||||
if current_event == "response.created" and "id" not in progress:
|
||||
progress["id"] = data_obj["response"]["id"]
|
||||
progress["status"] = data_obj["response"]["status"]
|
||||
progress["ready"].set()
|
||||
elif current_event == "response.output_text.delta":
|
||||
progress["delta_count"] += 1
|
||||
elif current_event in ("response.completed", "response.failed", "response.incomplete"):
|
||||
progress["done"].set()
|
||||
except urllib.error.HTTPError as exc:
|
||||
progress["error"] = f"HTTP {exc.code}: {exc.read().decode('utf-8', errors='replace')}"
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError) as exc:
|
||||
progress["error"] = f"{type(exc).__name__}: {exc}"
|
||||
finally:
|
||||
progress["ready"].set()
|
||||
progress["done"].set()
|
||||
|
||||
|
||||
def _extract_output_text(output_items: list[dict[str, Any]]) -> str:
|
||||
parts: list[str] = []
|
||||
for item in output_items:
|
||||
if item.get("type") != "message":
|
||||
continue
|
||||
for part in item.get("content", []):
|
||||
if part.get("type") == "output_text":
|
||||
parts.append(part["text"])
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _clear_stale_state() -> None:
|
||||
"""Wipe ~/.agentserver so a prior run's task/queue state never leaks into this run."""
|
||||
state_root = Path.home() / ".agentserver"
|
||||
if state_root.exists():
|
||||
shutil.rmtree(state_root, ignore_errors=True)
|
||||
print(f" cleared stale state: {state_root}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--first-target", type=int, default=30, help="First turn's countdown starting value.")
|
||||
parser.add_argument("--second-target", type=int, default=3, help="Steered turn's countdown starting value.")
|
||||
parser.add_argument(
|
||||
"--min-deltas-before-steering",
|
||||
type=int,
|
||||
default=15,
|
||||
help="Minimum text delta events to observe on turn 1 before sending the steering turn.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_clear_stale_state()
|
||||
|
||||
log_file = LOG_PATH.open("w", encoding="utf-8")
|
||||
print(f"Server logs (DEBUG level) are redirected to {LOG_PATH}.")
|
||||
|
||||
print(f"[1/5] Starting server (first target={args.first_target}, second target={args.second_target})...")
|
||||
server = _start_server(log_file) # type: ignore
|
||||
print(f" PID: {server.pid}")
|
||||
try:
|
||||
_wait_for_ready()
|
||||
|
||||
print("[2/5] Starting the first turn's background streaming countdown...")
|
||||
first_progress: dict[str, Any] = {
|
||||
"delta_count": 0,
|
||||
"ready": threading.Event(),
|
||||
"done": threading.Event(),
|
||||
}
|
||||
first_payload = {
|
||||
"input": f"Count down from {args.first_target}, slowly and with commentary.",
|
||||
"store": True,
|
||||
"background": True,
|
||||
"stream": True,
|
||||
}
|
||||
first_data = json.dumps(first_payload).encode("utf-8")
|
||||
first_request = urllib.request.Request(
|
||||
f"{BASE_URL}/responses", data=first_data, headers={"Content-Type": "application/json"}, method="POST"
|
||||
)
|
||||
first_watcher = threading.Thread(target=_watch_sse, args=(first_request, first_progress), daemon=True)
|
||||
first_watcher.start()
|
||||
if not first_progress["ready"].wait(timeout=60):
|
||||
raise SystemExit("FAIL: did not receive response.created for turn 1 in time.")
|
||||
if "id" not in first_progress:
|
||||
raise SystemExit(f"FAIL: turn 1 create request failed: {first_progress.get('error', 'unknown error')}")
|
||||
first_id = first_progress["id"]
|
||||
print(f" turn 1 response id: {first_id}, status: {first_progress['status']}")
|
||||
|
||||
print(f"[3/5] Waiting for turn 1 to stream at least {args.min_deltas_before_steering} tokens...")
|
||||
deadline = time.monotonic() + 60
|
||||
while first_progress["delta_count"] < args.min_deltas_before_steering:
|
||||
if first_progress["done"].is_set() or time.monotonic() > deadline:
|
||||
raise SystemExit(
|
||||
"FAIL: turn 1 finished or timed out before enough tokens streamed to steer reliably; "
|
||||
f"observed {first_progress['delta_count']} delta(s). See {LOG_PATH} for server logs."
|
||||
)
|
||||
time.sleep(0.1)
|
||||
count_at_steer_time = first_progress["delta_count"]
|
||||
print(f" turn 1 text deltas observed before steering: {count_at_steer_time}")
|
||||
|
||||
print(f"[4/5] Sending the steering turn (new target={args.second_target})...")
|
||||
second_payload = {
|
||||
"input": f"Actually, count down from {args.second_target} instead.",
|
||||
"store": True,
|
||||
"background": True,
|
||||
"stream": False,
|
||||
"previous_response_id": first_id,
|
||||
}
|
||||
# Forward the session id turn 1 was assigned so this turn resolves to the same
|
||||
# conversation chain and is queued as a steer instead of starting a fresh task.
|
||||
if "session_id" in first_progress:
|
||||
second_payload["agent_session_id"] = first_progress["session_id"]
|
||||
status, body = _http_post("/responses", second_payload)
|
||||
if status != 200 or body.get("status") != "queued":
|
||||
raise SystemExit(f"FAIL: expected an immediate queued response for the steering turn, got: {body}")
|
||||
second_id = body["id"]
|
||||
print(f" steering turn accepted immediately as queued; response id: {second_id}")
|
||||
|
||||
print("[5/5] Watching turn 1 end early and the steered turn complete...")
|
||||
first_progress["done"].wait(timeout=120)
|
||||
first_final = _http_get(f"/responses/{first_id}")[1]
|
||||
second_final = _poll_until_terminal(second_id, timeout=60)
|
||||
finally:
|
||||
_kill(server)
|
||||
log_file.close()
|
||||
|
||||
first_text = _extract_output_text(first_final.get("output", []))
|
||||
second_text = _extract_output_text(second_final.get("output", []))
|
||||
|
||||
print(f" turn 1 final status: {first_final['status']}, {len(first_text)} character(s): {first_text}")
|
||||
print(f" turn 2 final status: {second_final['status']}, {len(second_text)} character(s): {second_text}")
|
||||
|
||||
if "Serving steered turn" in LOG_PATH.read_text(encoding="utf-8"):
|
||||
print(" confirmed 'Serving steered turn' in the server log.")
|
||||
|
||||
if first_final["status"] != "completed":
|
||||
raise SystemExit(
|
||||
f"FAIL: turn 1 did not complete; last status: {first_final['status']}. See {LOG_PATH} for server logs."
|
||||
)
|
||||
# Loose bound: a steered turn 1 should have generated only a bit more than what we observed
|
||||
# right before steering, not a whole additional full run's worth of tokens.
|
||||
if first_progress["delta_count"] > count_at_steer_time * 3 + 20:
|
||||
raise SystemExit(
|
||||
"FAIL: turn 1 kept streaming long after the steering turn was sent -- steering did not "
|
||||
f"cancel it in time. See {LOG_PATH} for server logs."
|
||||
)
|
||||
|
||||
if second_final["status"] != "completed":
|
||||
raise SystemExit(
|
||||
f"FAIL: turn 2 did not complete; last status: {second_final['status']}. See {LOG_PATH} for server logs."
|
||||
)
|
||||
# Weak ordering check: each number from the new target down to 1 must appear, in order.
|
||||
search_from = 0
|
||||
for n in range(args.second_target, 0, -1):
|
||||
idx = second_text.find(str(n), search_from)
|
||||
if idx == -1:
|
||||
raise SystemExit(
|
||||
f"FAIL: steered turn output is missing '{n}' in order.\n got: {second_text!r}\n"
|
||||
f"See {LOG_PATH} for server logs."
|
||||
)
|
||||
search_from = idx + 1
|
||||
|
||||
print("PASS: the steering turn cancelled the in-progress countdown early and completed its own countdown.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user