diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index bef14e243..000000000 --- a/.coveragerc +++ /dev/null @@ -1,18 +0,0 @@ -[run] -branch = True -omit = - */tests/* - */site-packages/* - */__init__.py - src/a2a/grpc/* - -[report] -exclude_lines = - pragma: no cover - import - def __repr__ - raise NotImplementedError - if TYPE_CHECKING - @abstractmethod - pass - raise ImportError diff --git a/.mypy.ini b/.mypy.ini deleted file mode 100644 index b6f053be3..000000000 --- a/.mypy.ini +++ /dev/null @@ -1,7 +0,0 @@ -[mypy] -exclude = src/a2a/grpc/ -disable_error_code = import-not-found,annotation-unchecked,import-untyped -plugins = pydantic.mypy - -[mypy-examples.*] -follow_imports = skip diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 3562de26e..000000000 --- a/.ruff.toml +++ /dev/null @@ -1,169 +0,0 @@ -################################################################################# -# -# Ruff linter and code formatter for A2A -# -# This file follows the standards in Google Python Style Guide -# https://google.github.io/styleguide/pyguide.html -# - -line-length = 80 # Google Style Guide §3.2: 80 columns -indent-width = 4 # Google Style Guide §3.4: 4 spaces - -target-version = "py310" # Minimum Python version - -[lint] -ignore = [ - "COM812", # Trailing comma missing. - "FBT001", # Boolean positional arg in function definition - "FBT002", # Boolean default value in function definition - "D203", # 1 blank line required before class docstring (Google: 0) - "D213", # Multi-line docstring summary should start at the second line (Google: first line) - "D100", # Ignore Missing docstring in public module (often desired at top level __init__.py) - "D104", # Ignore Missing docstring in public package (often desired at top level __init__.py) - "D107", # Ignore Missing docstring in __init__ (use class docstring) - "TD002", # Ignore Missing author in TODOs (often not required) - "TD003", # Ignore Missing issue link in TODOs (often not required/available) - "T201", # Ignore print presence - "RUF012", # Ignore Mutable class attributes should be annotated with `typing.ClassVar` - "E501", # Ignore line length (handled by Ruff's dynamic line length) - "ANN002", - "ANN003", - "ANN401", - "TRY003", - "TRY201", - "FIX002", - "UP038", -] - -select = [ - "E", # pycodestyle errors (PEP 8) - "W", # pycodestyle warnings (PEP 8) - "F", # Pyflakes (logical errors, unused imports/variables) - "I", # isort (import sorting - Google Style §3.1.2) - "D", # pydocstyle (docstring conventions - Google Style §3.8) - "N", # pep8-naming (naming conventions - Google Style §3.16) - "UP", # pyupgrade (use modern Python syntax) - "ANN",# flake8-annotations (type hint usage/style - Google Style §2.22) - "A", # flake8-builtins (avoid shadowing builtins) - "B", # flake8-bugbear (potential logic errors & style issues - incl. mutable defaults B006, B008) - "C4", # flake8-comprehensions (unnecessary list/set/dict comprehensions) - "ISC",# flake8-implicit-str-concat (disallow implicit string concatenation across lines) - "T20",# flake8-print (discourage `print` - prefer logging) - "SIM",# flake8-simplify (simplify code, e.g., `if cond: return True else: return False`) - "PTH",# flake8-use-pathlib (use pathlib instead of os.path where possible) - "PL", # Pylint rules ported to Ruff (PLC, PLE, PLR, PLW) - "PIE",# flake8-pie (misc code improvements, e.g., no-unnecessary-pass) - "RUF",# Ruff-specific rules (e.g., RUF001-003 ambiguous unicode, RUF013 implicit optional) - "RET",# flake8-return (consistency in return statements) - "SLF",# flake8-self (check for private member access via `self`) - "TID",# flake8-tidy-imports (relative imports, banned imports - configure if needed) - "YTT",# flake8-boolean-trap (checks for boolean positional arguments, truthiness tests - Google Style §3.10) - "TD", # flake8-todos (check TODO format - Google Style §3.7) - "TCH",# flake8-type-checking (helps manage TYPE_CHECKING blocks and imports) - "PYI",# flake8-pyi (best practices for .pyi stub files, some rules are useful for .py too) - "S", # flake8-bandit (security issues) - "DTZ",# flake8-datetimez (timezone-aware datetimes) - "ERA",# flake8-eradicate (commented-out code) - "Q", # flake8-quotes (quote style consistency) - "RSE",# flake8-raise (modern raise statements) - "TRY",# tryceratops (exception handling best practices) - "PERF",# perflint (performance anti-patterns) - "BLE", - "T10", - "ICN", - "G", - "FIX", - "ASYNC", - "INP", -] - -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".hg", - ".mypy_cache", - ".nox", - ".pants.d", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "venv", - "*/migrations/*", - "src/a2a/grpc/**", - "tests/**", -] - -[lint.isort] -#force-sort-within-sections = true -#combine-as-imports = true -case-sensitive = true -#force-single-line = false -#known-first-party = [] -#known-third-party = [] -lines-after-imports = 2 -lines-between-types = 1 -#no-lines-before = ["LOCALFOLDER"] -#required-imports = [] -#section-order = ["future", "standard-library", "third-party", "first-party", "local-folder"] - -[lint.pydocstyle] -convention = "google" -ignore-decorators = ["typing.overload", "abc.abstractmethod"] - -[lint.flake8-annotations] -mypy-init-return = true -allow-star-arg-any = false - -[lint.pep8-naming] -ignore-names = ["test_*", "setUp", "tearDown", "mock_*"] -classmethod-decorators = ["classmethod", "pydantic.validator", "pydantic.root_validator"] -staticmethod-decorators = ["staticmethod"] - -[lint.flake8-tidy-imports] -ban-relative-imports = "all" # Google generally prefers absolute imports (§3.1.2) - -[lint.flake8-quotes] -docstring-quotes = "double" -inline-quotes = "single" - -[lint.per-file-ignores] -"__init__.py" = ["F401", "D", "ANN"] # Ignore unused imports in __init__.py -"*_test.py" = [ - "D", # All pydocstyle rules - "ANN", # Missing type annotation for function argument - "RUF013", # Implicit optional type in test function signatures - "S101", # Use of `assert` detected (expected in tests) - "PLR2004", - "SLF001", -] -"test_*.py" = [ - "D", - "ANN", - "RUF013", - "S101", - "PLR2004", - "SLF001", -] -"types.py" = ["D", "E501"] # Ignore docstring and annotation issues in types.py -"proto_utils.py" = ["D102", "PLR0911"] -"helpers.py" = ["ANN001", "ANN201", "ANN202"] -"scripts/*.py" = ["INP001"] - -[format] -exclude = [ - "src/a2a/grpc/**", -] -docstring-code-format = true -docstring-code-line-length = "dynamic" # Or set to 80 -quote-style = "single" -indent-style = "space" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7875c65a6..8bbfe6778 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.3.9](https://github.com/a2aproject/a2a-python/compare/v0.3.8...v0.3.9) (2025-10-15) + + +### Features + +* custom ID generators ([051ab20](https://github.com/a2aproject/a2a-python/commit/051ab20c395daa2807b0233cf1c53493e41b60c2)) + + +### Bug Fixes + +* apply `history_length` for `message/send` requests ([#498](https://github.com/a2aproject/a2a-python/issues/498)) ([a49f94e](https://github.com/a2aproject/a2a-python/commit/a49f94ef23d81b8375e409b1c1e51afaf1da1956)) +* **client:** `A2ACardResolver.get_agent_card` will auto-populate with `agent_card_path` when `relative_card_path` is empty ([#508](https://github.com/a2aproject/a2a-python/issues/508)) ([ba24ead](https://github.com/a2aproject/a2a-python/commit/ba24eadb5b6fcd056a008e4cbcef03b3f72a37c3)) + + +### Documentation + +* Fix Docstring formatting for code samples ([#492](https://github.com/a2aproject/a2a-python/issues/492)) ([dca66c3](https://github.com/a2aproject/a2a-python/commit/dca66c3100a2b9701a1c8b65ad6853769eefd511)) + ## [0.3.8](https://github.com/a2aproject/a2a-python/compare/v0.3.7...v0.3.8) (2025-10-06) diff --git a/pyproject.toml b/pyproject.toml index 192e2151e..46f7400a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,9 +53,19 @@ repository = "https://github.com/a2aproject/a2a-python" changelog = "https://github.com/a2aproject/a2a-python/blob/main/CHANGELOG.md" documentation = "https://a2a-protocol.org/latest/sdk/python/" +[build-system] +requires = ["hatchling", "uv-dynamic-versioning"] +build-backend = "hatchling.build" + +[tool.hatch.version] +source = "uv-dynamic-versioning" + [tool.hatch.build.targets.wheel] packages = ["src/a2a"] +[tool.hatch.build.targets.sdist] +exclude = ["tests/"] + [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" @@ -68,16 +78,6 @@ markers = [ [tool.pytest-asyncio] mode = "strict" -[build-system] -requires = ["hatchling", "uv-dynamic-versioning"] -build-backend = "hatchling.build" - -[tool.hatch.version] -source = "uv-dynamic-versioning" - -[tool.hatch.build.targets.sdist] -exclude = ["tests/"] - [tool.uv-dynamic-versioning] vcs = "git" style = "pep440" @@ -113,7 +113,17 @@ publish-url = "https://test.pypi.org/legacy/" explicit = true [tool.mypy] -plugins = ['pydantic.mypy'] +plugins = ["pydantic.mypy"] +exclude = ["src/a2a/grpc/"] +disable_error_code = [ + "import-not-found", + "annotation-unchecked", + "import-untyped", +] + +[[tool.mypy.overrides]] +module = "examples.*" +follow_imports = "skip" [tool.pyright] include = ["src"] @@ -128,3 +138,182 @@ exclude = [ ] reportMissingImports = "none" reportMissingModuleSource = "none" + +[tool.coverage.run] +branch = true +omit = [ + "*/tests/*", + "*/site-packages/*", + "*/__init__.py", + "src/a2a/grpc/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "import", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING", + "@abstractmethod", + "pass", + "raise ImportError", +] + +# +# Ruff linter and code formatter for A2A +# +[tool.ruff] +# This file follows the standards in Google Python Style Guide +# https://google.github.io/styleguide/pyguide.html +line-length = 80 # Google Style Guide §3.2: 80 columns +indent-width = 4 # Google Style Guide §3.4: 4 spaces +target-version = "py310" # Minimum Python version + +[tool.ruff.lint] +ignore = [ + "COM812", # Trailing comma missing. + "FBT001", # Boolean positional arg in function definition + "FBT002", # Boolean default value in function definition + "D203", # 1 blank line required before class docstring (Google: 0) + "D213", # Multi-line docstring summary should start at the second line (Google: first line) + "D100", # Ignore Missing docstring in public module (often desired at top level __init__.py) + "D104", # Ignore Missing docstring in public package (often desired at top level __init__.py) + "D107", # Ignore Missing docstring in __init__ (use class docstring) + "TD002", # Ignore Missing author in TODOs (often not required) + "TD003", # Ignore Missing issue link in TODOs (often not required/available) + "T201", # Ignore print presence + "RUF012", # Ignore Mutable class attributes should be annotated with `typing.ClassVar` + "E501", # Ignore line length (handled by Ruff's dynamic line length) + "ANN002", + "ANN003", + "ANN401", + "TRY003", + "TRY201", + "FIX002", +] + +select = [ + "E", # pycodestyle errors (PEP 8) + "W", # pycodestyle warnings (PEP 8) + "F", # Pyflakes (logical errors, unused imports/variables) + "I", # isort (import sorting - Google Style §3.1.2) + "D", # pydocstyle (docstring conventions - Google Style §3.8) + "N", # pep8-naming (naming conventions - Google Style §3.16) + "UP", # pyupgrade (use modern Python syntax) + "ANN",# flake8-annotations (type hint usage/style - Google Style §2.22) + "A", # flake8-builtins (avoid shadowing builtins) + "B", # flake8-bugbear (potential logic errors & style issues - incl. mutable defaults B006, B008) + "C4", # flake8-comprehensions (unnecessary list/set/dict comprehensions) + "ISC",# flake8-implicit-str-concat (disallow implicit string concatenation across lines) + "T20",# flake8-print (discourage `print` - prefer logging) + "SIM",# flake8-simplify (simplify code, e.g., `if cond: return True else: return False`) + "PTH",# flake8-use-pathlib (use pathlib instead of os.path where possible) + "PL", # Pylint rules ported to Ruff (PLC, PLE, PLR, PLW) + "PIE",# flake8-pie (misc code improvements, e.g., no-unnecessary-pass) + "RUF",# Ruff-specific rules (e.g., RUF001-003 ambiguous unicode, RUF013 implicit optional) + "RET",# flake8-return (consistency in return statements) + "SLF",# flake8-self (check for private member access via `self`) + "TID",# flake8-tidy-imports (relative imports, banned imports - configure if needed) + "YTT",# flake8-boolean-trap (checks for boolean positional arguments, truthiness tests - Google Style §3.10) + "TD", # flake8-todos (check TODO format - Google Style §3.7) + "TCH",# flake8-type-checking (helps manage TYPE_CHECKING blocks and imports) + "PYI",# flake8-pyi (best practices for .pyi stub files, some rules are useful for .py too) + "S", # flake8-bandit (security issues) + "DTZ",# flake8-datetimez (timezone-aware datetimes) + "ERA",# flake8-eradicate (commented-out code) + "Q", # flake8-quotes (quote style consistency) + "RSE",# flake8-raise (modern raise statements) + "TRY",# tryceratops (exception handling best practices) + "PERF",# perflint (performance anti-patterns) + "BLE", + "T10", + "ICN", + "G", + "FIX", + "ASYNC", + "INP", +] + +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "*/migrations/*", + "src/a2a/grpc/**", + "tests/**", +] + +[tool.ruff.lint.isort] +case-sensitive = true +lines-after-imports = 2 +lines-between-types = 1 + +[tool.ruff.lint.pydocstyle] +convention = "google" +ignore-decorators = ["typing.overload", "abc.abstractmethod"] + +[tool.ruff.lint.flake8-annotations] +mypy-init-return = true +allow-star-arg-any = false + +[tool.ruff.lint.pep8-naming] +ignore-names = ["test_*", "setUp", "tearDown", "mock_*"] +classmethod-decorators = ["classmethod", "pydantic.validator", "pydantic.root_validator"] +staticmethod-decorators = ["staticmethod"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" # Google generally prefers absolute imports (§3.1.2) + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +inline-quotes = "single" + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401", "D", "ANN"] # Ignore unused imports in __init__.py +"*_test.py" = [ + "D", # All pydocstyle rules + "ANN", # Missing type annotation for function argument + "RUF013", # Implicit optional type in test function signatures + "S101", # Use of `assert` detected (expected in tests) + "PLR2004", + "SLF001", +] +"test_*.py" = [ + "D", + "ANN", + "RUF013", + "S101", + "PLR2004", + "SLF001", +] +"types.py" = ["D", "E501"] # Ignore docstring and annotation issues in types.py +"proto_utils.py" = ["D102", "PLR0911"] +"helpers.py" = ["ANN001", "ANN201", "ANN202"] +"scripts/*.py" = ["INP001"] + +[tool.ruff.format] +exclude = [ + "src/a2a/grpc/**", +] +docstring-code-format = true +docstring-code-line-length = "dynamic" +quote-style = "single" +indent-style = "space" diff --git a/src/a2a/client/card_resolver.py b/src/a2a/client/card_resolver.py index 9df551525..f13fe3ab6 100644 --- a/src/a2a/client/card_resolver.py +++ b/src/a2a/client/card_resolver.py @@ -53,7 +53,7 @@ async def get_agent_card( Args: relative_card_path: Optional path to the agent card endpoint, relative to the base URL. If None, uses the default public - agent card path. + agent card path. Use `'/'` for an empty path. http_kwargs: Optional dictionary of keyword arguments to pass to the underlying httpx.get request. @@ -65,7 +65,7 @@ async def get_agent_card( A2AClientJSONError: If the response body cannot be decoded as JSON or validated against the AgentCard schema. """ - if relative_card_path is None: + if not relative_card_path: # Use the default public agent card path configured during initialization path_segment = self.agent_card_path else: diff --git a/src/a2a/client/client_factory.py b/src/a2a/client/client_factory.py index c568331f3..3e98c9f5e 100644 --- a/src/a2a/client/client_factory.py +++ b/src/a2a/client/client_factory.py @@ -41,14 +41,17 @@ class ClientFactory: The factory is configured with a `ClientConfig` and optionally a list of `Consumer`s to use for all generated `Client`s. The expected use is: - factory = ClientFactory(config, consumers) - # Optionally register custom client implementations - factory.register('my_customer_transport', NewCustomTransportClient) - # Then with an agent card make a client with additional consumers and - # interceptors - client = factory.create(card, additional_consumers, interceptors) - # Now the client can be used the same regardless of transport and - # aligns client config with server capabilities. + .. code-block:: python + + factory = ClientFactory(config, consumers) + # Optionally register custom client implementations + factory.register('my_customer_transport', NewCustomTransportClient) + # Then with an agent card make a client with additional consumers and + # interceptors + client = factory.create(card, additional_consumers, interceptors) + + Now the client can be used consistently regardless of the transport. This + aligns the client configuration with the server's capabilities. """ def __init__( diff --git a/src/a2a/server/agent_execution/context.py b/src/a2a/server/agent_execution/context.py index 4ac5fb0e0..cd9f8f973 100644 --- a/src/a2a/server/agent_execution/context.py +++ b/src/a2a/server/agent_execution/context.py @@ -1,8 +1,11 @@ -import uuid - from typing import Any from a2a.server.context import ServerCallContext +from a2a.server.id_generator import ( + IDGenerator, + IDGeneratorContext, + UUIDGenerator, +) from a2a.types import ( InvalidParamsError, Message, @@ -30,6 +33,8 @@ def __init__( # noqa: PLR0913 task: Task | None = None, related_tasks: list[Task] | None = None, call_context: ServerCallContext | None = None, + task_id_generator: IDGenerator | None = None, + context_id_generator: IDGenerator | None = None, ): """Initializes the RequestContext. @@ -40,6 +45,8 @@ def __init__( # noqa: PLR0913 task: The existing `Task` object retrieved from the store, if any. related_tasks: A list of other tasks related to the current request (e.g., for tool use). call_context: The server call context associated with this request. + task_id_generator: ID generator for new task IDs. Defaults to UUID generator. + context_id_generator: ID generator for new context IDs. Defaults to UUID generator. """ if related_tasks is None: related_tasks = [] @@ -49,6 +56,12 @@ def __init__( # noqa: PLR0913 self._current_task = task self._related_tasks = related_tasks self._call_context = call_context + self._task_id_generator = ( + task_id_generator if task_id_generator else UUIDGenerator() + ) + self._context_id_generator = ( + context_id_generator if context_id_generator else UUIDGenerator() + ) # If the task id and context id were provided, make sure they # match the request. Otherwise, create them if self._params: @@ -163,7 +176,9 @@ def _check_or_generate_task_id(self) -> None: return if not self._task_id and not self._params.message.task_id: - self._params.message.task_id = str(uuid.uuid4()) + self._params.message.task_id = self._task_id_generator.generate( + IDGeneratorContext(context_id=self._context_id) + ) if self._params.message.task_id: self._task_id = self._params.message.task_id @@ -173,6 +188,10 @@ def _check_or_generate_context_id(self) -> None: return if not self._context_id and not self._params.message.context_id: - self._params.message.context_id = str(uuid.uuid4()) + self._params.message.context_id = ( + self._context_id_generator.generate( + IDGeneratorContext(task_id=self._task_id) + ) + ) if self._params.message.context_id: self._context_id = self._params.message.context_id diff --git a/src/a2a/server/id_generator.py b/src/a2a/server/id_generator.py new file mode 100644 index 000000000..c523adc97 --- /dev/null +++ b/src/a2a/server/id_generator.py @@ -0,0 +1,28 @@ +import uuid + +from abc import ABC, abstractmethod + +from pydantic import BaseModel + + +class IDGeneratorContext(BaseModel): + """Context for providing additional information to ID generators.""" + + task_id: str | None = None + context_id: str | None = None + + +class IDGenerator(ABC): + """Interface for generating unique identifiers.""" + + @abstractmethod + def generate(self, context: IDGeneratorContext) -> str: + pass + + +class UUIDGenerator(IDGenerator): + """UUID implementation of the IDGenerator interface.""" + + def generate(self, context: IDGeneratorContext) -> str: + """Generates a random UUID, ignoring the context.""" + return str(uuid.uuid4()) diff --git a/src/a2a/server/models.py b/src/a2a/server/models.py index c677fa8c0..4b0f7504c 100644 --- a/src/a2a/server/models.py +++ b/src/a2a/server/models.py @@ -166,15 +166,18 @@ def create_task_model( TaskModel class with the specified table name. Example: - # Create a task model with default table name - TaskModel = create_task_model() + .. code-block:: python - # Create a task model with custom table name - CustomTaskModel = create_task_model('my_tasks') + # Create a task model with default table name + TaskModel = create_task_model() - # Use with a custom base - from myapp.database import Base as MyBase - TaskModel = create_task_model('tasks', MyBase) + # Create a task model with custom table name + CustomTaskModel = create_task_model('my_tasks') + + # Use with a custom base + from myapp.database import Base as MyBase + + TaskModel = create_task_model('tasks', MyBase) """ class TaskModel(TaskMixin, base): # type: ignore diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index 5e21fe8b0..30d1ee891 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -44,6 +44,7 @@ UnsupportedOperationError, ) from a2a.utils.errors import ServerError +from a2a.utils.task import apply_history_length from a2a.utils.telemetry import SpanKind, trace_class @@ -118,25 +119,7 @@ async def on_get_task( raise ServerError(error=TaskNotFoundError()) # Apply historyLength parameter if specified - if params.history_length is not None and task.history: - # Limit history to the most recent N messages - limited_history = ( - task.history[-params.history_length :] - if params.history_length > 0 - else [] - ) - # Create a new task instance with limited history - task = Task( - id=task.id, - context_id=task.context_id, - status=task.status, - artifacts=task.artifacts, - history=limited_history, - metadata=task.metadata, - kind=task.kind, - ) - - return task + return apply_history_length(task, params.history_length) async def on_cancel_task( self, params: TaskIdParams, context: ServerCallContext | None = None @@ -363,6 +346,10 @@ async def push_notification_callback() -> None: if isinstance(result, Task): self._validate_task_id_match(task_id, result.id) + if params.configuration: + result = apply_history_length( + result, params.configuration.history_length + ) await self._send_push_notification_if_needed(task_id, result_aggregator) diff --git a/src/a2a/server/tasks/task_updater.py b/src/a2a/server/tasks/task_updater.py index 4afc8b357..b61ab7001 100644 --- a/src/a2a/server/tasks/task_updater.py +++ b/src/a2a/server/tasks/task_updater.py @@ -1,10 +1,14 @@ import asyncio -import uuid from datetime import datetime, timezone from typing import Any from a2a.server.events import EventQueue +from a2a.server.id_generator import ( + IDGenerator, + IDGeneratorContext, + UUIDGenerator, +) from a2a.types import ( Artifact, Message, @@ -23,13 +27,22 @@ class TaskUpdater: Simplifies the process of creating and enqueueing standard task events. """ - def __init__(self, event_queue: EventQueue, task_id: str, context_id: str): + def __init__( + self, + event_queue: EventQueue, + task_id: str, + context_id: str, + artifact_id_generator: IDGenerator | None = None, + message_id_generator: IDGenerator | None = None, + ): """Initializes the TaskUpdater. Args: event_queue: The `EventQueue` associated with the task. task_id: The ID of the task. context_id: The context ID of the task. + artifact_id_generator: ID generator for new artifact IDs. Defaults to UUID generator. + message_id_generator: ID generator for new message IDs. Defaults to UUID generator. """ self.event_queue = event_queue self.task_id = task_id @@ -42,6 +55,12 @@ def __init__(self, event_queue: EventQueue, task_id: str, context_id: str): TaskState.failed, TaskState.rejected, } + self._artifact_id_generator = ( + artifact_id_generator if artifact_id_generator else UUIDGenerator() + ) + self._message_id_generator = ( + message_id_generator if message_id_generator else UUIDGenerator() + ) async def update_status( self, @@ -110,7 +129,11 @@ async def add_artifact( # noqa: PLR0913 extensions: Optional list of extensions for the artifact. """ if not artifact_id: - artifact_id = str(uuid.uuid4()) + artifact_id = self._artifact_id_generator.generate( + IDGeneratorContext( + task_id=self.task_id, context_id=self.context_id + ) + ) await self.event_queue.enqueue_event( TaskArtifactUpdateEvent( @@ -205,7 +228,11 @@ def new_agent_message( role=Role.agent, task_id=self.task_id, context_id=self.context_id, - message_id=str(uuid.uuid4()), + message_id=self._message_id_generator.generate( + IDGeneratorContext( + task_id=self.task_id, context_id=self.context_id + ) + ), metadata=metadata, parts=parts, ) diff --git a/src/a2a/utils/task.py b/src/a2a/utils/task.py index 22556cde3..5c5f3f076 100644 --- a/src/a2a/utils/task.py +++ b/src/a2a/utils/task.py @@ -70,3 +70,25 @@ def completed_task( artifacts=artifacts, history=history, ) + + +def apply_history_length(task: Task, history_length: int | None) -> Task: + """Applies history_length parameter on task and returns a new task object. + + Args: + task: The original task object with complete history + history_length: History length configuration value + + Returns: + A new task object with limited history + """ + # Apply historyLength parameter if specified + if history_length is not None and task.history: + # Limit history to the most recent N messages + limited_history = ( + task.history[-history_length:] if history_length > 0 else [] + ) + # Create a new task instance with limited history + return task.model_copy(update={'history': limited_history}) + + return task diff --git a/tests/server/agent_execution/test_context.py b/tests/server/agent_execution/test_context.py index 5cecd8929..684aecb27 100644 --- a/tests/server/agent_execution/test_context.py +++ b/tests/server/agent_execution/test_context.py @@ -6,6 +6,7 @@ from a2a.server.agent_execution import RequestContext from a2a.server.context import ServerCallContext +from a2a.server.id_generator import IDGenerator from a2a.types import ( Message, MessageSendParams, @@ -149,6 +150,20 @@ def test_check_or_generate_task_id_with_existing_task_id(self, mock_params): assert context.task_id == existing_id assert mock_params.message.task_id == existing_id + def test_check_or_generate_task_id_with_custom_id_generator( + self, mock_params + ): + """Test _check_or_generate_task_id uses custom ID generator when provided.""" + id_generator = Mock(spec=IDGenerator) + id_generator.generate.return_value = 'custom-task-id' + + context = RequestContext( + request=mock_params, task_id_generator=id_generator + ) + # The method is called during initialization + + assert context.task_id == 'custom-task-id' + def test_check_or_generate_context_id_no_params(self): """Test _check_or_generate_context_id with no params does nothing.""" context = RequestContext() @@ -168,6 +183,20 @@ def test_check_or_generate_context_id_with_existing_context_id( assert context.context_id == existing_id assert mock_params.message.context_id == existing_id + def test_check_or_generate_context_id_with_custom_id_generator( + self, mock_params + ): + """Test _check_or_generate_context_id uses custom ID generator when provided.""" + id_generator = Mock(spec=IDGenerator) + id_generator.generate.return_value = 'custom-context-id' + + context = RequestContext( + request=mock_params, context_id_generator=id_generator + ) + # The method is called during initialization + + assert context.context_id == 'custom-context-id' + def test_init_raises_error_on_task_id_mismatch( self, mock_params, mock_task ): diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index 6765000c1..5268af115 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -836,6 +836,85 @@ async def test_on_message_send_non_blocking(): assert task.status.state == TaskState.completed +@pytest.mark.asyncio +async def test_on_message_send_limit_history(): + task_store = InMemoryTaskStore() + push_store = InMemoryPushNotificationConfigStore() + + request_handler = DefaultRequestHandler( + agent_executor=HelloAgentExecutor(), + task_store=task_store, + push_config_store=push_store, + ) + params = MessageSendParams( + message=Message( + role=Role.user, + message_id='msg_push', + parts=[Part(root=TextPart(text='Hi'))], + ), + configuration=MessageSendConfiguration( + blocking=True, + accepted_output_modes=['text/plain'], + history_length=0, + ), + ) + + result = await request_handler.on_message_send( + params, create_server_call_context() + ) + + # verify that history_length is honored + assert result is not None + assert isinstance(result, Task) + assert result.history is not None and len(result.history) == 0 + assert result.status.state == TaskState.completed + + # verify that history is still persisted to the store + task = await task_store.get(result.id) + assert task is not None + assert task.history is not None and len(task.history) > 0 + + +@pytest.mark.asyncio +async def test_on_task_get_limit_history(): + task_store = InMemoryTaskStore() + push_store = InMemoryPushNotificationConfigStore() + + request_handler = DefaultRequestHandler( + agent_executor=HelloAgentExecutor(), + task_store=task_store, + push_config_store=push_store, + ) + params = MessageSendParams( + message=Message( + role=Role.user, + message_id='msg_push', + parts=[Part(root=TextPart(text='Hi'))], + ), + configuration=MessageSendConfiguration( + blocking=True, accepted_output_modes=['text/plain'] + ), + ) + + result = await request_handler.on_message_send( + params, create_server_call_context() + ) + + assert result is not None + assert isinstance(result, Task) + + get_task_result = await request_handler.on_get_task( + TaskQueryParams(id=result.id, history_length=0), + create_server_call_context(), + ) + assert get_task_result is not None + assert isinstance(get_task_result, Task) + assert ( + get_task_result.history is not None + and len(get_task_result.history) == 0 + ) + + @pytest.mark.asyncio async def test_on_message_send_interrupted_flow(): """Test on_message_send when flow is interrupted (e.g., auth_required).""" diff --git a/tests/server/tasks/test_task_updater.py b/tests/server/tasks/test_task_updater.py index 844470cbe..a8de65e33 100644 --- a/tests/server/tasks/test_task_updater.py +++ b/tests/server/tasks/test_task_updater.py @@ -1,11 +1,12 @@ import asyncio import uuid -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from a2a.server.events import EventQueue +from a2a.server.id_generator import IDGenerator from a2a.server.tasks import TaskUpdater from a2a.types import ( Message, @@ -151,6 +152,26 @@ async def test_add_artifact_generates_id( assert event.last_chunk is None +@pytest.mark.asyncio +async def test_add_artifact_generates_custom_id(event_queue, sample_parts): + """Test add_artifact uses a custom ID generator when provided.""" + artifact_id_generator = Mock(spec=IDGenerator) + artifact_id_generator.generate.return_value = 'custom-artifact-id' + task_updater = TaskUpdater( + event_queue=event_queue, + task_id='test-task-id', + context_id='test-context-id', + artifact_id_generator=artifact_id_generator, + ) + + await task_updater.add_artifact(parts=sample_parts, artifact_id=None) + + event_queue.enqueue_event.assert_called_once() + event = event_queue.enqueue_event.call_args[0][0] + assert isinstance(event, TaskArtifactUpdateEvent) + assert event.artifact.artifact_id == 'custom-artifact-id' + + @pytest.mark.asyncio @pytest.mark.parametrize( 'append_val, last_chunk_val', @@ -304,6 +325,22 @@ def test_new_agent_message_with_metadata(task_updater, sample_parts): assert message.metadata == metadata +def test_new_agent_message_with_custom_id_generator(event_queue, sample_parts): + """Test creating a new agent message with a custom message ID generator.""" + message_id_generator = Mock(spec=IDGenerator) + message_id_generator.generate.return_value = 'custom-message-id' + task_updater = TaskUpdater( + event_queue=event_queue, + task_id='test-task-id', + context_id='test-context-id', + message_id_generator=message_id_generator, + ) + + message = task_updater.new_agent_message(parts=sample_parts) + + assert message.message_id == 'custom-message-id' + + @pytest.mark.asyncio async def test_failed_without_message(task_updater, event_queue): """Test marking a task as failed without a message."""