diff --git a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md index 8f9837aa7d44..c5fa71281895 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/CHANGELOG.md @@ -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 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/api.md b/sdk/agentserver/azure-ai-agentserver-responses/api.md index 17bde6a3b87c..db0981863a6b 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/api.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/api.md @@ -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 = ..., @@ -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, diff --git a/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml b/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml index f7d2ca3b6a13..28e6bd30ab3d 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml +++ b/sdk/agentserver/azure-ai-agentserver-responses/api.metadata.yml @@ -1,3 +1,4 @@ -apiMdSha256: 47c8926a07ceae18da3b5d5e479b7bcbc291c7e397b6c756713d2afc94471af4 +apiMdSha256: 01fe11f0ae70655439169e023215797c7f3984ef6f19afc4742b5344f0c7f3bd +packageVersion: 2.2.0b2 parserVersion: 0.3.31 -pythonVersion: 3.11.15 +pythonVersion: 3.11.16 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_options.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_options.py index 13d40ff10c4a..2bc823b0363f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_options.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_options.py @@ -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, @@ -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: @@ -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", ) diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py index 7eca8c88fa17..e9da87ab4f0f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/_response_context.py @@ -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, diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py index 01719ccf05b8..192c6ef4aaa1 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/hosting/_orchestrator.py @@ -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: diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py index d4bba871cc8f..445970bb267f 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_base.py @@ -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 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_file.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_file.py index feae0477e650..2841f6f4d13a 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_file.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_file.py @@ -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`). @@ -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, diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py index 7199bd0f265f..86e11ad49d76 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_foundry_provider.py @@ -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 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py index a28b3820bb9a..27572dd2eb29 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/azure/ai/agentserver/responses/store/_memory.py @@ -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 @@ -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, diff --git a/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md b/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md index e639bf9fe50d..5563aa9d26d1 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md +++ b/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md @@ -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: @@ -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 | @@ -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 | diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_eager_history_prefetch.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_eager_history_prefetch.py index 7ef59e6e211b..0430f471dd2c 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_eager_history_prefetch.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/contract/test_eager_history_prefetch.py @@ -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.""" diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_response_store_parity.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_response_store_parity.py index 0779ed7cf260..87bb11a75b54 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_response_store_parity.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_file_response_store_parity.py @@ -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): diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py index 549c7b590d8b..b9d5ddf7df9c 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_foundry_storage_provider.py @@ -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 diff --git a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_options.py b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_options.py index 175598a46eec..7a0c9c4a13ac 100644 --- a/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_options.py +++ b/sdk/agentserver/azure-ai-agentserver-responses/tests/unit/test_options.py @@ -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 @@ -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: @@ -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