hivemind-a2a-agent-plugin 0.1.0a2__py3-none-any.whl
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/__init__.py +157 -0
- hivemind_a2a_agent_plugin/_client.py +230 -0
- hivemind_a2a_agent_plugin/version.py +8 -0
- hivemind_a2a_agent_plugin-0.1.0a2.dist-info/METADATA +154 -0
- hivemind_a2a_agent_plugin-0.1.0a2.dist-info/RECORD +9 -0
- hivemind_a2a_agent_plugin-0.1.0a2.dist-info/WHEEL +5 -0
- hivemind_a2a_agent_plugin-0.1.0a2.dist-info/entry_points.txt +2 -0
- hivemind_a2a_agent_plugin-0.1.0a2.dist-info/licenses/LICENSE +17 -0
- hivemind_a2a_agent_plugin-0.1.0a2.dist-info/top_level.txt +1 -0
|
@@ -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,9 @@
|
|
|
1
|
+
hivemind_a2a_agent_plugin/__init__.py,sha256=ua_J2a-6DOWpJFOje5-FkVF2tOCSJexQ57U0YkZ-MUE,5795
|
|
2
|
+
hivemind_a2a_agent_plugin/_client.py,sha256=f62WCe1uPXgON8sgVO__difY54cLUFOBgpeipQkRpto,7698
|
|
3
|
+
hivemind_a2a_agent_plugin/version.py,sha256=7c51Az-oGaEN_y0AvP9KO0mPZdqtsC6jApogoAQ2ya8,229
|
|
4
|
+
hivemind_a2a_agent_plugin-0.1.0a2.dist-info/licenses/LICENSE,sha256=CCh66Pv-YGCzi6ysUq5rGBsPZ2Vew0zEq-d-xDJZd-M,737
|
|
5
|
+
hivemind_a2a_agent_plugin-0.1.0a2.dist-info/METADATA,sha256=mTUpAXl5whu0AHREVH5yUpiOTIgBZ-RmigiCodzGf_g,4979
|
|
6
|
+
hivemind_a2a_agent_plugin-0.1.0a2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
hivemind_a2a_agent_plugin-0.1.0a2.dist-info/entry_points.txt,sha256=DFhcZN-HxWF2l9UaaGVHlZjkwbrSqIT7JqLSSIAzBnY,97
|
|
8
|
+
hivemind_a2a_agent_plugin-0.1.0a2.dist-info/top_level.txt,sha256=P0_ME7kyTGv9vWK3whGMgtuJtXQHtzM2Nmtm1mJonY4,26
|
|
9
|
+
hivemind_a2a_agent_plugin-0.1.0a2.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
hivemind_a2a_agent_plugin
|