diff --git a/src/a2a/utils/task.py b/src/a2a/utils/task.py index 4acf54e46..7ce3e8333 100644 --- a/src/a2a/utils/task.py +++ b/src/a2a/utils/task.py @@ -10,6 +10,17 @@ from a2a.utils.errors import InvalidParamsError +MAX_HISTORY_LENGTH = 1000 +"""Maximum allowed value for a ``history_length`` request. + +Keeps a single request from asking the server to materialize an +unbounded history (e.g. ``historyLength=999999999``). The value is a +pragmatic cap aligned with the other A2A SDKs' semantics, where very +large values effectively mean "return everything available"; clients +requesting more than this cap get ``InvalidParamsError``. +""" + + @runtime_checkable class HistoryLengthConfig(Protocol): """Protocol for configuration arguments containing history_length field.""" @@ -25,9 +36,13 @@ def HasField(self, field_name: Literal['history_length']) -> bool: # noqa: N802 def validate_history_length(config: HistoryLengthConfig | None) -> None: - """Validates that history_length is non-negative.""" + """Validates that history_length is non-negative and within limits.""" if config and config.history_length < 0: raise InvalidParamsError(message='history length must be non-negative') + if config and config.history_length > MAX_HISTORY_LENGTH: + raise InvalidParamsError( + message=f'history length must be at most {MAX_HISTORY_LENGTH}' + ) def apply_history_length( diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index 727679e7c..1ce65e2f0 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -2796,6 +2796,57 @@ async def test_on_get_task_negative_history_length_error(agent_card): assert 'history length must be non-negative' in exc_info.value.message +@pytest.mark.asyncio +async def test_on_get_task_history_length_too_large_error(agent_card): + """Test on_get_task raises error for history length above the limit.""" + from a2a.utils.task import MAX_HISTORY_LENGTH + + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandler( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=agent_card, + ) + params = GetTaskRequest(id='task1', history_length=MAX_HISTORY_LENGTH + 1) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError) as exc_info: + await request_handler.on_get_task(params, context) + + assert str(MAX_HISTORY_LENGTH) in exc_info.value.message + mock_task_store.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_message_send_history_length_too_large_error(agent_card): + """Test on_message_send raises error for history length above the limit.""" + from a2a.utils.task import MAX_HISTORY_LENGTH + + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandler( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=agent_card, + ) + + message_config = SendMessageConfiguration( + history_length=MAX_HISTORY_LENGTH + 1, + accepted_output_modes=['text/plain'], + ) + params = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, message_id='msg1', parts=[Part(text='Test')] + ), + configuration=message_config, + ) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError) as exc_info: + await request_handler.on_message_send(params, context) + + assert str(MAX_HISTORY_LENGTH) in exc_info.value.message + + @pytest.mark.asyncio async def test_on_list_tasks_page_size_too_small(agent_card): """Test on_list_tasks raises error for page_size < 1.""" diff --git a/tests/server/request_handlers/test_default_request_handler_v2.py b/tests/server/request_handlers/test_default_request_handler_v2.py index b276fb77a..404ef3a1c 100644 --- a/tests/server/request_handlers/test_default_request_handler_v2.py +++ b/tests/server/request_handlers/test_default_request_handler_v2.py @@ -1075,6 +1075,25 @@ async def test_on_get_task_negative_history_length_error(): assert 'history length must be non-negative' in exc_info.value.message +@pytest.mark.asyncio +async def test_on_get_task_history_length_too_large_error(): + """Test on_get_task raises error for history length above the limit.""" + from a2a.utils.task import MAX_HISTORY_LENGTH + + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandlerV2( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=create_default_agent_card(), + ) + params = GetTaskRequest(id='task1', history_length=MAX_HISTORY_LENGTH + 1) + context = create_server_call_context() + with pytest.raises(InvalidParamsError) as exc_info: + await request_handler.on_get_task(params, context) + assert str(MAX_HISTORY_LENGTH) in exc_info.value.message + mock_task_store.get.assert_not_awaited() + + @pytest.mark.asyncio async def test_on_list_tasks_page_size_too_small(): """Test on_list_tasks raises error for page_size < 1.""" diff --git a/tests/utils/test_task.py b/tests/utils/test_task.py index 8124955d1..5dde3b447 100644 --- a/tests/utils/test_task.py +++ b/tests/utils/test_task.py @@ -14,9 +14,11 @@ ) from a2a.utils.errors import InvalidParamsError from a2a.utils.task import ( + MAX_HISTORY_LENGTH, apply_history_length, decode_page_token, encode_page_token, + validate_history_length, ) @@ -89,5 +91,37 @@ def test_zero_history_length_returns_empty_history(self): self.assertEqual(len(result.history), 0) +class TestValidateHistoryLength(unittest.TestCase): + def test_none_config_passes(self): + # Does not raise + validate_history_length(None) + + def test_zero_passes(self): + validate_history_length(GetTaskRequest(history_length=0)) + + def test_boundary_max_passes(self): + validate_history_length( + GetTaskRequest(history_length=MAX_HISTORY_LENGTH) + ) + + def test_negative_raises(self): + with pytest.raises(InvalidParamsError) as excinfo: + validate_history_length(GetTaskRequest(history_length=-1)) + assert 'non-negative' in str(excinfo.value) + + def test_over_max_raises(self): + with pytest.raises(InvalidParamsError) as excinfo: + validate_history_length( + GetTaskRequest(history_length=MAX_HISTORY_LENGTH + 1) + ) + assert str(MAX_HISTORY_LENGTH) in str(excinfo.value) + + def test_over_max_raises_for_send_configuration(self): + with pytest.raises(InvalidParamsError): + validate_history_length( + SendMessageConfiguration(history_length=MAX_HISTORY_LENGTH + 1) + ) + + if __name__ == '__main__': unittest.main()