diff --git a/acts/sut-behaviors.yaml b/acts/sut-behaviors.yaml new file mode 100644 index 000000000..15971b9ea --- /dev/null +++ b/acts/sut-behaviors.yaml @@ -0,0 +1,106 @@ +# Which ACTS `tck-*` behaviours this SDK's ITK agent implements (ACTS §11.1). +# +# **This file is the list.** `itk/acts_behaviors.py` implements the behaviours +# but does not restate their names, so there is nothing here to drift out of +# step with — the two are a claim and its implementation, and the runner checks +# one against the other by running the tests. +# +# A prefix listed here but not implemented makes the test FAIL, not skip: +# deliberately, so lagging support stays visible in the conformance report +# instead of quietly shrinking it. Adding a behaviour means adding a branch in +# `_dispatch` and an entry here. + +acts_version: "1.0" + +behaviors: + - prefix: "tck-complete-task" + description: "Complete the task with a text response message" + response_type: task + terminal_state: TASK_STATE_COMPLETED + + - prefix: "tck-input-required" + description: "Return the task in INPUT_REQUIRED" + response_type: task + terminal_state: TASK_STATE_INPUT_REQUIRED + + - prefix: "tck-auth-required" + description: "Return the task in AUTH_REQUIRED" + response_type: task + terminal_state: TASK_STATE_AUTH_REQUIRED + + - prefix: "tck-reject-task" + description: "Reject the task" + response_type: task + terminal_state: TASK_STATE_REJECTED + + - prefix: "tck-task-failure" + description: "Complete with FAILED and an error message" + response_type: task + terminal_state: TASK_STATE_FAILED + + - prefix: "tck-message-response" + description: "Return a direct Message, not a Task" + response_type: message + + - prefix: "tck-multi-turn" + description: "Stay in INPUT_REQUIRED until the user sends 'done'" + response_type: task + terminal_state: TASK_STATE_COMPLETED + + - prefix: "tck-cancel" + description: "Remain in WORKING until canceled" + response_type: task + terminal_state: TASK_STATE_CANCELED + + - prefix: "tck-long-running" + description: "Stay in WORKING briefly, then complete" + response_type: task + terminal_state: TASK_STATE_COMPLETED + delay_ms: 1000 + + - prefix: "tck-artifact-text" + description: "Complete with a text artifact" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - text: "generated text content" + + - prefix: "tck-artifact-data" + description: "Complete with a structured data artifact" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - data: {key: "value", count: 1} + + - prefix: "tck-artifact-file" + description: "Complete with a file artifact carrying inline bytes" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - file: {name: "document.txt", mediaType: "text/plain"} + + - prefix: "tck-artifact-file-url" + description: "Complete with a file artifact carrying a URL" + response_type: task + terminal_state: TASK_STATE_COMPLETED + artifacts: + - fileUrl: + url: "https://example.com/document.txt" + name: "document.txt" + mediaType: "text/plain" + + - prefix: "tck-stream-basic" + description: "Stream working -> artifact -> completed" + response_type: task + terminal_state: TASK_STATE_COMPLETED + streaming: true + artifacts: + - text: "streamed content" + + - prefix: "tck-stream-chunked" + description: "Stream one artifact across several appended chunks" + response_type: task + terminal_state: TASK_STATE_COMPLETED + streaming: true + artifacts: + - text: "chunk one chunk two chunk three" diff --git a/itk/acts_behaviors.py b/itk/acts_behaviors.py new file mode 100644 index 000000000..391bb8402 --- /dev/null +++ b/itk/acts_behaviors.py @@ -0,0 +1,358 @@ +"""The ACTS SUT behaviour contract (ACTS spec §11) for the ITK agent. + +ACTS tests are declarative — they say what to send and what to expect — so the +agent under test has to produce a *deterministic* reply for each case. §11 +does that with a message-prefix convention rather than a side-channel API: the +text of the first user message part names the behaviour, and the agent obeys. + +This module owns that mapping. `itk/main.py` routes to it when the first user +message starts with ``tck-``, and otherwise falls through to the existing ITK +instruction path, so one agent binary serves both suites. + +**The behaviour is a property of the task, not of the message.** A multi-turn +test opens with ``tck-multi-turn start`` and then sends plain ``here is more +input`` and ``done``; only the first message names the contract, so a +continuation has to recover it from the task's history. :func:`behavior_for` +handles both. + +**`acts/sut-behaviors.yaml` is the list; this module is the implementation.** +They are separate on purpose — the YAML is what the SDK *claims*, the code is +what it *does*, and the runner checks one against the other by running tests. +So nothing here re-states the list: the name is read straight out of the +message with a regex, and a prefix that reaches :func:`_dispatch` without a +branch fails the task loudly. Keeping a second copy of the names here would +add a way for the claim and the behaviour to drift silently, which is the one +thing the split exists to prevent. + +That regex is greedy to the word boundary, which gives longest-match for +free: `tck-artifact-file-url` beats `tck-artifact-file` without an ordered +table, and a typo like `tck-complet-task` is reported as an unimplemented +behaviour instead of falling through to the ITK path and failing there with +"no valid instruction". +""" + +from __future__ import annotations + +import asyncio +import logging +import re +import uuid +from typing import TYPE_CHECKING, Any + +from google.protobuf import json_format +from google.protobuf.struct_pb2 import Struct, Value + +import acts_client_parse + +from a2a.server.tasks import TaskUpdater +from a2a.types.a2a_pb2 import Message, Part, Task, TaskState, TaskStatus + + +if TYPE_CHECKING: + from a2a.server.agent_execution import RequestContext + from a2a.server.events import EventQueue + + +logger = logging.getLogger(__name__) + +#: Every behaviour name starts with this, and it is what tells the agent an +#: incoming message belongs to ACTS rather than to an ITK traversal. +PREFIX = 'tck-' + +#: The word a multi-turn conversation ends on. Fixed by the corpus, which +#: sends exactly this to close `CORE-MULTI-001`, `CORE-MULTI-005` and +#: `CORE-HIST-002`. +MULTI_TURN_DONE = 'done' + +#: How long `tck-long-running` stays in WORKING before completing. Short +#: enough not to dominate a run, long enough that a test polling for a +#: non-terminal state sees one: the corpus polls with `delay_ms: 2000` and +#: `max_attempts: 15`. +LONG_RUNNING_DELAY_S = 1.0 + +#: A behaviour name: `tck-` and one or more hyphen-joined lowercase words. +#: Greedy, so it stops at the first character that cannot be part of a name — +#: which is what makes `tck-artifact-file-url document` yield the full name +#: rather than `tck-artifact-file`. +_NAME = re.compile(r'^(tck-[a-z0-9]+(?:-[a-z0-9]+)*)') + + +def _first_text(message: Message | None) -> str: + """The first text part of a message, or ``''``.""" + if message is None: + return '' + for part in message.parts: + if part.text: + return part.text + return '' + + +def behavior_in(text: str) -> str | None: + """The behaviour named by ``text``, or ``None``. + + Names an *asserted* behaviour, not necessarily an implemented one: an + unknown `tck-*` still routes here, and :func:`_dispatch` reports it as + unimplemented. That is the honest outcome — the alternative is a message + plainly meant for ACTS being handed to the traversal decoder. + """ + match = _NAME.match(text.strip()) + return match.group(1) if match else None + + +def behavior_for(context: RequestContext) -> str | None: + """The behaviour this request belongs to, current message or task. + + A continuation turn carries no prefix, so when the incoming message names + none, the task's own history is consulted — its first user message is + where the contract was declared. + """ + named = behavior_in(_first_text(context.message)) + if named is not None: + return named + + task = context.current_task + if task is None: + return None + for historical in task.history: + found = behavior_in(_first_text(historical)) + if found is not None: + return found + return None + + +def is_acts_request(context: RequestContext) -> bool: + """Should this request be served by the ACTS contract rather than ITK?""" + return behavior_for(context) is not None + + +def _data_part(payload: dict[str, Any]) -> Part: + """A data part. `Part.data` is a `Value`, not a `Struct`.""" + struct = Struct() + struct.update(payload) + return Part(data=Value(struct_value=struct)) + + +async def run( + behavior: str, + context: RequestContext, + event_queue: EventQueue, +) -> None: + """Serve one ACTS request, start to finish. + + Owns the task lifecycle rather than being handed a live task, because + `tck-message-response` must produce **no** task at all — A2A lets an agent + answer with a bare `Message`, and a server that opened a task first would + make the response a task update instead, which is the opposite of what + `CORE-SEND-003` checks. + """ + logger.info('ACTS behaviour %s on task %s', behavior, context.task_id) + + if behavior == 'tck-message-response': + await event_queue.enqueue_event( + Message( + role='ROLE_AGENT', + message_id=str(uuid.uuid4()), + context_id=context.context_id or '', + parts=[Part(text='tck message response')], + ) + ) + return + + updater = TaskUpdater(event_queue, context.task_id, context.context_id) + + # A continuation turn already has a task; re-announcing it would emit a + # second submitted event for the same id. + if context.current_task is None: + task = Task( + id=context.task_id, + context_id=context.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), + history=[context.message] if context.message else [], + ) + async with updater._lock: # noqa: SLF001 + await event_queue.enqueue_event(task) + + await updater.update_status(TaskState.TASK_STATE_WORKING) + await _dispatch(behavior, context, updater) + + +async def _dispatch( + behavior: str, + context: RequestContext, + updater: TaskUpdater, +) -> None: + """Take an already-working task to wherever the behaviour ends. + + Unknown behaviours fail the task loudly rather than completing it — a + silent success would report conformance the agent never demonstrated. + """ + if behavior == acts_client_parse.BEHAVIOR: + await _client_parse(context, updater) + return + + if behavior == 'tck-multi-turn': + await _multi_turn(context, updater) + return + + if behavior == 'tck-cancel': + # Hold in WORKING. The framework cancels the executor task when a + # CancelTask arrives, which surfaces here as CancelledError. + try: + while True: + await asyncio.sleep(0.2) + except asyncio.CancelledError: + logger.info('tck-cancel: task %s canceled', context.task_id) + raise + return + + if behavior == 'tck-long-running': + await asyncio.sleep(LONG_RUNNING_DELAY_S) + # `CORE-EXEC-001` polls to completion and then asserts the finished + # task carries at least one artifact, so the work has to leave one + # behind even though §11.2 describes this behaviour only as "delayed + # completion". + await updater.add_artifact( + [Part(text='long running result')], + name='long-running', + last_chunk=True, + ) + await updater.complete( + updater.new_agent_message([Part(text='long running work finished')]) + ) + return + + if behavior in ('tck-stream-basic', 'tck-stream-chunked'): + await _stream(behavior, updater) + return + + if behavior.startswith('tck-artifact-'): + await _artifact(behavior, updater) + return + + terminal = { + 'tck-complete-task': updater.complete, + 'tck-task-failure': updater.failed, + 'tck-reject-task': updater.reject, + 'tck-input-required': updater.requires_input, + 'tck-auth-required': updater.requires_auth, + }.get(behavior) + + if terminal is None: + await updater.failed( + updater.new_agent_message( + [Part(text=f'unimplemented ACTS behaviour {behavior!r}')] + ) + ) + return + + await terminal(updater.new_agent_message([Part(text=f'{behavior} ok')])) + + +async def _client_parse(context: RequestContext, updater: TaskUpdater) -> None: + """ACTS §10: run a canonical wire payload through this SDK's own client. + + The request carries `{operation, wire_payload}` in a data part; the reply + carries whatever the client parsed, in the same data-part shape, so the + runner can assert `expect_parsed` against it exactly as it would + `expect.body`. + """ + request = None + for part in (context.message.parts if context.message else ()): + if part.HasField('data'): + request = acts_client_parse.request_from( + json_format.MessageToDict(part.data) + if hasattr(part.data, 'DESCRIPTOR') + else part.data + ) + if request is not None: + break + + if request is None: + await updater.failed( + updater.new_agent_message( + [Part(text='tck-client-parse needs {operation, wire_payload}')] + ) + ) + return + + operation, payload = request + parsed = await acts_client_parse.parse(operation, payload) + await updater.add_artifact( + [_data_part(parsed)], name=acts_client_parse.BEHAVIOR, last_chunk=True + ) + await updater.complete( + updater.new_agent_message([Part(text=f'{operation} parsed')]) + ) + + +async def _multi_turn(context: RequestContext, updater: TaskUpdater) -> None: + """INPUT_REQUIRED until the user says `done`, then COMPLETED.""" + said = _first_text(context.message).strip().lower() + if said.startswith(MULTI_TURN_DONE): + await updater.complete( + updater.new_agent_message([Part(text='multi-turn complete')]) + ) + return + await updater.requires_input( + updater.new_agent_message([Part(text='more input please')]) + ) + + +async def _artifact(behavior: str, updater: TaskUpdater) -> None: + """Complete with the artifact shape the behaviour names.""" + parts = { + 'tck-artifact-text': [Part(text='generated text content')], + 'tck-artifact-data': [_data_part({'key': 'value', 'count': 1})], + 'tck-artifact-file': [ + Part( + raw=b'file bytes', + filename='document.txt', + media_type='text/plain', + ) + ], + 'tck-artifact-file-url': [ + Part( + url='https://example.com/document.txt', + filename='document.txt', + media_type='text/plain', + ) + ], + }[behavior] + + await updater.add_artifact(parts, name=behavior, last_chunk=True) + await updater.complete( + updater.new_agent_message([Part(text=f'{behavior} ok')]) + ) + + +async def _stream(behavior: str, updater: TaskUpdater) -> None: + """working -> artifact(s) -> completed, as separate events. + + Emitted through the updater so each step is its own queue event, which is + what makes them separate SSE frames — a single combined update would + satisfy `min_count` only by accident. + """ + await updater.update_status( + TaskState.TASK_STATE_WORKING, + message=updater.new_agent_message([Part(text='streaming started')]), + ) + + if behavior == 'tck-stream-chunked': + artifact_id = f'{updater.task_id}-chunked' + chunks = ['chunk one ', 'chunk two ', 'chunk three'] + for index, chunk in enumerate(chunks): + await updater.add_artifact( + [Part(text=chunk)], + artifact_id=artifact_id, + name='chunked', + append=index > 0, + last_chunk=index == len(chunks) - 1, + ) + else: + await updater.add_artifact( + [Part(text='streamed content')], name='streamed', last_chunk=True + ) + + await updater.complete( + updater.new_agent_message([Part(text=f'{behavior} ok')]) + ) diff --git a/itk/acts_client_parse.py b/itk/acts_client_parse.py new file mode 100644 index 000000000..55698528c --- /dev/null +++ b/itk/acts_client_parse.py @@ -0,0 +1,232 @@ +"""The `tck-client-parse` behaviour: ACTS §10 client tests. + +Every other ACTS step drives the SUT as a **server** — send bytes, assert on +what comes back. A client test inverts that: it supplies a canonical wire +payload and asks whether *this SDK's client* parses it correctly, which no A2A +operation can ask a server. §10 defines the file format and says nothing about +the mechanism, so the runner cannot reach the client at all and skips the +eight `CLIENT-*` tests. + +This closes that gap from the agent side. The runner sends an ordinary +`send_message` naming `tck-client-parse` with `{operation, wire_payload}` in a +data part; the agent builds a real SDK client whose HTTP transport returns +that payload verbatim, performs the operation, and hands back whatever its own +client produced. + +**A mock transport rather than a bare deserializer.** Calling +`SendMessageResponse.FromJson(...)` directly would be far less code and would +prove much less: it skips the JSON-RPC envelope, the error mapping and the +response plumbing, which is most of what a client test is about. +`CLIENT-PARSE-004` makes that concrete — it feeds a JSON-RPC *error* envelope +and expects the client to surface `{error: {code, message}}`. Unwrapping that +by hand in the agent would be reimplementing the code under test. + +The reply is shaped like a dispatcher's `payload` — the §4.2 assertion root +for the operation — so `expect_parsed` reads exactly like `expect.body` and +the runner needs no special assertion path. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +import httpx + +from a2a.client import ClientConfig, create_client +from a2a.client.card_resolver import A2ACardResolver +from a2a.types.a2a_pb2 import ( + AgentCapabilities, + AgentCard, + AgentInterface, + GetExtendedAgentCardRequest, + GetTaskRequest, + Message, + SendMessageRequest, +) +from a2a.utils import TransportProtocol +from google.protobuf.json_format import MessageToDict + + +logger = logging.getLogger(__name__) + +BEHAVIOR = 'tck-client-parse' + +#: Where the fake server lives. Nothing dials it — the mock transport answers +#: before a socket is opened — but the client needs a syntactically valid base. +_BASE_URL = 'http://acts-client-parse.invalid' + +_CARD_PATH = '/.well-known/agent-card.json' +_EXTENDED_CARD_PATH = '/extendedAgentCard' + + +def _is_enveloped(payload: Any) -> bool: + """Is this payload a JSON-RPC envelope rather than a bare object?""" + return isinstance(payload, dict) and ( + 'jsonrpc' in payload or 'result' in payload or 'error' in payload + ) + + +def _transport(payload: Any) -> httpx.MockTransport: + """An HTTP transport that answers every request with ``payload``. + + The response's JSON-RPC ``id`` is rewritten to echo the request's, which + is what a real server does. The corpus's canned payloads carry a fixed id + (``req-001``, ``1``, …) that cannot match one the client invented at call + time, so a client validating the correlation — as JSON-RPC 2.0 requires — + rejects the payload before parsing any of it. Echoing keeps the test about + parsing rather than about a correlation the canned-payload model cannot + express. + """ + + def handler(request: httpx.Request) -> httpx.Response: + body = payload + if _is_enveloped(payload): + try: + sent = json.loads(request.content) + except ValueError: + sent = None + if isinstance(sent, dict) and 'id' in sent: + body = {**payload, 'id': sent['id']} + return httpx.Response(200, json=body) + + return httpx.MockTransport(handler) + + +def _scaffold_card() -> AgentCard: + """A minimal card advertising JSON-RPC, so `create_client` can bind. + + A client needs a card before it will talk to anything. Passing the card + object rather than a URL is what keeps the mock transport free to answer + with the payload under test: were the client to resolve a card over that + transport it would be handed the `wire_payload` instead. + + JSON-RPC because that is the binding every non-card `wire_payload` in the + corpus is written in. + """ + return AgentCard( + name='acts-client-parse', + version='1.0.0', + capabilities=AgentCapabilities(streaming=False, extended_agent_card=True), + supported_interfaces=[ + AgentInterface( + url=_BASE_URL, + protocol_binding='JSONRPC', + protocol_version='1.0', + ) + ], + default_input_modes=['text/plain'], + default_output_modes=['text/plain'], + ) + + +async def _client(payload: Any) -> Any: + config = ClientConfig() + config.streaming = False + config.supported_protocol_bindings = [TransportProtocol.JSONRPC] + config.httpx_client = httpx.AsyncClient(transport=_transport(payload)) + return await create_client(_scaffold_card(), client_config=config) + + +async def _parse_card(payload: Any, path: str) -> dict[str, Any]: + """Run a bare card payload through the SDK's own card handling. + + Both card operations land here when the payload is a bare card, which is + how the corpus writes them — and correctly so: a card is fetched over + plain HTTP on every binding, so there is no envelope to unwrap. The two + differ only in the path they are served from. + """ + async with httpx.AsyncClient(transport=_transport(payload)) as http: + resolver = A2ACardResolver(httpx_client=http, base_url=_BASE_URL) + card = await resolver.get_agent_card(relative_card_path=path) + return MessageToDict(card) + + +async def _parse_extended_card_envelope(payload: Any) -> dict[str, Any]: + """The enveloped form of `get_extended_agent_card`, via the RPC client.""" + async with httpx.AsyncClient(transport=_transport(payload)) as http: + config = ClientConfig() + config.streaming = False + config.supported_protocol_bindings = [TransportProtocol.JSONRPC] + config.httpx_client = http + client = await create_client(_scaffold_card(), client_config=config) + try: + card = await client.get_extended_agent_card( + GetExtendedAgentCardRequest() + ) + finally: + await client.close() + return MessageToDict(card) + + +async def parse(operation: str, payload: Any) -> dict[str, Any]: + """Feed ``payload`` to this SDK's client and return what it produced. + + The result is the §4.2 assertion root for ``operation``: a + `SendMessageResponse` keeps its `task`/`message` discriminator, `get_task` + returns the Task's own fields, and a card operation returns the card. + + An error the client raises comes back as ``{'error': {...}}`` rather than + propagating, because for `CLIENT-PARSE-004` that *is* the expected parse. + """ + try: + if operation == 'get_agent_card': + return await _parse_card(payload, _CARD_PATH) + if operation == 'get_extended_agent_card': + # The corpus writes this one as a bare card, matching the wire: + # `supportedInterfaces` on its own payload names REST, where the + # extended card is a plain GET. Accept an envelope too, since a + # JSON-RPC binding does wrap it. + if _is_enveloped(payload): + return await _parse_extended_card_envelope(payload) + return await _parse_card(payload, _EXTENDED_CARD_PATH) + + client = await _client(payload) + try: + if operation == 'send_message': + async for event in client.send_message( + SendMessageRequest( + message=Message(role='ROLE_USER', message_id='acts') + ) + ): + # StreamResponse keeps the oneof, which is exactly the + # discriminator `expect_parsed: {task: ...}` addresses. + return MessageToDict(event) + return {} + if operation == 'get_task': + task = await client.get_task(GetTaskRequest(id='acts')) + return MessageToDict(task) + finally: + await client.close() + + return {'error': {'message': f'unsupported client operation {operation!r}'}} + except Exception as exc: # noqa: BLE001 - the error IS the parse result + return _as_error(exc, payload) + + +def _as_error(exc: Exception, payload: Any) -> dict[str, Any]: + """Render a client-raised error the way `expect_parsed` addresses it. + + `CLIENT-PARSE-004` asserts `error.code` and `error.message`. An SDK error + object does not necessarily carry the JSON-RPC code, so the envelope's own + `error` is preferred when the payload had one — the assertion is about the + client having surfaced *that* error, and inventing a code here would pass + the test without the client having done anything. + """ + if isinstance(payload, dict) and isinstance(payload.get('error'), dict): + return {'error': dict(payload['error']), 'raised': type(exc).__name__} + return {'error': {'message': str(exc)}, 'raised': type(exc).__name__} + + +def request_from(part_data: Any) -> tuple[str, Any] | None: + """Read `{operation, wire_payload}` out of the step's data part.""" + if not isinstance(part_data, dict): + return None + operation = part_data.get('operation') + if not isinstance(operation, str): + return None + return operation, part_data.get('wire_payload') + + +__all__ = ['BEHAVIOR', 'parse', 'request_from'] diff --git a/itk/main.py b/itk/main.py index b27cd3c71..c938f9666 100644 --- a/itk/main.py +++ b/itk/main.py @@ -15,6 +15,8 @@ from pyproto import instruction_pb2 +import acts_behaviors + from a2a.client import Client, ClientConfig, create_client from a2a.client.errors import A2AClientError from a2a.compat.v0_3 import a2a_v0_3_pb2_grpc @@ -40,6 +42,7 @@ AgentCapabilities, AgentCard, AgentInterface, + AgentSkill, CancelTaskRequest, Message, Part, @@ -352,6 +355,17 @@ async def execute( ) -> None: """Executes a task instruction.""" logger.info('Executing task %s', context.task_id) + + # Dual mode. An ACTS conformance test names a `tck-*` behaviour in its + # first user message (ACTS §11); anything else is an ITK traversal + # carrying a protobuf Instruction. The branch is taken before any task + # is created, because one ACTS behaviour must answer with a bare + # Message and so must not open a task at all. + behavior = acts_behaviors.behavior_for(context) + if behavior is not None: + await acts_behaviors.run(behavior, context, event_queue) + return + task_updater = TaskUpdater( event_queue, context.task_id, @@ -495,10 +509,25 @@ async def main_async(http_port: int, grpc_port: int) -> None: name='ITK v10 Agent', description='Python agent using SDK 1.0.', version='1.0.0', - capabilities=AgentCapabilities(streaming=True), + # ACTS evaluates a test's `preconditions` against this card and skips + # when they are unmet (ACTS §12.5), so anything the agent really does + # has to be advertised or the matching tests silently never run. + capabilities=AgentCapabilities( + streaming=True, + push_notifications=True, + extended_agent_card=True, + ), default_input_modes=['text/plain'], default_output_modes=['text/plain'], supported_interfaces=interfaces, + skills=[ + AgentSkill( + id='acts-behaviors', + name='ACTS behaviours', + description='Implements the ACTS §11 tck-* behaviour contract.', + tags=['acts', 'conformance'], + ) + ], ) task_store = InMemoryTaskStore() @@ -509,6 +538,12 @@ async def main_async(http_port: int, grpc_port: int) -> None: config_store=push_config_store, ) + # One handler for every binding. It carries `extended_agent_card` because + # the card advertises `extendedAgentCard: true`, and a capability is + # advertised per agent, not per binding — configuring it on JSON-RPC alone + # made `Get Extended Agent Card` answer with the card over JSON-RPC and + # `ExtendedAgentCardNotConfiguredError` over gRPC and REST, from an agent + # claiming the capability once for all three. handler = DefaultRequestHandler( agent_executor=V10AgentExecutor(), agent_card=agent_card, @@ -516,15 +551,6 @@ async def main_async(http_port: int, grpc_port: int) -> None: queue_manager=InMemoryQueueManager(), push_config_store=push_config_store, push_sender=push_sender, - ) - - handler_extended = DefaultRequestHandler( - agent_executor=V10AgentExecutor(), - agent_card=agent_card, - task_store=task_store, - queue_manager=InMemoryQueueManager(), - push_config_store=push_config_store, - push_sender=push_sender, extended_agent_card=agent_card, ) @@ -532,7 +558,7 @@ async def main_async(http_port: int, grpc_port: int) -> None: agent_card=agent_card, card_url='/.well-known/agent-card.json' ) jsonrpc_routes = create_jsonrpc_routes( - request_handler=handler_extended, + request_handler=handler, rpc_url='/', enable_v0_3_compat=True, )