hivemind-a2a-agent-plugin 0.1.0a2__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.
- hivemind_a2a_agent_plugin-0.1.0a2/LICENSE +17 -0
- hivemind_a2a_agent_plugin-0.1.0a2/PKG-INFO +154 -0
- hivemind_a2a_agent_plugin-0.1.0a2/README.md +126 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin/__init__.py +157 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin/_client.py +230 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin/version.py +8 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin.egg-info/PKG-INFO +154 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin.egg-info/SOURCES.txt +14 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin.egg-info/dependency_links.txt +1 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin.egg-info/entry_points.txt +2 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin.egg-info/requires.txt +17 -0
- hivemind_a2a_agent_plugin-0.1.0a2/hivemind_a2a_agent_plugin.egg-info/top_level.txt +1 -0
- hivemind_a2a_agent_plugin-0.1.0a2/pyproject.toml +49 -0
- hivemind_a2a_agent_plugin-0.1.0a2/setup.cfg +4 -0
- hivemind_a2a_agent_plugin-0.1.0a2/tests/test_client.py +241 -0
- hivemind_a2a_agent_plugin-0.1.0a2/tests/test_protocol.py +134 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
Copyright 2024 JarbasAi
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hivemind-a2a-agent-plugin
|
|
3
|
+
Version: 0.1.0a2
|
|
4
|
+
Summary: A2A agent protocol plugin for HiveMind-core
|
|
5
|
+
Author-email: JarbasAi <jarbasai@mailfence.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/TigreGotico/hivemind-a2a-agent-plugin
|
|
8
|
+
Project-URL: Issues, https://github.com/TigreGotico/hivemind-a2a-agent-plugin/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: ovos-utils<1.0.0,>=0.8.2
|
|
13
|
+
Requires-Dist: hivemind-plugin-manager<1.0.0,>=0.5.0
|
|
14
|
+
Requires-Dist: httpx>=0.25.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
18
|
+
Requires-Dist: fastapi; extra == "test"
|
|
19
|
+
Requires-Dist: uvicorn; extra == "test"
|
|
20
|
+
Requires-Dist: httpx; extra == "test"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: fastapi; extra == "dev"
|
|
25
|
+
Requires-Dist: uvicorn; extra == "dev"
|
|
26
|
+
Requires-Dist: httpx; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# hivemind-a2a-agent-plugin
|
|
30
|
+
|
|
31
|
+
A [HiveMind](https://github.com/JarbasHiveMind/HiveMind-core) agent-protocol plugin
|
|
32
|
+
that bridges the hive to external
|
|
33
|
+
[A2A (Agent-to-Agent)](https://google.github.io/A2A/) agents.
|
|
34
|
+
|
|
35
|
+
Natural-language queries arriving from hive satellites are forwarded to a configured
|
|
36
|
+
A2A server via JSON-RPC 2.0 (`tasks/send` or `tasks/sendSubscribe`), and the
|
|
37
|
+
response is streamed back to the originating satellite.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## What is A2A?
|
|
42
|
+
|
|
43
|
+
[A2A](https://google.github.io/A2A/) is an open protocol for agent interoperability.
|
|
44
|
+
An A2A server:
|
|
45
|
+
|
|
46
|
+
1. Publishes an **agent card** at `GET /.well-known/agent.json` describing its
|
|
47
|
+
capabilities, skills, and the URL that accepts tasks.
|
|
48
|
+
2. Accepts **task** requests as JSON-RPC 2.0 at its root URL — either a blocking
|
|
49
|
+
`tasks/send` or a streaming `tasks/sendSubscribe` (SSE).
|
|
50
|
+
|
|
51
|
+
Any compliant A2A server (LangChain agents, Google ADK, CrewAI, custom FastAPI
|
|
52
|
+
services, …) works as a backend for this plugin.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install hivemind-a2a-agent-plugin
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The plugin registers itself under the `hivemind.agent.protocol` entry-point group
|
|
63
|
+
so HiveMind-core discovers it automatically when it is installed.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Add the following to your OVOS / HiveMind config (typically
|
|
70
|
+
`~/.config/hivemind/hivemind.conf`):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"hivemind": {
|
|
75
|
+
"agent_protocol": "hivemind-a2a-agent-plugin",
|
|
76
|
+
"a2a_agent": {
|
|
77
|
+
"agent_url": "http://localhost:9999",
|
|
78
|
+
"auth_header": "Bearer secret",
|
|
79
|
+
"timeout": 60,
|
|
80
|
+
"streaming": false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Key | Default | Description |
|
|
87
|
+
|--------------|---------|------------------------------------------------------|
|
|
88
|
+
| `agent_url` | — | **Required.** Root URL of the A2A server. |
|
|
89
|
+
| `auth_header`| — | Optional `Authorization` header (e.g. `Bearer …`). |
|
|
90
|
+
| `timeout` | `60` | HTTP timeout in seconds. |
|
|
91
|
+
| `streaming` | `false` | Prefer `tasks/sendSubscribe` (SSE) when `true`. |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Hive wiring example
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
HiveMind master
|
|
99
|
+
└── hivemind-a2a-agent-plugin ← loaded as agent_protocol
|
|
100
|
+
└── A2A server (http://localhost:9999)
|
|
101
|
+
└── your LLM / agent / tool backend
|
|
102
|
+
|
|
103
|
+
Satellite (voice client, phone, …)
|
|
104
|
+
→ "what's the capital of France?"
|
|
105
|
+
→ HiveMind master receives utterance
|
|
106
|
+
→ plugin forwards to A2A server via tasks/send
|
|
107
|
+
→ A2A server responds: "Paris is the capital of France."
|
|
108
|
+
→ HiveMind master streams answer back to satellite
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Start a minimal FastAPI A2A server and the HiveMind master:**
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# 1. run the example mock A2A server (also used by e2e tests)
|
|
115
|
+
uvicorn tests.e2e.mock_a2a_server:app --port 9999
|
|
116
|
+
|
|
117
|
+
# 2. configure hivemind-core to use this plugin (see above)
|
|
118
|
+
# 3. start hivemind-core
|
|
119
|
+
hivemind-core listen
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Session / context mapping
|
|
125
|
+
|
|
126
|
+
The plugin maps the HiveMind `session_id` directly to the A2A `sessionId` parameter
|
|
127
|
+
so multi-turn conversations are kept in context on the A2A server side. No extra
|
|
128
|
+
state is stored in the plugin; the A2A server owns conversation history.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Error handling
|
|
133
|
+
|
|
134
|
+
The plugin never silences errors. If the A2A server is unreachable, returns an
|
|
135
|
+
empty response, or returns a JSON-RPC error object, a human-readable error string
|
|
136
|
+
is yielded to the satellite so the user always receives a reply.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
git clone https://github.com/TigreGotico/hivemind-a2a-agent-plugin
|
|
144
|
+
cd hivemind-a2a-agent-plugin
|
|
145
|
+
pip install -e ".[dev]"
|
|
146
|
+
pytest tests/ # unit tests (no server needed)
|
|
147
|
+
pytest tests/e2e/ # e2e tests (spins up a FastAPI mock server in-process)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Credits
|
|
151
|
+
|
|
152
|
+
Funded by [NGI0 Commons Fund](https://nlnet.nl/project/OpenVoiceOS) / [NLnet](https://nlnet.nl)
|
|
153
|
+
under grant agreement No [101135429](https://cordis.europa.eu/project/id/101135429),
|
|
154
|
+
through the European Commission's [Next Generation Internet](https://ngi.eu) programme.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# hivemind-a2a-agent-plugin
|
|
2
|
+
|
|
3
|
+
A [HiveMind](https://github.com/JarbasHiveMind/HiveMind-core) agent-protocol plugin
|
|
4
|
+
that bridges the hive to external
|
|
5
|
+
[A2A (Agent-to-Agent)](https://google.github.io/A2A/) agents.
|
|
6
|
+
|
|
7
|
+
Natural-language queries arriving from hive satellites are forwarded to a configured
|
|
8
|
+
A2A server via JSON-RPC 2.0 (`tasks/send` or `tasks/sendSubscribe`), and the
|
|
9
|
+
response is streamed back to the originating satellite.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What is A2A?
|
|
14
|
+
|
|
15
|
+
[A2A](https://google.github.io/A2A/) is an open protocol for agent interoperability.
|
|
16
|
+
An A2A server:
|
|
17
|
+
|
|
18
|
+
1. Publishes an **agent card** at `GET /.well-known/agent.json` describing its
|
|
19
|
+
capabilities, skills, and the URL that accepts tasks.
|
|
20
|
+
2. Accepts **task** requests as JSON-RPC 2.0 at its root URL — either a blocking
|
|
21
|
+
`tasks/send` or a streaming `tasks/sendSubscribe` (SSE).
|
|
22
|
+
|
|
23
|
+
Any compliant A2A server (LangChain agents, Google ADK, CrewAI, custom FastAPI
|
|
24
|
+
services, …) works as a backend for this plugin.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install hivemind-a2a-agent-plugin
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The plugin registers itself under the `hivemind.agent.protocol` entry-point group
|
|
35
|
+
so HiveMind-core discovers it automatically when it is installed.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
Add the following to your OVOS / HiveMind config (typically
|
|
42
|
+
`~/.config/hivemind/hivemind.conf`):
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"hivemind": {
|
|
47
|
+
"agent_protocol": "hivemind-a2a-agent-plugin",
|
|
48
|
+
"a2a_agent": {
|
|
49
|
+
"agent_url": "http://localhost:9999",
|
|
50
|
+
"auth_header": "Bearer secret",
|
|
51
|
+
"timeout": 60,
|
|
52
|
+
"streaming": false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Key | Default | Description |
|
|
59
|
+
|--------------|---------|------------------------------------------------------|
|
|
60
|
+
| `agent_url` | — | **Required.** Root URL of the A2A server. |
|
|
61
|
+
| `auth_header`| — | Optional `Authorization` header (e.g. `Bearer …`). |
|
|
62
|
+
| `timeout` | `60` | HTTP timeout in seconds. |
|
|
63
|
+
| `streaming` | `false` | Prefer `tasks/sendSubscribe` (SSE) when `true`. |
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Hive wiring example
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
HiveMind master
|
|
71
|
+
└── hivemind-a2a-agent-plugin ← loaded as agent_protocol
|
|
72
|
+
└── A2A server (http://localhost:9999)
|
|
73
|
+
└── your LLM / agent / tool backend
|
|
74
|
+
|
|
75
|
+
Satellite (voice client, phone, …)
|
|
76
|
+
→ "what's the capital of France?"
|
|
77
|
+
→ HiveMind master receives utterance
|
|
78
|
+
→ plugin forwards to A2A server via tasks/send
|
|
79
|
+
→ A2A server responds: "Paris is the capital of France."
|
|
80
|
+
→ HiveMind master streams answer back to satellite
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Start a minimal FastAPI A2A server and the HiveMind master:**
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# 1. run the example mock A2A server (also used by e2e tests)
|
|
87
|
+
uvicorn tests.e2e.mock_a2a_server:app --port 9999
|
|
88
|
+
|
|
89
|
+
# 2. configure hivemind-core to use this plugin (see above)
|
|
90
|
+
# 3. start hivemind-core
|
|
91
|
+
hivemind-core listen
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Session / context mapping
|
|
97
|
+
|
|
98
|
+
The plugin maps the HiveMind `session_id` directly to the A2A `sessionId` parameter
|
|
99
|
+
so multi-turn conversations are kept in context on the A2A server side. No extra
|
|
100
|
+
state is stored in the plugin; the A2A server owns conversation history.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Error handling
|
|
105
|
+
|
|
106
|
+
The plugin never silences errors. If the A2A server is unreachable, returns an
|
|
107
|
+
empty response, or returns a JSON-RPC error object, a human-readable error string
|
|
108
|
+
is yielded to the satellite so the user always receives a reply.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
git clone https://github.com/TigreGotico/hivemind-a2a-agent-plugin
|
|
116
|
+
cd hivemind-a2a-agent-plugin
|
|
117
|
+
pip install -e ".[dev]"
|
|
118
|
+
pytest tests/ # unit tests (no server needed)
|
|
119
|
+
pytest tests/e2e/ # e2e tests (spins up a FastAPI mock server in-process)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Credits
|
|
123
|
+
|
|
124
|
+
Funded by [NGI0 Commons Fund](https://nlnet.nl/project/OpenVoiceOS) / [NLnet](https://nlnet.nl)
|
|
125
|
+
under grant agreement No [101135429](https://cordis.europa.eu/project/id/101135429),
|
|
126
|
+
through the European Commission's [Next Generation Internet](https://ngi.eu) programme.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""HiveMind A2A agent protocol plugin.
|
|
2
|
+
|
|
3
|
+
Bridges HiveMind natural-language queries to external A2A (Agent-to-Agent)
|
|
4
|
+
agents. Any utterance arriving from hive satellites is forwarded to a
|
|
5
|
+
configured A2A server via JSON-RPC 2.0 (``tasks/send`` / ``tasks/sendSubscribe``),
|
|
6
|
+
and the response is streamed back through the HiveMind session.
|
|
7
|
+
|
|
8
|
+
The plugin is registered under the ``hivemind.agent.protocol`` entry-point
|
|
9
|
+
group so hivemind-core discovers it automatically.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
from typing import Any, Dict, Iterator, Optional
|
|
15
|
+
|
|
16
|
+
from ovos_utils.log import LOG
|
|
17
|
+
|
|
18
|
+
from hivemind_plugin_manager.protocols import AgentProtocol
|
|
19
|
+
|
|
20
|
+
from hivemind_a2a_agent_plugin._client import A2AClient
|
|
21
|
+
from hivemind_a2a_agent_plugin.version import __version__
|
|
22
|
+
|
|
23
|
+
# Default configuration keys (all live under hivemind → a2a_agent in
|
|
24
|
+
# the OVOS config tree, but can also be supplied as plain kwargs).
|
|
25
|
+
_CFG_URL = "agent_url"
|
|
26
|
+
_CFG_AUTH = "auth_header"
|
|
27
|
+
_CFG_TIMEOUT = "timeout"
|
|
28
|
+
_CFG_STREAMING = "streaming"
|
|
29
|
+
|
|
30
|
+
_DEFAULT_TIMEOUT = 60.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclasses.dataclass()
|
|
34
|
+
class A2AAgentProtocol(AgentProtocol):
|
|
35
|
+
"""HiveMind agent protocol that delegates NL queries to an A2A agent.
|
|
36
|
+
|
|
37
|
+
Configuration (passed via ``config`` dict or the OVOS ``Configuration``
|
|
38
|
+
key ``"hivemind" → "a2a_agent"``):
|
|
39
|
+
|
|
40
|
+
.. code-block:: yaml
|
|
41
|
+
|
|
42
|
+
hivemind:
|
|
43
|
+
a2a_agent:
|
|
44
|
+
agent_url: "http://localhost:9999"
|
|
45
|
+
auth_header: "Bearer secret" # optional
|
|
46
|
+
timeout: 60 # seconds, optional
|
|
47
|
+
streaming: false # prefer SSE when true, optional
|
|
48
|
+
|
|
49
|
+
The plugin is stateless with respect to the HiveMind client transport —
|
|
50
|
+
it only owns the outbound HTTP connection to the A2A server and maps
|
|
51
|
+
HiveMind session IDs to A2A ``sessionId`` values so conversation context
|
|
52
|
+
is preserved across multi-turn exchanges.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
config: Dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
# Internal state — not part of the public interface.
|
|
58
|
+
_client: Optional[A2AClient] = dataclasses.field(
|
|
59
|
+
default=None, init=False, repr=False, compare=False
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
# Merge OVOS global config if available, but explicit kwargs win.
|
|
64
|
+
try:
|
|
65
|
+
from ovos_config import Configuration
|
|
66
|
+
cfg_root = Configuration()
|
|
67
|
+
ovos_a2a = cfg_root.get("hivemind", {}).get("a2a_agent", {})
|
|
68
|
+
except Exception: # pragma: no cover
|
|
69
|
+
ovos_a2a = {}
|
|
70
|
+
|
|
71
|
+
merged: Dict[str, Any] = {**ovos_a2a, **self.config}
|
|
72
|
+
|
|
73
|
+
url: Optional[str] = merged.get(_CFG_URL)
|
|
74
|
+
if not url:
|
|
75
|
+
LOG.warning(
|
|
76
|
+
"A2AAgentProtocol: no agent_url configured — "
|
|
77
|
+
"natural_language_query will return error answers"
|
|
78
|
+
)
|
|
79
|
+
else:
|
|
80
|
+
auth = merged.get(_CFG_AUTH)
|
|
81
|
+
timeout = float(merged.get(_CFG_TIMEOUT, _DEFAULT_TIMEOUT))
|
|
82
|
+
streaming = bool(merged.get(_CFG_STREAMING, False))
|
|
83
|
+
self._client = A2AClient(
|
|
84
|
+
base_url=url,
|
|
85
|
+
auth_header=auth,
|
|
86
|
+
timeout=timeout,
|
|
87
|
+
streaming=streaming,
|
|
88
|
+
)
|
|
89
|
+
LOG.info(f"A2AAgentProtocol: connected to A2A agent at {url}")
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# AgentProtocol implementation
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def natural_language_query(
|
|
96
|
+
self,
|
|
97
|
+
utterance: str,
|
|
98
|
+
lang: str,
|
|
99
|
+
session_id: Optional[str] = None,
|
|
100
|
+
) -> "Iterator[Optional[str]]":
|
|
101
|
+
"""Forward *utterance* to the A2A agent and yield response chunks.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
utterance: The user's text, as received from a hive satellite.
|
|
105
|
+
lang: BCP-47 language tag (e.g. ``"en-us"``). Forwarded
|
|
106
|
+
as context metadata so A2A agents can apply
|
|
107
|
+
language-specific processing.
|
|
108
|
+
session_id: HiveMind session identifier; mapped 1-to-1 to the
|
|
109
|
+
A2A ``sessionId`` so multi-turn context is preserved.
|
|
110
|
+
|
|
111
|
+
Yields:
|
|
112
|
+
Non-empty text chunks from the agent response, terminated by
|
|
113
|
+
``None`` (the AgentProtocol sentinel). On any error a
|
|
114
|
+
human-readable error string is yielded before the sentinel so
|
|
115
|
+
callers always get at least one answer.
|
|
116
|
+
"""
|
|
117
|
+
if self._client is None:
|
|
118
|
+
yield "A2A agent not configured — no agent_url set."
|
|
119
|
+
yield None
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
LOG.debug(
|
|
123
|
+
f"A2AAgentProtocol: query lang={lang!r} session={session_id!r} "
|
|
124
|
+
f"utterance={utterance!r}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
if self._client.streaming:
|
|
129
|
+
yielded_any = False
|
|
130
|
+
for chunk in self._client.stream_task(
|
|
131
|
+
message_text=utterance,
|
|
132
|
+
session_id=session_id,
|
|
133
|
+
lang=lang,
|
|
134
|
+
):
|
|
135
|
+
if chunk:
|
|
136
|
+
yielded_any = True
|
|
137
|
+
yield chunk
|
|
138
|
+
if not yielded_any:
|
|
139
|
+
yield "The A2A agent returned an empty streaming response."
|
|
140
|
+
else:
|
|
141
|
+
text = self._client.send_task(
|
|
142
|
+
message_text=utterance,
|
|
143
|
+
session_id=session_id,
|
|
144
|
+
lang=lang,
|
|
145
|
+
)
|
|
146
|
+
if text:
|
|
147
|
+
yield text
|
|
148
|
+
else:
|
|
149
|
+
yield "The A2A agent returned an empty response."
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
LOG.error(f"A2AAgentProtocol: error querying A2A agent: {exc}", exc_info=True)
|
|
152
|
+
yield f"Error contacting A2A agent: {exc}"
|
|
153
|
+
|
|
154
|
+
yield None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["A2AAgentProtocol", "__version__"]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Thin A2A JSON-RPC 2.0 client.
|
|
2
|
+
|
|
3
|
+
Vendored subset of ovos-a2a-solver-plugin's A2AClient so the HiveMind
|
|
4
|
+
plugin has zero extra dependencies beyond ``httpx``. If ovos-a2a-solver-plugin
|
|
5
|
+
is importable its richer implementation is preferred at import time via the
|
|
6
|
+
``__init__`` module; this copy is the fallback.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, Generator, List, Optional
|
|
14
|
+
from urllib.parse import urljoin
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from ovos_utils.log import LOG
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AgentSkill:
|
|
23
|
+
"""A capability advertised in an agent card."""
|
|
24
|
+
id: str
|
|
25
|
+
name: str
|
|
26
|
+
description: str = ""
|
|
27
|
+
tags: List[str] = field(default_factory=list)
|
|
28
|
+
examples: List[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_dict(cls, d: Dict[str, Any]) -> "AgentSkill":
|
|
32
|
+
return cls(
|
|
33
|
+
id=d.get("id", ""),
|
|
34
|
+
name=d.get("name", ""),
|
|
35
|
+
description=d.get("description", ""),
|
|
36
|
+
tags=d.get("tags", []),
|
|
37
|
+
examples=d.get("examples", []),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class AgentCard:
|
|
43
|
+
"""Parsed A2A agent card (``/.well-known/agent.json``)."""
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
url: str
|
|
47
|
+
version: str = "1.0"
|
|
48
|
+
skills: List[AgentSkill] = field(default_factory=list)
|
|
49
|
+
streaming: bool = False
|
|
50
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_dict(cls, d: Dict[str, Any]) -> "AgentCard":
|
|
54
|
+
skills = [AgentSkill.from_dict(s) for s in d.get("skills", [])]
|
|
55
|
+
caps = d.get("capabilities", {})
|
|
56
|
+
return cls(
|
|
57
|
+
name=d.get("name", ""),
|
|
58
|
+
description=d.get("description", ""),
|
|
59
|
+
url=d.get("url", ""),
|
|
60
|
+
version=d.get("version", "1.0"),
|
|
61
|
+
skills=skills,
|
|
62
|
+
streaming=caps.get("streaming", False),
|
|
63
|
+
raw=d,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class A2AClient:
|
|
68
|
+
"""Minimal A2A JSON-RPC 2.0 client.
|
|
69
|
+
|
|
70
|
+
Handles:
|
|
71
|
+
- Agent-card discovery (``GET /.well-known/agent.json``)
|
|
72
|
+
- Task submission via ``tasks/send`` (blocking)
|
|
73
|
+
- Streaming via ``tasks/sendSubscribe`` (SSE)
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
base_url: Root URL of the A2A server.
|
|
77
|
+
auth_header: Optional ``Authorization`` header value.
|
|
78
|
+
timeout: HTTP timeout in seconds (default 60).
|
|
79
|
+
streaming: Prefer SSE streaming when True.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
AGENT_CARD_PATH = "/.well-known/agent.json"
|
|
83
|
+
JSONRPC_VERSION = "2.0"
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
base_url: str,
|
|
88
|
+
auth_header: Optional[str] = None,
|
|
89
|
+
timeout: float = 60.0,
|
|
90
|
+
streaming: bool = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.base_url = base_url.rstrip("/")
|
|
93
|
+
self.timeout = timeout
|
|
94
|
+
self.streaming = streaming
|
|
95
|
+
headers: Dict[str, str] = {"Content-Type": "application/json"}
|
|
96
|
+
if auth_header:
|
|
97
|
+
headers["Authorization"] = auth_header
|
|
98
|
+
self._http = httpx.Client(headers=headers, timeout=timeout)
|
|
99
|
+
|
|
100
|
+
def fetch_agent_card(self) -> AgentCard:
|
|
101
|
+
"""Fetch and parse ``/.well-known/agent.json``."""
|
|
102
|
+
url = self.base_url + self.AGENT_CARD_PATH
|
|
103
|
+
LOG.debug(f"A2AClient: fetching agent card from {url}")
|
|
104
|
+
resp = self._http.get(url)
|
|
105
|
+
resp.raise_for_status()
|
|
106
|
+
card = AgentCard.from_dict(resp.json())
|
|
107
|
+
LOG.debug(f"A2AClient: discovered agent '{card.name}' at {card.url}")
|
|
108
|
+
return card
|
|
109
|
+
|
|
110
|
+
def send_task(
|
|
111
|
+
self,
|
|
112
|
+
message_text: str,
|
|
113
|
+
session_id: Optional[str] = None,
|
|
114
|
+
lang: Optional[str] = None,
|
|
115
|
+
history: Optional[List[Dict[str, str]]] = None,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Submit a task (blocking) and return the final text response."""
|
|
118
|
+
rpc_id = str(uuid.uuid4())
|
|
119
|
+
parts: List[Dict[str, Any]] = [{"type": "text", "text": message_text}]
|
|
120
|
+
msg: Dict[str, Any] = {"role": "user", "parts": parts}
|
|
121
|
+
if lang:
|
|
122
|
+
msg["metadata"] = {"lang": lang}
|
|
123
|
+
params: Dict[str, Any] = {"id": rpc_id, "message": msg}
|
|
124
|
+
if session_id:
|
|
125
|
+
params["sessionId"] = session_id
|
|
126
|
+
if history:
|
|
127
|
+
params["history"] = [
|
|
128
|
+
{"role": t["role"],
|
|
129
|
+
"parts": [{"type": "text", "text": t["content"]}]}
|
|
130
|
+
for t in history
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
payload = {
|
|
134
|
+
"jsonrpc": self.JSONRPC_VERSION,
|
|
135
|
+
"id": rpc_id,
|
|
136
|
+
"method": "tasks/send",
|
|
137
|
+
"params": params,
|
|
138
|
+
}
|
|
139
|
+
resp = self._http.post(self.base_url, content=json.dumps(payload))
|
|
140
|
+
resp.raise_for_status()
|
|
141
|
+
return self._extract_text(resp.json(), rpc_id)
|
|
142
|
+
|
|
143
|
+
def stream_task(
|
|
144
|
+
self,
|
|
145
|
+
message_text: str,
|
|
146
|
+
session_id: Optional[str] = None,
|
|
147
|
+
lang: Optional[str] = None,
|
|
148
|
+
history: Optional[List[Dict[str, str]]] = None,
|
|
149
|
+
) -> Generator[str, None, None]:
|
|
150
|
+
"""Submit a task and yield text chunks via SSE (``tasks/sendSubscribe``)."""
|
|
151
|
+
rpc_id = str(uuid.uuid4())
|
|
152
|
+
parts: List[Dict[str, Any]] = [{"type": "text", "text": message_text}]
|
|
153
|
+
msg: Dict[str, Any] = {"role": "user", "parts": parts}
|
|
154
|
+
if lang:
|
|
155
|
+
msg["metadata"] = {"lang": lang}
|
|
156
|
+
params: Dict[str, Any] = {"id": rpc_id, "message": msg}
|
|
157
|
+
if session_id:
|
|
158
|
+
params["sessionId"] = session_id
|
|
159
|
+
if history:
|
|
160
|
+
params["history"] = [
|
|
161
|
+
{"role": t["role"],
|
|
162
|
+
"parts": [{"type": "text", "text": t["content"]}]}
|
|
163
|
+
for t in history
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
payload = {
|
|
167
|
+
"jsonrpc": self.JSONRPC_VERSION,
|
|
168
|
+
"id": rpc_id,
|
|
169
|
+
"method": "tasks/sendSubscribe",
|
|
170
|
+
"params": params,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
with self._http.stream(
|
|
174
|
+
"POST", self.base_url,
|
|
175
|
+
content=json.dumps(payload),
|
|
176
|
+
headers={"Accept": "text/event-stream"},
|
|
177
|
+
) as resp:
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
for line in resp.iter_lines():
|
|
180
|
+
line = line.strip()
|
|
181
|
+
if not line or not line.startswith("data:"):
|
|
182
|
+
continue
|
|
183
|
+
data_str = line[len("data:"):].strip()
|
|
184
|
+
if data_str == "[DONE]":
|
|
185
|
+
break
|
|
186
|
+
try:
|
|
187
|
+
event = json.loads(data_str)
|
|
188
|
+
except json.JSONDecodeError:
|
|
189
|
+
continue
|
|
190
|
+
chunk = self._extract_stream_chunk(event)
|
|
191
|
+
if chunk:
|
|
192
|
+
yield chunk
|
|
193
|
+
|
|
194
|
+
@staticmethod
|
|
195
|
+
def _extract_text(body: Dict[str, Any], rpc_id: str) -> str:
|
|
196
|
+
if "error" in body:
|
|
197
|
+
err = body["error"]
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
f"A2A RPC error {err.get('code')}: {err.get('message')}"
|
|
200
|
+
)
|
|
201
|
+
result = body.get("result", {})
|
|
202
|
+
for artifact in result.get("artifacts", []):
|
|
203
|
+
for part in artifact.get("parts", []):
|
|
204
|
+
if part.get("type") == "text":
|
|
205
|
+
return part["text"]
|
|
206
|
+
msg = result.get("message", {})
|
|
207
|
+
for part in msg.get("parts", []):
|
|
208
|
+
if part.get("type") == "text":
|
|
209
|
+
return part["text"]
|
|
210
|
+
LOG.warning(f"A2AClient: could not extract text from response: {body}")
|
|
211
|
+
return ""
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _extract_stream_chunk(event: Dict[str, Any]) -> str:
|
|
215
|
+
result = event.get("result", {})
|
|
216
|
+
for key in ("delta", "artifact"):
|
|
217
|
+
artifact = result.get(key, {})
|
|
218
|
+
for part in artifact.get("parts", []):
|
|
219
|
+
if part.get("type") == "text":
|
|
220
|
+
return part["text"]
|
|
221
|
+
return ""
|
|
222
|
+
|
|
223
|
+
def close(self) -> None:
|
|
224
|
+
self._http.close()
|
|
225
|
+
|
|
226
|
+
def __enter__(self) -> "A2AClient":
|
|
227
|
+
return self
|
|
228
|
+
|
|
229
|
+
def __exit__(self, *_: Any) -> None:
|
|
230
|
+
self.close()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hivemind-a2a-agent-plugin
|
|
3
|
+
Version: 0.1.0a2
|
|
4
|
+
Summary: A2A agent protocol plugin for HiveMind-core
|
|
5
|
+
Author-email: JarbasAi <jarbasai@mailfence.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/TigreGotico/hivemind-a2a-agent-plugin
|
|
8
|
+
Project-URL: Issues, https://github.com/TigreGotico/hivemind-a2a-agent-plugin/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: ovos-utils<1.0.0,>=0.8.2
|
|
13
|
+
Requires-Dist: hivemind-plugin-manager<1.0.0,>=0.5.0
|
|
14
|
+
Requires-Dist: httpx>=0.25.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
18
|
+
Requires-Dist: fastapi; extra == "test"
|
|
19
|
+
Requires-Dist: uvicorn; extra == "test"
|
|
20
|
+
Requires-Dist: httpx; extra == "test"
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
24
|
+
Requires-Dist: fastapi; extra == "dev"
|
|
25
|
+
Requires-Dist: uvicorn; extra == "dev"
|
|
26
|
+
Requires-Dist: httpx; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# hivemind-a2a-agent-plugin
|
|
30
|
+
|
|
31
|
+
A [HiveMind](https://github.com/JarbasHiveMind/HiveMind-core) agent-protocol plugin
|
|
32
|
+
that bridges the hive to external
|
|
33
|
+
[A2A (Agent-to-Agent)](https://google.github.io/A2A/) agents.
|
|
34
|
+
|
|
35
|
+
Natural-language queries arriving from hive satellites are forwarded to a configured
|
|
36
|
+
A2A server via JSON-RPC 2.0 (`tasks/send` or `tasks/sendSubscribe`), and the
|
|
37
|
+
response is streamed back to the originating satellite.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## What is A2A?
|
|
42
|
+
|
|
43
|
+
[A2A](https://google.github.io/A2A/) is an open protocol for agent interoperability.
|
|
44
|
+
An A2A server:
|
|
45
|
+
|
|
46
|
+
1. Publishes an **agent card** at `GET /.well-known/agent.json` describing its
|
|
47
|
+
capabilities, skills, and the URL that accepts tasks.
|
|
48
|
+
2. Accepts **task** requests as JSON-RPC 2.0 at its root URL — either a blocking
|
|
49
|
+
`tasks/send` or a streaming `tasks/sendSubscribe` (SSE).
|
|
50
|
+
|
|
51
|
+
Any compliant A2A server (LangChain agents, Google ADK, CrewAI, custom FastAPI
|
|
52
|
+
services, …) works as a backend for this plugin.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install hivemind-a2a-agent-plugin
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The plugin registers itself under the `hivemind.agent.protocol` entry-point group
|
|
63
|
+
so HiveMind-core discovers it automatically when it is installed.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Add the following to your OVOS / HiveMind config (typically
|
|
70
|
+
`~/.config/hivemind/hivemind.conf`):
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"hivemind": {
|
|
75
|
+
"agent_protocol": "hivemind-a2a-agent-plugin",
|
|
76
|
+
"a2a_agent": {
|
|
77
|
+
"agent_url": "http://localhost:9999",
|
|
78
|
+
"auth_header": "Bearer secret",
|
|
79
|
+
"timeout": 60,
|
|
80
|
+
"streaming": false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Key | Default | Description |
|
|
87
|
+
|--------------|---------|------------------------------------------------------|
|
|
88
|
+
| `agent_url` | — | **Required.** Root URL of the A2A server. |
|
|
89
|
+
| `auth_header`| — | Optional `Authorization` header (e.g. `Bearer …`). |
|
|
90
|
+
| `timeout` | `60` | HTTP timeout in seconds. |
|
|
91
|
+
| `streaming` | `false` | Prefer `tasks/sendSubscribe` (SSE) when `true`. |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Hive wiring example
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
HiveMind master
|
|
99
|
+
└── hivemind-a2a-agent-plugin ← loaded as agent_protocol
|
|
100
|
+
└── A2A server (http://localhost:9999)
|
|
101
|
+
└── your LLM / agent / tool backend
|
|
102
|
+
|
|
103
|
+
Satellite (voice client, phone, …)
|
|
104
|
+
→ "what's the capital of France?"
|
|
105
|
+
→ HiveMind master receives utterance
|
|
106
|
+
→ plugin forwards to A2A server via tasks/send
|
|
107
|
+
→ A2A server responds: "Paris is the capital of France."
|
|
108
|
+
→ HiveMind master streams answer back to satellite
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Start a minimal FastAPI A2A server and the HiveMind master:**
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# 1. run the example mock A2A server (also used by e2e tests)
|
|
115
|
+
uvicorn tests.e2e.mock_a2a_server:app --port 9999
|
|
116
|
+
|
|
117
|
+
# 2. configure hivemind-core to use this plugin (see above)
|
|
118
|
+
# 3. start hivemind-core
|
|
119
|
+
hivemind-core listen
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Session / context mapping
|
|
125
|
+
|
|
126
|
+
The plugin maps the HiveMind `session_id` directly to the A2A `sessionId` parameter
|
|
127
|
+
so multi-turn conversations are kept in context on the A2A server side. No extra
|
|
128
|
+
state is stored in the plugin; the A2A server owns conversation history.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Error handling
|
|
133
|
+
|
|
134
|
+
The plugin never silences errors. If the A2A server is unreachable, returns an
|
|
135
|
+
empty response, or returns a JSON-RPC error object, a human-readable error string
|
|
136
|
+
is yielded to the satellite so the user always receives a reply.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
git clone https://github.com/TigreGotico/hivemind-a2a-agent-plugin
|
|
144
|
+
cd hivemind-a2a-agent-plugin
|
|
145
|
+
pip install -e ".[dev]"
|
|
146
|
+
pytest tests/ # unit tests (no server needed)
|
|
147
|
+
pytest tests/e2e/ # e2e tests (spins up a FastAPI mock server in-process)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Credits
|
|
151
|
+
|
|
152
|
+
Funded by [NGI0 Commons Fund](https://nlnet.nl/project/OpenVoiceOS) / [NLnet](https://nlnet.nl)
|
|
153
|
+
under grant agreement No [101135429](https://cordis.europa.eu/project/id/101135429),
|
|
154
|
+
through the European Commission's [Next Generation Internet](https://ngi.eu) programme.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
hivemind_a2a_agent_plugin/__init__.py
|
|
5
|
+
hivemind_a2a_agent_plugin/_client.py
|
|
6
|
+
hivemind_a2a_agent_plugin/version.py
|
|
7
|
+
hivemind_a2a_agent_plugin.egg-info/PKG-INFO
|
|
8
|
+
hivemind_a2a_agent_plugin.egg-info/SOURCES.txt
|
|
9
|
+
hivemind_a2a_agent_plugin.egg-info/dependency_links.txt
|
|
10
|
+
hivemind_a2a_agent_plugin.egg-info/entry_points.txt
|
|
11
|
+
hivemind_a2a_agent_plugin.egg-info/requires.txt
|
|
12
|
+
hivemind_a2a_agent_plugin.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
14
|
+
tests/test_protocol.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hivemind_a2a_agent_plugin
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=42", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hivemind-a2a-agent-plugin"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "A2A agent protocol plugin for HiveMind-core"
|
|
9
|
+
license = { text = "Apache-2.0" }
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "JarbasAi", email = "jarbasai@mailfence.com" }
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
dependencies = [
|
|
16
|
+
"ovos-utils>=0.8.2,<1.0.0",
|
|
17
|
+
"hivemind-plugin-manager>=0.5.0,<1.0.0",
|
|
18
|
+
"httpx>=0.25.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
test = [
|
|
23
|
+
"pytest",
|
|
24
|
+
"pytest-cov",
|
|
25
|
+
"fastapi",
|
|
26
|
+
"uvicorn",
|
|
27
|
+
"httpx",
|
|
28
|
+
]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest",
|
|
31
|
+
"pytest-cov",
|
|
32
|
+
"fastapi",
|
|
33
|
+
"uvicorn",
|
|
34
|
+
"httpx",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/TigreGotico/hivemind-a2a-agent-plugin"
|
|
39
|
+
Issues = "https://github.com/TigreGotico/hivemind-a2a-agent-plugin/issues"
|
|
40
|
+
|
|
41
|
+
[project.entry-points."hivemind.agent.protocol"]
|
|
42
|
+
"hivemind-a2a-agent-plugin" = "hivemind_a2a_agent_plugin:A2AAgentProtocol"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.dynamic]
|
|
45
|
+
version = { attr = "hivemind_a2a_agent_plugin.version.__version__" }
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["."]
|
|
49
|
+
include = ["hivemind_a2a_agent_plugin*"]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Unit tests for the A2A client — mock HTTP, no real server."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from hivemind_a2a_agent_plugin._client import A2AClient, AgentCard, AgentSkill
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Helpers — build minimal JSON-RPC response bodies
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
def _send_response(text: str, rpc_id: str = "test-id") -> dict:
|
|
17
|
+
return {
|
|
18
|
+
"jsonrpc": "2.0",
|
|
19
|
+
"id": rpc_id,
|
|
20
|
+
"result": {
|
|
21
|
+
"artifacts": [
|
|
22
|
+
{"parts": [{"type": "text", "text": text}]}
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _error_response(code: int, message: str, rpc_id: str = "test-id") -> dict:
|
|
29
|
+
return {
|
|
30
|
+
"jsonrpc": "2.0",
|
|
31
|
+
"id": rpc_id,
|
|
32
|
+
"error": {"code": code, "message": message},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
AGENT_CARD_DICT = {
|
|
37
|
+
"name": "TestAgent",
|
|
38
|
+
"description": "A test agent",
|
|
39
|
+
"url": "http://localhost:9999",
|
|
40
|
+
"version": "1.0",
|
|
41
|
+
"capabilities": {"streaming": True},
|
|
42
|
+
"skills": [
|
|
43
|
+
{
|
|
44
|
+
"id": "qa",
|
|
45
|
+
"name": "Q&A",
|
|
46
|
+
"description": "Answers questions",
|
|
47
|
+
"tags": ["qa"],
|
|
48
|
+
"examples": ["What is 2+2?"],
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# AgentCard / AgentSkill parsing
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
class TestAgentCard:
|
|
59
|
+
def test_from_dict_basic(self):
|
|
60
|
+
card = AgentCard.from_dict(AGENT_CARD_DICT)
|
|
61
|
+
assert card.name == "TestAgent"
|
|
62
|
+
assert card.streaming is True
|
|
63
|
+
assert len(card.skills) == 1
|
|
64
|
+
assert card.skills[0].id == "qa"
|
|
65
|
+
|
|
66
|
+
def test_from_dict_no_capabilities(self):
|
|
67
|
+
d = {**AGENT_CARD_DICT, "capabilities": {}}
|
|
68
|
+
card = AgentCard.from_dict(d)
|
|
69
|
+
assert card.streaming is False
|
|
70
|
+
|
|
71
|
+
def test_from_dict_no_skills(self):
|
|
72
|
+
d = {**AGENT_CARD_DICT, "skills": []}
|
|
73
|
+
card = AgentCard.from_dict(d)
|
|
74
|
+
assert card.skills == []
|
|
75
|
+
|
|
76
|
+
def test_skill_from_dict(self):
|
|
77
|
+
skill = AgentSkill.from_dict(AGENT_CARD_DICT["skills"][0])
|
|
78
|
+
assert skill.id == "qa"
|
|
79
|
+
assert "qa" in skill.tags
|
|
80
|
+
assert skill.examples == ["What is 2+2?"]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# A2AClient.fetch_agent_card
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
class TestFetchAgentCard:
|
|
88
|
+
def test_fetch_success(self, httpx_mock):
|
|
89
|
+
httpx_mock.add_response(
|
|
90
|
+
method="GET",
|
|
91
|
+
url="http://localhost:9999/.well-known/agent.json",
|
|
92
|
+
json=AGENT_CARD_DICT,
|
|
93
|
+
)
|
|
94
|
+
client = A2AClient("http://localhost:9999")
|
|
95
|
+
card = client.fetch_agent_card()
|
|
96
|
+
assert card.name == "TestAgent"
|
|
97
|
+
assert card.streaming is True
|
|
98
|
+
|
|
99
|
+
def test_fetch_http_error(self, httpx_mock):
|
|
100
|
+
httpx_mock.add_response(
|
|
101
|
+
method="GET",
|
|
102
|
+
url="http://localhost:9999/.well-known/agent.json",
|
|
103
|
+
status_code=404,
|
|
104
|
+
)
|
|
105
|
+
client = A2AClient("http://localhost:9999")
|
|
106
|
+
with pytest.raises(httpx.HTTPStatusError):
|
|
107
|
+
client.fetch_agent_card()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# A2AClient.send_task
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
class TestSendTask:
|
|
115
|
+
def test_send_basic(self, httpx_mock):
|
|
116
|
+
httpx_mock.add_response(
|
|
117
|
+
method="POST",
|
|
118
|
+
url="http://localhost:9999",
|
|
119
|
+
json=_send_response("Paris is the capital of France."),
|
|
120
|
+
)
|
|
121
|
+
client = A2AClient("http://localhost:9999")
|
|
122
|
+
result = client.send_task("What is the capital of France?", lang="en-us")
|
|
123
|
+
assert result == "Paris is the capital of France."
|
|
124
|
+
|
|
125
|
+
def test_send_with_session(self, httpx_mock):
|
|
126
|
+
def _capture(request: httpx.Request):
|
|
127
|
+
body = json.loads(request.content)
|
|
128
|
+
assert body["params"]["sessionId"] == "sess-123"
|
|
129
|
+
return httpx.Response(200, json=_send_response("ok"))
|
|
130
|
+
|
|
131
|
+
httpx_mock.add_callback(_capture, method="POST", url="http://localhost:9999")
|
|
132
|
+
client = A2AClient("http://localhost:9999")
|
|
133
|
+
result = client.send_task("hello", session_id="sess-123")
|
|
134
|
+
assert result == "ok"
|
|
135
|
+
|
|
136
|
+
def test_send_rpc_error(self, httpx_mock):
|
|
137
|
+
httpx_mock.add_response(
|
|
138
|
+
method="POST",
|
|
139
|
+
url="http://localhost:9999",
|
|
140
|
+
json=_error_response(-32603, "internal error"),
|
|
141
|
+
)
|
|
142
|
+
client = A2AClient("http://localhost:9999")
|
|
143
|
+
with pytest.raises(RuntimeError, match="A2A RPC error"):
|
|
144
|
+
client.send_task("hello")
|
|
145
|
+
|
|
146
|
+
def test_send_empty_response(self, httpx_mock):
|
|
147
|
+
httpx_mock.add_response(
|
|
148
|
+
method="POST",
|
|
149
|
+
url="http://localhost:9999",
|
|
150
|
+
json={"jsonrpc": "2.0", "id": "x", "result": {}},
|
|
151
|
+
)
|
|
152
|
+
client = A2AClient("http://localhost:9999")
|
|
153
|
+
result = client.send_task("hello")
|
|
154
|
+
assert result == ""
|
|
155
|
+
|
|
156
|
+
def test_send_message_fallback(self, httpx_mock):
|
|
157
|
+
"""result.message fallback path."""
|
|
158
|
+
httpx_mock.add_response(
|
|
159
|
+
method="POST",
|
|
160
|
+
url="http://localhost:9999",
|
|
161
|
+
json={
|
|
162
|
+
"jsonrpc": "2.0",
|
|
163
|
+
"id": "x",
|
|
164
|
+
"result": {
|
|
165
|
+
"message": {"parts": [{"type": "text", "text": "fallback answer"}]}
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
client = A2AClient("http://localhost:9999")
|
|
170
|
+
result = client.send_task("hello")
|
|
171
|
+
assert result == "fallback answer"
|
|
172
|
+
|
|
173
|
+
def test_send_auth_header_forwarded(self, httpx_mock):
|
|
174
|
+
def _capture(request: httpx.Request):
|
|
175
|
+
assert request.headers["authorization"] == "Bearer secret"
|
|
176
|
+
return httpx.Response(200, json=_send_response("authed"))
|
|
177
|
+
|
|
178
|
+
httpx_mock.add_callback(_capture, method="POST", url="http://localhost:9999")
|
|
179
|
+
client = A2AClient("http://localhost:9999", auth_header="Bearer secret")
|
|
180
|
+
result = client.send_task("hello")
|
|
181
|
+
assert result == "authed"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
# A2AClient.stream_task (SSE)
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def _sse_body(*chunks: str, done: bool = True) -> bytes:
|
|
189
|
+
lines = []
|
|
190
|
+
for chunk in chunks:
|
|
191
|
+
event = {
|
|
192
|
+
"result": {
|
|
193
|
+
"delta": {"parts": [{"type": "text", "text": chunk}]}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
lines.append(f"data: {json.dumps(event)}\n\n")
|
|
197
|
+
if done:
|
|
198
|
+
lines.append("data: [DONE]\n\n")
|
|
199
|
+
return "".join(lines).encode()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class TestStreamTask:
|
|
203
|
+
def test_stream_basic(self, httpx_mock):
|
|
204
|
+
httpx_mock.add_response(
|
|
205
|
+
method="POST",
|
|
206
|
+
url="http://localhost:9999",
|
|
207
|
+
content=_sse_body("Hello", " world"),
|
|
208
|
+
headers={"Content-Type": "text/event-stream"},
|
|
209
|
+
)
|
|
210
|
+
client = A2AClient("http://localhost:9999", streaming=True)
|
|
211
|
+
chunks = list(client.stream_task("hi"))
|
|
212
|
+
assert chunks == ["Hello", " world"]
|
|
213
|
+
|
|
214
|
+
def test_stream_skips_empty_lines(self, httpx_mock):
|
|
215
|
+
body = b"data: \n\ndata: [DONE]\n\n"
|
|
216
|
+
httpx_mock.add_response(
|
|
217
|
+
method="POST",
|
|
218
|
+
url="http://localhost:9999",
|
|
219
|
+
content=body,
|
|
220
|
+
headers={"Content-Type": "text/event-stream"},
|
|
221
|
+
)
|
|
222
|
+
client = A2AClient("http://localhost:9999", streaming=True)
|
|
223
|
+
chunks = list(client.stream_task("hi"))
|
|
224
|
+
assert chunks == []
|
|
225
|
+
|
|
226
|
+
def test_stream_artifact_key(self, httpx_mock):
|
|
227
|
+
event = {
|
|
228
|
+
"result": {
|
|
229
|
+
"artifact": {"parts": [{"type": "text", "text": "artifact chunk"}]}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
body = f"data: {json.dumps(event)}\n\ndata: [DONE]\n\n".encode()
|
|
233
|
+
httpx_mock.add_response(
|
|
234
|
+
method="POST",
|
|
235
|
+
url="http://localhost:9999",
|
|
236
|
+
content=body,
|
|
237
|
+
headers={"Content-Type": "text/event-stream"},
|
|
238
|
+
)
|
|
239
|
+
client = A2AClient("http://localhost:9999", streaming=True)
|
|
240
|
+
chunks = list(client.stream_task("hi"))
|
|
241
|
+
assert chunks == ["artifact chunk"]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Unit tests for A2AAgentProtocol — mock A2AClient, no HTTP."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from hivemind_a2a_agent_plugin import A2AAgentProtocol
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
# Helpers
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
def _protocol(agent_url: str = "http://a2a.test", streaming: bool = False,
|
|
18
|
+
**extra) -> A2AAgentProtocol:
|
|
19
|
+
cfg = {"agent_url": agent_url, "streaming": streaming, **extra}
|
|
20
|
+
with patch("hivemind_a2a_agent_plugin._client.A2AClient") as MockClient:
|
|
21
|
+
instance = MockClient.return_value
|
|
22
|
+
instance.streaming = streaming
|
|
23
|
+
proto = A2AAgentProtocol(config=cfg)
|
|
24
|
+
proto._client = instance
|
|
25
|
+
return proto
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Tests
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
class TestProtocolInit:
|
|
33
|
+
def test_no_url_yields_error(self):
|
|
34
|
+
proto = A2AAgentProtocol(config={})
|
|
35
|
+
chunks = list(proto.natural_language_query("hello", "en-us"))
|
|
36
|
+
assert chunks[-1] is None
|
|
37
|
+
assert "not configured" in chunks[0].lower()
|
|
38
|
+
|
|
39
|
+
def test_url_creates_client(self):
|
|
40
|
+
with patch("hivemind_a2a_agent_plugin.A2AClient") as MockClient:
|
|
41
|
+
proto = A2AAgentProtocol(config={"agent_url": "http://a2a.test"})
|
|
42
|
+
MockClient.assert_called_once()
|
|
43
|
+
call_kwargs = MockClient.call_args
|
|
44
|
+
assert call_kwargs.kwargs["base_url"] == "http://a2a.test"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestNaturalLanguageQueryBlocking:
|
|
48
|
+
def test_basic_answer(self):
|
|
49
|
+
proto = _protocol()
|
|
50
|
+
proto._client.streaming = False
|
|
51
|
+
proto._client.send_task.return_value = "Paris."
|
|
52
|
+
|
|
53
|
+
chunks = list(proto.natural_language_query("capital of France?", "en-us"))
|
|
54
|
+
assert chunks == ["Paris.", None]
|
|
55
|
+
|
|
56
|
+
def test_empty_response_fallback(self):
|
|
57
|
+
proto = _protocol()
|
|
58
|
+
proto._client.streaming = False
|
|
59
|
+
proto._client.send_task.return_value = ""
|
|
60
|
+
|
|
61
|
+
chunks = list(proto.natural_language_query("hello", "en-us"))
|
|
62
|
+
assert chunks[-1] is None
|
|
63
|
+
assert "empty" in chunks[0].lower()
|
|
64
|
+
|
|
65
|
+
def test_session_id_forwarded(self):
|
|
66
|
+
proto = _protocol()
|
|
67
|
+
proto._client.streaming = False
|
|
68
|
+
proto._client.send_task.return_value = "ok"
|
|
69
|
+
|
|
70
|
+
list(proto.natural_language_query("hi", "en-us", session_id="sid-42"))
|
|
71
|
+
call_kwargs = proto._client.send_task.call_args
|
|
72
|
+
assert call_kwargs.kwargs.get("session_id") == "sid-42"
|
|
73
|
+
|
|
74
|
+
def test_lang_forwarded(self):
|
|
75
|
+
proto = _protocol()
|
|
76
|
+
proto._client.streaming = False
|
|
77
|
+
proto._client.send_task.return_value = "ok"
|
|
78
|
+
|
|
79
|
+
list(proto.natural_language_query("hi", "pt-pt", session_id="x"))
|
|
80
|
+
call_kwargs = proto._client.send_task.call_args
|
|
81
|
+
assert call_kwargs.kwargs.get("lang") == "pt-pt"
|
|
82
|
+
|
|
83
|
+
def test_exception_yields_error_not_silence(self):
|
|
84
|
+
proto = _protocol()
|
|
85
|
+
proto._client.streaming = False
|
|
86
|
+
proto._client.send_task.side_effect = RuntimeError("connection refused")
|
|
87
|
+
|
|
88
|
+
chunks = list(proto.natural_language_query("hello", "en-us"))
|
|
89
|
+
assert chunks[-1] is None
|
|
90
|
+
assert len(chunks) == 2
|
|
91
|
+
assert "connection refused" in chunks[0].lower() or "error" in chunks[0].lower()
|
|
92
|
+
|
|
93
|
+
def test_rpc_error_yields_human_readable(self):
|
|
94
|
+
proto = _protocol()
|
|
95
|
+
proto._client.streaming = False
|
|
96
|
+
proto._client.send_task.side_effect = RuntimeError("A2A RPC error -32603: internal")
|
|
97
|
+
|
|
98
|
+
chunks = list(proto.natural_language_query("hello", "en-us"))
|
|
99
|
+
assert any("error" in c.lower() for c in chunks if c is not None)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestNaturalLanguageQueryStreaming:
|
|
103
|
+
def _stream_proto(self, chunks_to_yield: List[str]) -> A2AAgentProtocol:
|
|
104
|
+
proto = _protocol(streaming=True)
|
|
105
|
+
proto._client.streaming = True
|
|
106
|
+
proto._client.stream_task.return_value = iter(chunks_to_yield)
|
|
107
|
+
return proto
|
|
108
|
+
|
|
109
|
+
def test_streaming_yields_chunks(self):
|
|
110
|
+
proto = self._stream_proto(["Hello", " world"])
|
|
111
|
+
chunks = list(proto.natural_language_query("hi", "en-us"))
|
|
112
|
+
assert chunks == ["Hello", " world", None]
|
|
113
|
+
|
|
114
|
+
def test_streaming_empty_fallback(self):
|
|
115
|
+
proto = self._stream_proto([])
|
|
116
|
+
chunks = list(proto.natural_language_query("hi", "en-us"))
|
|
117
|
+
assert chunks[-1] is None
|
|
118
|
+
assert "empty" in chunks[0].lower()
|
|
119
|
+
|
|
120
|
+
def test_streaming_exception_yields_error(self):
|
|
121
|
+
proto = _protocol(streaming=True)
|
|
122
|
+
proto._client.streaming = True
|
|
123
|
+
proto._client.stream_task.side_effect = Exception("SSE broken")
|
|
124
|
+
chunks = list(proto.natural_language_query("hi", "en-us"))
|
|
125
|
+
assert chunks[-1] is None
|
|
126
|
+
assert any("error" in c.lower() or "sse" in c.lower()
|
|
127
|
+
for c in chunks if c)
|
|
128
|
+
|
|
129
|
+
def test_sentinel_always_last(self):
|
|
130
|
+
"""None sentinel must be the last yielded value."""
|
|
131
|
+
proto = self._stream_proto(["a", "b", "c"])
|
|
132
|
+
chunks = list(proto.natural_language_query("q", "en-us"))
|
|
133
|
+
assert chunks[-1] is None
|
|
134
|
+
assert chunks[:-1] == ["a", "b", "c"]
|