Skip to content
Open
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
17 changes: 16 additions & 1 deletion src/a2a/utils/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
from a2a.utils.errors import InvalidParamsError


MAX_HISTORY_LENGTH = 1000

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd avoid setting MAX_HISTORY_LENGTH just as a global constant. If this change lands it should be configurable, opt-in, with None default and set by some server config

"""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."""
Expand All @@ -25,9 +36,13 @@ def HasField(self, field_name: Literal['history_length']) -> bool: # noqa: N802


def validate_history_length(config: HistoryLengthConfig | None) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states that right now server "allowing an unbounded-history read via an absurdly large parameter", but the change into validate_history_length doesn't affect this, full task history will still be loaded in memory.

example on_get_task:

# param validation happens here:
validate_history_length(params)                                                    
...
# task is loaded with full history here:
task: Task | None = await self.task_store.get(task_id, context)  
...
# history cap is applied here:
return apply_history_length(task, params)                                   

"""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(
Expand Down
51 changes: 51 additions & 0 deletions tests/server/request_handlers/test_default_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
34 changes: 34 additions & 0 deletions tests/utils/test_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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()
Loading