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.
@@ -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,8 @@
1
+ # START_VERSION_BLOCK
2
+ VERSION_MAJOR = 0
3
+ VERSION_MINOR = 1
4
+ VERSION_BUILD = 0
5
+ VERSION_ALPHA = 2
6
+ # END_VERSION_BLOCK
7
+
8
+ __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
@@ -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,2 @@
1
+ [hivemind.agent.protocol]
2
+ hivemind-a2a-agent-plugin = hivemind_a2a_agent_plugin:A2AAgentProtocol
@@ -0,0 +1,17 @@
1
+ ovos-utils<1.0.0,>=0.8.2
2
+ hivemind-plugin-manager<1.0.0,>=0.5.0
3
+ httpx>=0.25.0
4
+
5
+ [dev]
6
+ pytest
7
+ pytest-cov
8
+ fastapi
9
+ uvicorn
10
+ httpx
11
+
12
+ [test]
13
+ pytest
14
+ pytest-cov
15
+ fastapi
16
+ uvicorn
17
+ httpx
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]