spakky-a2a 6.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. spakky_a2a-6.10.0/PKG-INFO +108 -0
  2. spakky_a2a-6.10.0/README.md +92 -0
  3. spakky_a2a-6.10.0/pyproject.toml +91 -0
  4. spakky_a2a-6.10.0/src/spakky/plugins/a2a/__init__.py +31 -0
  5. spakky_a2a-6.10.0/src/spakky/plugins/a2a/card/__init__.py +1 -0
  6. spakky_a2a-6.10.0/src/spakky/plugins/a2a/card/derivation.py +123 -0
  7. spakky_a2a-6.10.0/src/spakky/plugins/a2a/client.py +127 -0
  8. spakky_a2a-6.10.0/src/spakky/plugins/a2a/config.py +35 -0
  9. spakky_a2a-6.10.0/src/spakky/plugins/a2a/delegation.py +271 -0
  10. spakky_a2a-6.10.0/src/spakky/plugins/a2a/error.py +85 -0
  11. spakky_a2a-6.10.0/src/spakky/plugins/a2a/executor/__init__.py +1 -0
  12. spakky_a2a-6.10.0/src/spakky/plugins/a2a/executor/adapter.py +252 -0
  13. spakky_a2a-6.10.0/src/spakky/plugins/a2a/executor/event_mapping.py +276 -0
  14. spakky_a2a-6.10.0/src/spakky/plugins/a2a/grpc_transport/__init__.py +13 -0
  15. spakky_a2a-6.10.0/src/spakky/plugins/a2a/grpc_transport/builder.py +38 -0
  16. spakky_a2a-6.10.0/src/spakky/plugins/a2a/grpc_transport/handler.py +160 -0
  17. spakky_a2a-6.10.0/src/spakky/plugins/a2a/main.py +27 -0
  18. spakky_a2a-6.10.0/src/spakky/plugins/a2a/post_processors/__init__.py +1 -0
  19. spakky_a2a-6.10.0/src/spakky/plugins/a2a/post_processors/register_agent_servers.py +55 -0
  20. spakky_a2a-6.10.0/src/spakky/plugins/a2a/py.typed +0 -0
  21. spakky_a2a-6.10.0/src/spakky/plugins/a2a/rest_transport/__init__.py +5 -0
  22. spakky_a2a-6.10.0/src/spakky/plugins/a2a/rest_transport/builder.py +58 -0
  23. spakky_a2a-6.10.0/src/spakky/plugins/a2a/server/__init__.py +1 -0
  24. spakky_a2a-6.10.0/src/spakky/plugins/a2a/server/builder.py +92 -0
  25. spakky_a2a-6.10.0/src/spakky/plugins/a2a/server/registry.py +56 -0
  26. spakky_a2a-6.10.0/src/spakky/plugins/a2a/server/request_handler.py +41 -0
  27. spakky_a2a-6.10.0/src/spakky/plugins/a2a/stereotypes/__init__.py +1 -0
  28. spakky_a2a-6.10.0/src/spakky/plugins/a2a/stereotypes/a2a_agent_server.py +27 -0
  29. spakky_a2a-6.10.0/src/spakky/plugins/a2a/store/__init__.py +1 -0
  30. spakky_a2a-6.10.0/src/spakky/plugins/a2a/store/interfaces.py +36 -0
  31. spakky_a2a-6.10.0/src/spakky/plugins/a2a/store/task_store.py +81 -0
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.3
2
+ Name: spakky-a2a
3
+ Version: 6.10.0
4
+ Summary: A2A (Agent2Agent) protocol server plugin for Spakky framework
5
+ Author: Spakky
6
+ Author-email: Spakky <sejong418@icloud.com>
7
+ License: MIT
8
+ Requires-Dist: a2a-sdk[http-server]>=1.1.0
9
+ Requires-Dist: grpcio>=1.68.0
10
+ Requires-Dist: pydantic>=2.4
11
+ Requires-Dist: pydantic-settings>=2.13.1
12
+ Requires-Dist: spakky>=6.10.0
13
+ Requires-Dist: spakky-agent>=6.10.0
14
+ Requires-Python: >=3.12
15
+ Description-Content-Type: text/markdown
16
+
17
+ # spakky-a2a
18
+
19
+ A2A (Agent2Agent) protocol server plugin for the Spakky framework.
20
+
21
+ ## Remote teammate delegation
22
+
23
+ `A2AAgentDelegate` implements the core `IAgentDelegate` port for teammates whose
24
+ `AgentExecutionSpec.teammates` entry points at a remote AgentCard URL. The core
25
+ agent runner exposes each teammate as a model-callable delegation tool named
26
+ `teammate.<name>.delegate`; local teammate pods run in-process, while remote
27
+ teammates use the official `a2a-sdk` client.
28
+
29
+ ```python
30
+ from spakky.agent import Agent, AgentExecutionSpec, AgentTeammate
31
+ from spakky.plugins.a2a import A2AAgentDelegate
32
+
33
+
34
+ @Agent(
35
+ spec=AgentExecutionSpec(
36
+ name="orchestrator",
37
+ teammates=(
38
+ AgentTeammate(
39
+ name="researcher",
40
+ card_url="https://agents.example.com/.well-known/agent-card.json",
41
+ ),
42
+ ),
43
+ )
44
+ )
45
+ class Orchestrator:
46
+ def __init__(self, delegate: A2AAgentDelegate) -> None:
47
+ self._delegate = delegate
48
+ ```
49
+
50
+ Remote delegation sends `message/send` through the SDK client, tracks the remote
51
+ task stream, and maps child task/message/artifact updates back into Spakky's
52
+ protocol-neutral event stream with the parent run id preserved.
53
+
54
+ ## REST HTTP+JSON transport
55
+
56
+ `build_a2a_rest_app()` builds a mountable Starlette app for the official A2A
57
+ HTTP+JSON binding. The transport reuses the same AgentCard derivation,
58
+ `TaskStore`, `SpakkyAgentExecutor`, and neutral agent-event projection used by
59
+ the JSON-RPC and gRPC transports.
60
+
61
+ ```python
62
+ from spakky.plugins.a2a.rest_transport import build_a2a_rest_app
63
+
64
+ app = build_a2a_rest_app(
65
+ assistant_agent,
66
+ base_url="https://agents.example.com/a2a",
67
+ version="1.0.0",
68
+ )
69
+ ```
70
+
71
+ The SDK route names differ from JSON-RPC method strings:
72
+
73
+ | A2A operation | REST route |
74
+ |---------------|------------|
75
+ | `message/send` | `POST /message:send` |
76
+ | `message/stream` | `POST /message:stream` |
77
+ | `tasks/get` | `GET /tasks/{id}` |
78
+ | `tasks/cancel` | `POST /tasks/{id}:cancel` |
79
+ | `tasks/subscribe` | `GET /tasks/{id}:subscribe` or `POST /tasks/{id}:subscribe` |
80
+
81
+ REST request and response bodies use the A2A SDK protobuf JSON encoding. For
82
+ example, send a user message with `{"message":{"role":"ROLE_USER","messageId":"m1","parts":[{"text":"hi"}]}}`.
83
+
84
+ ## HITL and auth interrupts
85
+
86
+ `SpakkyAgentExecutor` consumes the core `AgentRunner.run_events()` stream. Approval
87
+ and auth pauses arrive as protocol-neutral `RunPausedEvent` items rather than as
88
+ successful `RunFinishedEvent` terminals. The A2A projector maps
89
+ `reason=approval_required` to `TASK_STATE_INPUT_REQUIRED` and includes the
90
+ approval id plus allowed decisions in a data part. It maps `reason=auth_required`
91
+ to `TASK_STATE_AUTH_REQUIRED`, so auth-required is reachable without inspecting
92
+ durable `state.reason` after the run stream drains.
93
+
94
+ ## gRPC transport
95
+
96
+ `build_a2a_grpc_handler()` builds a `grpc.GenericRpcHandler` for the official
97
+ `lf.a2a.v1.A2AService` descriptor. The transport exposes:
98
+
99
+ - `SendMessage`
100
+ - `SendStreamingMessage`
101
+ - `GetTask`
102
+ - `CancelTask`
103
+
104
+ The gRPC handler reuses the same AgentCard derivation, `TaskStore`,
105
+ `SpakkyAgentExecutor`, and neutral agent-event projection used by the JSON-RPC
106
+ transport. Add the handler to a `spakky-grpc` `GrpcServerSpec` or another
107
+ `grpc.aio.Server`, then call `/lf.a2a.v1.A2AService/<Method>` with the protobuf
108
+ message classes provided by `a2a-sdk`.
@@ -0,0 +1,92 @@
1
+ # spakky-a2a
2
+
3
+ A2A (Agent2Agent) protocol server plugin for the Spakky framework.
4
+
5
+ ## Remote teammate delegation
6
+
7
+ `A2AAgentDelegate` implements the core `IAgentDelegate` port for teammates whose
8
+ `AgentExecutionSpec.teammates` entry points at a remote AgentCard URL. The core
9
+ agent runner exposes each teammate as a model-callable delegation tool named
10
+ `teammate.<name>.delegate`; local teammate pods run in-process, while remote
11
+ teammates use the official `a2a-sdk` client.
12
+
13
+ ```python
14
+ from spakky.agent import Agent, AgentExecutionSpec, AgentTeammate
15
+ from spakky.plugins.a2a import A2AAgentDelegate
16
+
17
+
18
+ @Agent(
19
+ spec=AgentExecutionSpec(
20
+ name="orchestrator",
21
+ teammates=(
22
+ AgentTeammate(
23
+ name="researcher",
24
+ card_url="https://agents.example.com/.well-known/agent-card.json",
25
+ ),
26
+ ),
27
+ )
28
+ )
29
+ class Orchestrator:
30
+ def __init__(self, delegate: A2AAgentDelegate) -> None:
31
+ self._delegate = delegate
32
+ ```
33
+
34
+ Remote delegation sends `message/send` through the SDK client, tracks the remote
35
+ task stream, and maps child task/message/artifact updates back into Spakky's
36
+ protocol-neutral event stream with the parent run id preserved.
37
+
38
+ ## REST HTTP+JSON transport
39
+
40
+ `build_a2a_rest_app()` builds a mountable Starlette app for the official A2A
41
+ HTTP+JSON binding. The transport reuses the same AgentCard derivation,
42
+ `TaskStore`, `SpakkyAgentExecutor`, and neutral agent-event projection used by
43
+ the JSON-RPC and gRPC transports.
44
+
45
+ ```python
46
+ from spakky.plugins.a2a.rest_transport import build_a2a_rest_app
47
+
48
+ app = build_a2a_rest_app(
49
+ assistant_agent,
50
+ base_url="https://agents.example.com/a2a",
51
+ version="1.0.0",
52
+ )
53
+ ```
54
+
55
+ The SDK route names differ from JSON-RPC method strings:
56
+
57
+ | A2A operation | REST route |
58
+ |---------------|------------|
59
+ | `message/send` | `POST /message:send` |
60
+ | `message/stream` | `POST /message:stream` |
61
+ | `tasks/get` | `GET /tasks/{id}` |
62
+ | `tasks/cancel` | `POST /tasks/{id}:cancel` |
63
+ | `tasks/subscribe` | `GET /tasks/{id}:subscribe` or `POST /tasks/{id}:subscribe` |
64
+
65
+ REST request and response bodies use the A2A SDK protobuf JSON encoding. For
66
+ example, send a user message with `{"message":{"role":"ROLE_USER","messageId":"m1","parts":[{"text":"hi"}]}}`.
67
+
68
+ ## HITL and auth interrupts
69
+
70
+ `SpakkyAgentExecutor` consumes the core `AgentRunner.run_events()` stream. Approval
71
+ and auth pauses arrive as protocol-neutral `RunPausedEvent` items rather than as
72
+ successful `RunFinishedEvent` terminals. The A2A projector maps
73
+ `reason=approval_required` to `TASK_STATE_INPUT_REQUIRED` and includes the
74
+ approval id plus allowed decisions in a data part. It maps `reason=auth_required`
75
+ to `TASK_STATE_AUTH_REQUIRED`, so auth-required is reachable without inspecting
76
+ durable `state.reason` after the run stream drains.
77
+
78
+ ## gRPC transport
79
+
80
+ `build_a2a_grpc_handler()` builds a `grpc.GenericRpcHandler` for the official
81
+ `lf.a2a.v1.A2AService` descriptor. The transport exposes:
82
+
83
+ - `SendMessage`
84
+ - `SendStreamingMessage`
85
+ - `GetTask`
86
+ - `CancelTask`
87
+
88
+ The gRPC handler reuses the same AgentCard derivation, `TaskStore`,
89
+ `SpakkyAgentExecutor`, and neutral agent-event projection used by the JSON-RPC
90
+ transport. Add the handler to a `spakky-grpc` `GrpcServerSpec` or another
91
+ `grpc.aio.Server`, then call `/lf.a2a.v1.A2AService/<Method>` with the protobuf
92
+ message classes provided by `a2a-sdk`.
@@ -0,0 +1,91 @@
1
+ [project]
2
+ name = "spakky-a2a"
3
+ version = "6.10.0"
4
+ description = "A2A (Agent2Agent) protocol server plugin for Spakky framework"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Spakky", email = "sejong418@icloud.com" }]
9
+ dependencies = [
10
+ "a2a-sdk[http-server]>=1.1.0",
11
+ "grpcio>=1.68.0",
12
+ "pydantic>=2.4",
13
+ "pydantic-settings>=2.13.1",
14
+ "spakky>=6.10.0",
15
+ "spakky-agent>=6.10.0",
16
+ ]
17
+
18
+ [project.entry-points."spakky.plugins"]
19
+ spakky-a2a = "spakky.plugins.a2a.main:initialize"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest-asyncio>=1.3.0",
24
+ "pytest-integration-mark>=0.2.0",
25
+ "httpx>=0.28.0",
26
+ "spakky-grpc>=6.9.1",
27
+ "types-grpcio>=1.0.0.20260614",
28
+ "types-protobuf>=7.34.1.20260518",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.10.10,<0.12.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.uv.build-backend]
36
+ module-root = "src"
37
+ module-name = "spakky.plugins.a2a"
38
+
39
+ [tool.pyrefly]
40
+ python-version = "3.12"
41
+ search_path = ["src", "."]
42
+ project_excludes = ["**/__pycache__", "**/*.pyc"]
43
+
44
+ [tool.ruff]
45
+ builtins = ["_"]
46
+ cache-dir = "~/.cache/ruff"
47
+
48
+ [tool.pytest.ini_options]
49
+ pythonpath = "src/spakky/plugins/a2a"
50
+ testpaths = "tests"
51
+ python_files = ["test_*.py"]
52
+ asyncio_mode = "auto"
53
+ markers = ["known_issue(reason): 알려진 버그"]
54
+ addopts = """
55
+ --cov
56
+ --cov-report=term
57
+ --cov-report=xml
58
+ --no-cov-on-fail
59
+ --strict-markers
60
+ --dist=load
61
+ -p no:warnings
62
+ -n auto
63
+ --spec
64
+ --with-integration
65
+ """
66
+ spec_test_format = "{result} {docstring_summary}"
67
+
68
+ [tool.coverage.run]
69
+ include = ["src/spakky/plugins/a2a/**/*.py"]
70
+ branch = true
71
+
72
+ [tool.coverage.report]
73
+ show_missing = true
74
+ precision = 2
75
+ fail_under = 100
76
+ skip_empty = true
77
+ exclude_lines = [
78
+ "pragma: no cover",
79
+ "def __repr__",
80
+ "raise AssertionError",
81
+ "raise NotImplementedError",
82
+ "@(abc\\.)?abstractmethod",
83
+ "@(typing\\.)?overload",
84
+ "\\.\\.\\.",
85
+ "pass",
86
+ ]
87
+
88
+ [tool.uv.sources]
89
+ spakky = { workspace = true }
90
+ spakky-agent = { workspace = true }
91
+ spakky-grpc = { workspace = true }
@@ -0,0 +1,31 @@
1
+ """A2A (Agent2Agent) protocol server plugin for the Spakky framework.
2
+
3
+ Exposes a spakky ``@Agent`` as an A2A protocol server: an AgentCard is derived
4
+ from the agent's spec, tools, and teammates, and JSON-RPC/HTTP plus SSE routes
5
+ are mounted from the official ``a2a-sdk``. Marker, config, and plugin identifier
6
+ are re-exported; transport types live under ``a2a-sdk``.
7
+ """
8
+
9
+ from spakky.core.application.plugin import Plugin
10
+
11
+ from spakky.plugins.a2a.config import (
12
+ SPAKKY_A2A_CONFIG_ENV_PREFIX,
13
+ A2AConfig,
14
+ )
15
+ from spakky.plugins.a2a.client import A2ARemoteAgentClient, RemoteA2AMessage
16
+ from spakky.plugins.a2a.delegation import A2AAgentDelegate, A2AStreamEventMapper
17
+ from spakky.plugins.a2a.stereotypes.a2a_agent_server import A2AAgentServer
18
+
19
+ PLUGIN_NAME = Plugin(name="spakky-a2a")
20
+ """Plugin identifier for the A2A integration."""
21
+
22
+ __all__ = [
23
+ "PLUGIN_NAME",
24
+ "SPAKKY_A2A_CONFIG_ENV_PREFIX",
25
+ "A2AConfig",
26
+ "A2AAgentDelegate",
27
+ "A2ARemoteAgentClient",
28
+ "A2AStreamEventMapper",
29
+ "A2AAgentServer",
30
+ "RemoteA2AMessage",
31
+ ]
@@ -0,0 +1 @@
1
+ """A2A plugin card package."""
@@ -0,0 +1,123 @@
1
+ """AgentCard derivation from an @Agent declaration.
2
+
3
+ Maps a spakky ``@Agent`` spec, its discovered tool catalog, and its declared
4
+ teammates onto an a2a-sdk ``AgentCard``. The a2a-sdk 1.x ``AgentCard`` is a
5
+ protobuf message (``a2a_pb2``) whose transport endpoint is expressed as an
6
+ ``AgentInterface`` entry rather than a flat ``url`` field, so the base URL is
7
+ advertised through ``supported_interfaces``.
8
+ """
9
+
10
+ from a2a.types import (
11
+ AgentCapabilities,
12
+ AgentCard,
13
+ AgentInterface,
14
+ AgentSkill,
15
+ )
16
+ from a2a.utils import TransportProtocol
17
+ from spakky.agent.execution import Agent, AgentTeammate, StreamingExposureMode
18
+ from spakky.agent.tooling import AgentToolDescriptor, AgentToolMetadata
19
+
20
+ JSON_CONTENT_TYPE = "application/json"
21
+ """Tool skills advertise JSON-shaped input and output payloads."""
22
+
23
+ TEXT_CONTENT_TYPE = "text/plain"
24
+ """The card's default conversational input and output content type."""
25
+
26
+ TEAMMATE_DELEGATION_TAG = "delegation"
27
+ """Tag attached to a skill derived from a declared teammate."""
28
+
29
+
30
+ class AgentCardFactory:
31
+ """Builds an a2a-sdk ``AgentCard`` from an @Agent Pod declaration."""
32
+
33
+ def build(
34
+ self,
35
+ agent: Agent,
36
+ base_url: str,
37
+ version: str,
38
+ protocol: TransportProtocol = TransportProtocol.JSONRPC,
39
+ ) -> AgentCard:
40
+ """Derive an AgentCard from an @Agent spec, tools, and teammates.
41
+
42
+ Args:
43
+ agent: The @Agent Pod metadata carrying spec and tool catalog.
44
+ base_url: Transport endpoint advertised on the card interface.
45
+ version: Semantic version advertised on the card.
46
+ protocol: A2A transport protocol advertised for ``base_url``.
47
+
48
+ Returns:
49
+ A protobuf ``AgentCard`` ready to publish on the well-known route.
50
+ """
51
+ spec = agent.spec
52
+ name = spec.name or agent.target.__name__
53
+ description = spec.objective or spec.instructions or name
54
+ # A guarded final-only profile suppresses incremental streaming exposure.
55
+ streaming = (
56
+ spec.streaming_exposure_mode
57
+ is not StreamingExposureMode.NO_STREAM_UNTIL_FINAL_GUARDED
58
+ )
59
+ tool_skills = [
60
+ self._tool_skill(descriptor)
61
+ for descriptor in agent.tool_catalog.descriptors
62
+ if not self._is_teammate_delegation_tool(descriptor)
63
+ ]
64
+ teammate_skills = [
65
+ self._teammate_skill(teammate) for teammate in spec.teammates
66
+ ]
67
+ return AgentCard(
68
+ name=name,
69
+ description=description,
70
+ version=version,
71
+ supported_interfaces=[
72
+ AgentInterface(
73
+ url=base_url,
74
+ protocol_binding=protocol.value,
75
+ )
76
+ ],
77
+ capabilities=AgentCapabilities(
78
+ streaming=streaming,
79
+ push_notifications=False,
80
+ ),
81
+ default_input_modes=[TEXT_CONTENT_TYPE],
82
+ default_output_modes=[TEXT_CONTENT_TYPE],
83
+ skills=[*tool_skills, *teammate_skills],
84
+ )
85
+
86
+ @staticmethod
87
+ def _tool_skill(descriptor: AgentToolDescriptor) -> AgentSkill:
88
+ """Project one tool descriptor onto an AgentSkill."""
89
+ return AgentSkill(
90
+ id=descriptor.identity.key,
91
+ name=descriptor.identity.name,
92
+ description=descriptor.description or descriptor.identity.name,
93
+ tags=AgentCardFactory._skill_tags(descriptor.metadata),
94
+ input_modes=[JSON_CONTENT_TYPE],
95
+ output_modes=[JSON_CONTENT_TYPE],
96
+ )
97
+
98
+ @staticmethod
99
+ def _teammate_skill(teammate: AgentTeammate) -> AgentSkill:
100
+ """Project one declared teammate onto a delegation AgentSkill."""
101
+ return AgentSkill(
102
+ id=f"teammate:{teammate.name}",
103
+ name=teammate.name,
104
+ description="Delegated teammate",
105
+ tags=[TEAMMATE_DELEGATION_TAG],
106
+ )
107
+
108
+ @staticmethod
109
+ def _is_teammate_delegation_tool(descriptor: AgentToolDescriptor) -> bool:
110
+ """Return whether a tool descriptor is the runner's synthetic teammate tool."""
111
+ return descriptor.schema.name.startswith(
112
+ "teammate."
113
+ ) and descriptor.schema.name.endswith(".delegate")
114
+
115
+ @staticmethod
116
+ def _skill_tags(metadata: AgentToolMetadata) -> list[str]:
117
+ """Derive deterministic skill tags from typed tool metadata."""
118
+ return [
119
+ *(permission.name for permission in metadata.permissions),
120
+ metadata.data_access.value,
121
+ metadata.externality.value,
122
+ metadata.idempotency.value,
123
+ ]
@@ -0,0 +1,127 @@
1
+ """Official a2a-sdk client wrapper for remote teammate calls."""
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from contextlib import AbstractAsyncContextManager
5
+ from dataclasses import dataclass, field
6
+ from uuid import uuid4
7
+ from urllib.parse import urlsplit
8
+
9
+ import httpx
10
+ from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
11
+ from a2a.types import (
12
+ AgentCard,
13
+ GetTaskRequest,
14
+ Message,
15
+ Part,
16
+ Role,
17
+ SendMessageConfiguration,
18
+ SendMessageRequest,
19
+ StreamResponse,
20
+ Task,
21
+ )
22
+
23
+ DEFAULT_AGENT_CARD_PATH = "/.well-known/agent-card.json"
24
+ """Default A2A well-known AgentCard route."""
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class RemoteA2AMessage:
29
+ """Message envelope sent to a remote A2A teammate."""
30
+
31
+ text: str
32
+ task_id: str | None = None
33
+ context_id: str | None = None
34
+ message_id: str = field(default_factory=lambda: f"message-{uuid4()}")
35
+
36
+
37
+ class A2ARemoteAgentClient:
38
+ """Small wrapper around the official a2a-sdk client and types."""
39
+
40
+ _httpx_client: httpx.AsyncClient | None
41
+ _config: ClientConfig
42
+ _factory: ClientFactory
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ httpx_client: httpx.AsyncClient | None = None,
48
+ config: ClientConfig | None = None,
49
+ ) -> None:
50
+ self._httpx_client = httpx_client
51
+ self._config = config or ClientConfig(httpx_client=httpx_client)
52
+ self._factory = ClientFactory(self._config)
53
+
54
+ async def resolve_card(self, card_url: str) -> AgentCard:
55
+ """Fetch a remote AgentCard with the SDK resolver."""
56
+ parts = urlsplit(card_url)
57
+ base_url = f"{parts.scheme}://{parts.netloc}"
58
+ path = parts.path or DEFAULT_AGENT_CARD_PATH
59
+ async with self._http_client() as client:
60
+ resolver = A2ACardResolver(client, base_url=base_url)
61
+ return await resolver.get_agent_card(path)
62
+
63
+ async def send_message(
64
+ self,
65
+ card_url: str,
66
+ message: RemoteA2AMessage,
67
+ ) -> tuple[StreamResponse, ...]:
68
+ """Send a message and collect the SDK response stream."""
69
+ return tuple([event async for event in self.stream_message(card_url, message)])
70
+
71
+ async def stream_message(
72
+ self,
73
+ card_url: str,
74
+ message: RemoteA2AMessage,
75
+ ) -> AsyncGenerator[StreamResponse, None]:
76
+ """Send a message and yield remote task/message updates as they arrive."""
77
+ card = await self.resolve_card(card_url)
78
+ client = self._factory.create(card)
79
+ request = SendMessageRequest(
80
+ message=Message(
81
+ role=Role.ROLE_USER,
82
+ message_id=message.message_id,
83
+ task_id=message.task_id or "",
84
+ context_id=message.context_id or "",
85
+ parts=[Part(text=message.text)],
86
+ ),
87
+ configuration=SendMessageConfiguration(return_immediately=False),
88
+ )
89
+ try:
90
+ async for event in client.send_message(request):
91
+ yield event
92
+ finally:
93
+ await client.close()
94
+
95
+ async def get_task(self, card_url: str, task_id: str) -> Task:
96
+ """Fetch a remote A2A task by id using the SDK client."""
97
+ card = await self.resolve_card(card_url)
98
+ client = self._factory.create(card)
99
+ try:
100
+ return await client.get_task(GetTaskRequest(id=task_id))
101
+ finally:
102
+ await client.close()
103
+
104
+ def _http_client(self) -> AbstractAsyncContextManager[httpx.AsyncClient]:
105
+ if self._httpx_client is not None:
106
+ return _BorrowedAsyncClient(self._httpx_client)
107
+ return httpx.AsyncClient()
108
+
109
+
110
+ class _BorrowedAsyncClient:
111
+ """Async context manager that leaves caller-owned httpx clients open."""
112
+
113
+ _client: httpx.AsyncClient
114
+
115
+ def __init__(self, client: httpx.AsyncClient) -> None:
116
+ self._client = client
117
+
118
+ async def __aenter__(self) -> httpx.AsyncClient:
119
+ return self._client
120
+
121
+ async def __aexit__(
122
+ self,
123
+ exc_type: object,
124
+ exc: object,
125
+ traceback: object,
126
+ ) -> None:
127
+ return None
@@ -0,0 +1,35 @@
1
+ """A2A plugin configuration."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+ from spakky.core.stereotype.configuration import Configuration
7
+
8
+ SPAKKY_A2A_CONFIG_ENV_PREFIX = "SPAKKY_A2A_"
9
+ """Environment prefix for A2A plugin settings."""
10
+
11
+ DEFAULT_A2A_BASE_URL = "http://localhost:8000"
12
+ """Fallback base URL advertised on a derived AgentCard interface."""
13
+
14
+ DEFAULT_A2A_VERSION = "1.0.0"
15
+ """Fallback semantic version advertised on a derived AgentCard."""
16
+
17
+
18
+ @Configuration()
19
+ class A2AConfig(BaseSettings):
20
+ """Configuration for the A2A protocol server integration."""
21
+
22
+ model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
23
+ env_prefix=SPAKKY_A2A_CONFIG_ENV_PREFIX,
24
+ env_file_encoding="utf-8",
25
+ env_nested_delimiter="__",
26
+ )
27
+
28
+ default_base_url: str = DEFAULT_A2A_BASE_URL
29
+ """Base URL advertised on a derived AgentCard transport interface."""
30
+
31
+ default_version: str = DEFAULT_A2A_VERSION
32
+ """Semantic version advertised on a derived AgentCard."""
33
+
34
+ def __init__(self) -> None:
35
+ super().__init__()