Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Bugs Fixed

- Changed the default history fetch limit from 100 to -1 (unlimited), avoiding
automatic truncation of conversation history. Positive limits remain supported.

- Scoped durable multi-turn task IDs with `FOUNDRY_AGENT_SESSION_GUID` when
available, preventing recreated same-name sessions from colliding with task
tombstones. Existing pre-rollout active chains remain resumable through a
Expand Down
4 changes: 2 additions & 2 deletions sdk/agentserver/azure-ai-agentserver-responses/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ namespace azure.ai.agentserver.responses
client_headers: dict[str, str] | None = ...,
conversation_id: str | None = ...,
created_at: datetime | None = ...,
history_limit: int = 100,
history_limit: int = -1,
input_items: list[InputParam] | list[OutputItem] | None = ...,
mode_flags: ResponseModeFlags,
platform_context: PlatformContext | None = ...,
Expand Down Expand Up @@ -832,7 +832,7 @@ namespace azure.ai.agentserver.responses
*,
additional_server_version: str | None = ...,
create_span_hook: CreateSpanHook | None = ...,
default_fetch_history_count: int = 100,
default_fetch_history_count: int = -1,
default_model: str | None = ...,
resilient_background: bool = False,
shutdown_grace_period_seconds: int = 10,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
apiMdSha256: 47c8926a07ceae18da3b5d5e479b7bcbc291c7e397b6c756713d2afc94471af4
apiMdSha256: 01fe11f0ae70655439169e023215797c7f3984ef6f19afc4742b5344f0c7f3bd
packageVersion: 2.2.0b2
parserVersion: 0.3.31
pythonVersion: 3.11.15
pythonVersion: 3.11.16
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ def __init__(
*,
additional_server_version: str | None = None,
default_model: str | None = None,
default_fetch_history_count: int = 100,
default_fetch_history_count: int = -1,
sse_keep_alive_interval_seconds: int | None = None,
shutdown_grace_period_seconds: int = 10,
create_span_hook: "CreateSpanHook | None" = None,
Expand All @@ -40,8 +40,8 @@ def __init__(
raise ValueError("sse_keep_alive_interval_seconds must be > 0 when set")
self.sse_keep_alive_interval_seconds = sse_keep_alive_interval_seconds

if default_fetch_history_count <= 0:
raise ValueError("default_fetch_history_count must be > 0")
if default_fetch_history_count != -1 and default_fetch_history_count <= 0:
raise ValueError("default_fetch_history_count must be -1 (unlimited) or > 0")
self.default_fetch_history_count = default_fetch_history_count

if shutdown_grace_period_seconds <= 0:
Expand Down Expand Up @@ -89,19 +89,19 @@ def _first_non_empty(*keys: str) -> str | None:
return normalized
return None

def _parse_positive_int(*keys: str) -> int | None:
def _parse_history_limit(*keys: str) -> int | None:
raw = _first_non_empty(*keys)
if raw is None:
return None
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{keys[0]} must be a positive integer") from exc
if value <= 0:
raise ValueError(f"{keys[0]} must be > 0")
raise ValueError(f"{keys[0]} must be -1 (unlimited) or a positive integer") from exc
if value != -1 and value <= 0:
raise ValueError(f"{keys[0]} must be -1 (unlimited) or > 0")
return value

default_fetch_history_count = _parse_positive_int(
default_fetch_history_count = _parse_history_limit(
"DEFAULT_FETCH_HISTORY_ITEM_COUNT",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def __init__( # pylint: disable=too-many-arguments
input_items: list[InputParam] | list[OutputItem] | None = None,
previous_response_id: str | None = None,
conversation_id: str | None = None,
history_limit: int = 100,
history_limit: int = -1,
client_headers: dict[str, str] | None = None,
query_parameters: dict[str, str] | None = None,
platform_context: PlatformContext | None = None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1104,7 +1104,7 @@ async def _run_background_non_stream(
store: bool = True,
agent_session_id: str | None = None,
conversation_id: str | None = None,
history_limit: int = 100,
history_limit: int = -1,
runtime_state: _RuntimeState | None = None,
runtime_options: ResponsesServerOptions | None = None,
) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ async def get_history_item_ids(
:type previous_response_id: str | None
:param conversation_id: Optional conversation ID to scope history lookup.
:type conversation_id: str | None
:param limit: Maximum number of history item IDs to return.
:param limit: Maximum number of history item IDs to return, or -1 for all items.
:type limit: int
:keyword context: Platform context for multi-tenant partitioning.
:paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ async def get_history_item_ids(
:type previous_response_id: str | None
:param conversation_id: Optional conversation id to scope history lookup.
:type conversation_id: str | None
:param limit: Maximum number of item IDs to return (most recent N).
:param limit: Maximum number of item IDs to return (most recent N), or -1 for all items.
:type limit: int
:keyword context: Platform context (accepted but unused —
matches :class:`InMemoryResponseProvider`).
Expand Down Expand Up @@ -579,6 +579,8 @@ async def get_history_item_ids(
resolved.extend(indexes.get("input_item_ids") or [])
resolved.extend(indexes.get("output_item_ids") or [])

if limit == -1:
return resolved
if limit <= 0:
return []
# Keep the most recent N item IDs from the resolved chain,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@ async def get_history_item_ids(
:type previous_response_id: str | None
:param conversation_id: An explicit conversation scope identifier, if available.
:type conversation_id: str | None
:param limit: Maximum number of item IDs to return.
:param limit: Maximum number of item IDs to return, or -1 for all items.
:type limit: int
:keyword context: Platform context for multi-tenant partitioning.
:paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ async def get_history_item_ids(
:type previous_response_id: str | None
:param conversation_id: Optional conversation ID to scope history lookup.
:type conversation_id: str | None
:param limit: Maximum number of item IDs to return (most recent N).
:param limit: Maximum number of item IDs to return (most recent N), or -1 for all items.
:type limit: int
:keyword context: Platform context for multi-tenant partitioning.
:paramtype context: ~azure.ai.agentserver.responses.PlatformContext | None
Expand Down Expand Up @@ -325,6 +325,8 @@ async def get_history_item_ids(
resolved.extend(entry.input_item_ids or [])
resolved.extend(entry.output_item_ids or [])

if limit == -1:
return resolved
if limit <= 0:
return []
# Keep the most recent N item IDs from the resolved chain,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -603,9 +603,15 @@ history = await context.get_history()
- Two-step resolution: resolves history item IDs, then fetches actual items
- Ascending order — oldest-first
- Configurable limit via `ResponsesServerOptions.default_fetch_history_count`
(default: 100)
(default: `-1`, unlimited). Positive values retain only the newest N items.
- Lazy singleton — computed once and cached

The same limit applies to history references saved when chaining stored responses.
Unlimited history avoids item-count truncation, but does not provide model context
window management or summarization; long conversations can increase memory usage,
latency, and model input size. Configure a positive limit if needed, taking care
not to separate tool calls from their results when preparing model input.

### Client Headers

Returns `x-client-*` prefixed headers forwarded from the original HTTP request:
Expand Down Expand Up @@ -1290,7 +1296,7 @@ Handlers that do not interact with an LLM typically omit usage.
| Option | Default | Description |
|--------|---------|-------------|
| `default_model` | `None` | Default model when `model` is omitted from the request |
| `default_fetch_history_count` | `100` | Maximum history items resolved by `get_history()` |
| `default_fetch_history_count` | `-1` | Maximum history items resolved by `get_history()`; `-1` fetches all history |
| `sse_keep_alive_interval_seconds` | `None` (disabled) | Interval between SSE keep-alive comments |
| `shutdown_grace_period_seconds` | `10` | Seconds to wait for in-flight requests on shutdown |

Expand All @@ -1300,7 +1306,7 @@ Platform environment variables (read once at startup via `AgentConfig`):
|----------|---------|-------------|
| `SSE_KEEPALIVE_INTERVAL` | Disabled | Interval (seconds) between SSE keep-alive comments |
| `PORT` | `8088` | HTTP listen port |
| `DEFAULT_FETCH_HISTORY_ITEM_COUNT` | `100` | Override for `default_fetch_history_count` |
| `DEFAULT_FETCH_HISTORY_ITEM_COUNT` | `-1` | Override for `default_fetch_history_count` when using `ResponsesServerOptions.from_env()` |
| `FOUNDRY_PROJECT_ENDPOINT` | — | Foundry project endpoint (enables persistence) |
| `FOUNDRY_AGENT_SESSION_ID` | — | Platform-supplied session ID |
| `FOUNDRY_AGENT_NAME` | — | Agent name for tracing |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,34 @@ class TestEagerHistoryPrefetchValidation:
"""Verify that invalid conversation references are rejected before
the handler runs."""

@pytest.mark.parametrize("limit", [None, 10])
def test_history_limit_across_stored_turns(self, limit: int | None) -> None:
"""The default retains more than 100 items through prefetch and persistence."""
histories: list[list[Any]] = []

async def handler(request: Any, context: Any, cancellation_signal: Any) -> Any:
histories.append(list(await context.get_history()))
return await _simple_handler(request, context, cancellation_signal)

options = ResponsesServerOptions() if limit is None else ResponsesServerOptions(default_fetch_history_count=limit)
app = ResponsesAgentServerHost(options=options, store=InMemoryResponseProvider())
app.response_handler(handler)
inputs = [{"role": "user", "content": f"message {index}"} for index in range(120)]
with TestClient(app) as client:
first = client.post("/responses", json={"model": "m", "input": inputs, "store": True})
assert first.status_code == 200
previous_id = first.json()["id"]
for _ in range(2):
response = client.post(
"/responses",
json={"model": "m", "input": "next", "previous_response_id": previous_id, "store": True},
)
assert response.status_code == 200
previous_id = response.json()["id"]
assert [len(history) for history in histories] == ([0, 120, 121] if limit is None else [0, 10, 10])
if limit is None:
assert histories[2][:120] == histories[1]

def test_nonexistent_previous_response_id_returns_404(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""POST with a nonexistent previous_response_id should return
404 when the provider raises FoundryResourceNotFoundError."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,11 +329,32 @@ async def test_history_respects_limit(tmp_path: Path) -> None:
ids = await provider.get_history_item_ids("r_prev", None, limit=3)
# Chronological order is oldest-first; truncation must keep the newest IDs.
assert ids == ["out1", "out2", "out3"]
# Non-positive limit returns empty.
ids_all = await provider.get_history_item_ids("r_prev", None, limit=-1)
assert ids_all == ["hist1", "hist2", "in1", "in2", "out1", "out2", "out3"]
# Zero still returns empty.
ids_zero = await provider.get_history_item_ids("r_prev", None, limit=0)
assert ids_zero == []


@pytest.mark.asyncio
async def test_history_unlimited_preserves_all_conversation_items(tmp_path: Path) -> None:
for _label, factory in _make_provider_factories(tmp_path):
provider = factory()
expected = []
for turn in range(3):
items = [_input_item(f"in_{turn}_{index}") for index in range(50)]
output = _output_item(f"out_{turn}")
await provider.create_response(
_response(f"r_{turn}", conversation_id="conv-1", output=[output]),
items,
history_item_ids=None,
)
expected.extend(item["id"] for item in items)
expected.append(output["id"])
assert await provider.get_history_item_ids(None, "conv-1", limit=-1) == expected
assert await provider.get_history_item_ids(None, "conv-1", limit=10) == expected[-10:]


@pytest.mark.asyncio
async def test_history_neither_arg_returns_empty(tmp_path: Path) -> None:
for _label, factory in _make_provider_factories(tmp_path):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,18 +400,19 @@ async def test_get_items__preserves_input_order(credential: Any, settings: Found


@pytest.mark.asyncio
@pytest.mark.parametrize("limit", [-1, 10, 1000])
async def test_get_history_item_ids__gets_to_history_endpoint(
credential: Any, settings: FoundryStorageSettings
credential: Any, settings: FoundryStorageSettings, limit: int
) -> None:
provider = _make_provider(credential, settings, _make_response(200, ["item_h1", "item_h2"]))

await provider.get_history_item_ids(None, None, limit=10)
await provider.get_history_item_ids(None, None, limit=limit)

request = provider._client.send_request.call_args[0][0]
assert request.method == "GET"
assert "history/item_ids" in request.url
assert "api-version=v1" in request.url
assert "limit=10" in request.url
assert f"limit={limit}" in request.url


@pytest.mark.asyncio
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
def test_options__defaults_match_public_contract() -> None:
options = ResponsesServerOptions()

assert options.default_fetch_history_count == 100
assert options.default_fetch_history_count == -1
assert options.default_model is None
assert options.additional_server_version is None
assert options.sse_keep_alive_enabled is False
Expand Down Expand Up @@ -43,7 +43,35 @@ def test_options__invalid_boundary_values_fail_fast() -> None:
ResponsesServerOptions(sse_keep_alive_interval_seconds=0)

with pytest.raises(ValueError):
ResponsesServerOptions.from_env({"DEFAULT_FETCH_HISTORY_ITEM_COUNT": "-1"})
ResponsesServerOptions.from_env({"DEFAULT_FETCH_HISTORY_ITEM_COUNT": "-2"})


@pytest.mark.parametrize("limit", [-1, 1, 10, 1000])
def test_options__history_limit_from_constructor_and_environment(limit: int) -> None:
assert ResponsesServerOptions(default_fetch_history_count=limit).default_fetch_history_count == limit
assert (
ResponsesServerOptions.from_env({"DEFAULT_FETCH_HISTORY_ITEM_COUNT": str(limit)}).default_fetch_history_count
== limit
)


@pytest.mark.parametrize("limit", [0, -2, -100])
def test_options__invalid_history_limits(limit: int) -> None:
with pytest.raises(ValueError, match="unlimited"):
ResponsesServerOptions(default_fetch_history_count=limit)
with pytest.raises(ValueError, match="unlimited"):
ResponsesServerOptions.from_env({"DEFAULT_FETCH_HISTORY_ITEM_COUNT": str(limit)})


@pytest.mark.parametrize("value", ["invalid", "1.5"])
def test_options__invalid_history_environment_value(value: str) -> None:
with pytest.raises(ValueError, match="positive integer"):
ResponsesServerOptions.from_env({"DEFAULT_FETCH_HISTORY_ITEM_COUNT": value})


@pytest.mark.parametrize("environ", [{}, {"DEFAULT_FETCH_HISTORY_ITEM_COUNT": ""}])
def test_options__environment_defaults_to_unlimited(environ: dict[str, str]) -> None:
assert ResponsesServerOptions.from_env(environ).default_fetch_history_count == -1


def test_options__spec_environment_variable_names_are_supported() -> None:
Expand All @@ -68,5 +96,5 @@ def test_options__legacy_environment_variable_names_are_ignored() -> None:
)

assert options.default_model is None
assert options.default_fetch_history_count == 100
assert options.default_fetch_history_count == -1
assert options.sse_keep_alive_interval_seconds is None