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.
- spakky_a2a-6.10.0/PKG-INFO +108 -0
- spakky_a2a-6.10.0/README.md +92 -0
- spakky_a2a-6.10.0/pyproject.toml +91 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/__init__.py +31 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/card/__init__.py +1 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/card/derivation.py +123 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/client.py +127 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/config.py +35 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/delegation.py +271 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/error.py +85 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/executor/__init__.py +1 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/executor/adapter.py +252 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/executor/event_mapping.py +276 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/grpc_transport/__init__.py +13 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/grpc_transport/builder.py +38 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/grpc_transport/handler.py +160 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/main.py +27 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/post_processors/__init__.py +1 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/post_processors/register_agent_servers.py +55 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/py.typed +0 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/rest_transport/__init__.py +5 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/rest_transport/builder.py +58 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/server/__init__.py +1 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/server/builder.py +92 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/server/registry.py +56 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/server/request_handler.py +41 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/stereotypes/__init__.py +1 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/stereotypes/a2a_agent_server.py +27 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/store/__init__.py +1 -0
- spakky_a2a-6.10.0/src/spakky/plugins/a2a/store/interfaces.py +36 -0
- 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__()
|