astropods-adapter-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ dist/
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: astropods-adapter-core
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic bridge between agent adapters and the Astropods messaging service
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: astropods-messaging>=0.0.4
8
+ Requires-Dist: grpcio>=1.62
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # astropods-adapter-core
15
+
16
+ Framework-agnostic bridge between Python agents and the Astropods messaging service.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install astropods-adapter-core
22
+ ```
23
+
24
+ Requires Python 3.10+.
25
+
26
+ ## Usage
27
+
28
+ If you're using a supported framework, use the pre-built adapter package instead (e.g. `astropods-adapter-langchain`). Use this package directly to connect a custom or unsupported framework.
29
+
30
+ Create a class with `name`, `stream`, and `get_config`, then call `serve()`:
31
+
32
+ ```python
33
+ from astropods_adapter_core import StreamHooks, StreamOptions, serve
34
+
35
+ class MyAdapter:
36
+ name = "My Agent"
37
+
38
+ async def stream(self, prompt: str, hooks: StreamHooks, options: StreamOptions) -> None:
39
+ try:
40
+ hooks.on_chunk("Hello!")
41
+ hooks.on_finish()
42
+ except Exception as e:
43
+ hooks.on_error(e)
44
+
45
+ def get_config(self) -> dict:
46
+ return {"system_prompt": "You are a helpful assistant.", "tools": []}
47
+
48
+ serve(MyAdapter())
49
+ ```
50
+
51
+ `serve()` blocks until `SIGINT` or `SIGTERM`. Under `ast dev`, `GRPC_SERVER_ADDR` is injected automatically.
52
+
53
+ ## API
54
+
55
+ ### `AgentAdapter` protocol
56
+
57
+ | Member | Description |
58
+ |--------|-------------|
59
+ | `name: str` | Display name used in logs and registration |
60
+ | `async stream(prompt, hooks, options)` | Stream a response, invoking hooks as the agent progresses |
61
+ | `get_config() -> dict` | Return `{"system_prompt": str, "tools": [...]}` for playground display |
62
+
63
+ ### `StreamHooks`
64
+
65
+ Call these inside `stream()` as the agent produces output:
66
+
67
+ | Method | When to call |
68
+ |--------|-------------|
69
+ | `on_chunk(text)` | Each text token or fragment from the LLM |
70
+ | `on_status_update({"status": "..."})` | Agent state change — valid values: `THINKING`, `SEARCHING`, `GENERATING`, `PROCESSING`, `ANALYZING`, `CUSTOM` |
71
+ | `on_finish()` | Response complete — call exactly once per request |
72
+ | `on_error(exception)` | Error occurred — call instead of `on_finish` |
73
+
74
+ For `CUSTOM` status, include `"custom_message"` in the dict:
75
+
76
+ ```python
77
+ hooks.on_status_update({"status": "CUSTOM", "custom_message": "Fetching data..."})
78
+ ```
79
+
80
+ ### `StreamOptions`
81
+
82
+ Per-request context passed to `stream()`:
83
+
84
+ | Field | Description |
85
+ |-------|-------------|
86
+ | `conversation_id` | Stable ID for the conversation thread |
87
+ | `user_id` | ID of the user who sent the message |
88
+
89
+ ### `serve(adapter, options?)`
90
+
91
+ Connects the adapter to the messaging service and blocks until shutdown.
92
+
93
+ ```python
94
+ from astropods_adapter_core import ServeOptions, serve
95
+
96
+ # Override the gRPC address (default: GRPC_SERVER_ADDR env var or localhost:9090)
97
+ serve(adapter, ServeOptions(server_address="astro-messaging:9090"))
98
+ ```
99
+
100
+ ### `MessagingBridge`
101
+
102
+ `serve()` is a thin wrapper around `MessagingBridge`. Use it directly if you need lifecycle control:
103
+
104
+ ```python
105
+ import asyncio
106
+ from astropods_adapter_core import MessagingBridge
107
+
108
+ bridge = MessagingBridge(adapter)
109
+ asyncio.run(bridge.start())
110
+ ```
@@ -0,0 +1,97 @@
1
+ # astropods-adapter-core
2
+
3
+ Framework-agnostic bridge between Python agents and the Astropods messaging service.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install astropods-adapter-core
9
+ ```
10
+
11
+ Requires Python 3.10+.
12
+
13
+ ## Usage
14
+
15
+ If you're using a supported framework, use the pre-built adapter package instead (e.g. `astropods-adapter-langchain`). Use this package directly to connect a custom or unsupported framework.
16
+
17
+ Create a class with `name`, `stream`, and `get_config`, then call `serve()`:
18
+
19
+ ```python
20
+ from astropods_adapter_core import StreamHooks, StreamOptions, serve
21
+
22
+ class MyAdapter:
23
+ name = "My Agent"
24
+
25
+ async def stream(self, prompt: str, hooks: StreamHooks, options: StreamOptions) -> None:
26
+ try:
27
+ hooks.on_chunk("Hello!")
28
+ hooks.on_finish()
29
+ except Exception as e:
30
+ hooks.on_error(e)
31
+
32
+ def get_config(self) -> dict:
33
+ return {"system_prompt": "You are a helpful assistant.", "tools": []}
34
+
35
+ serve(MyAdapter())
36
+ ```
37
+
38
+ `serve()` blocks until `SIGINT` or `SIGTERM`. Under `ast dev`, `GRPC_SERVER_ADDR` is injected automatically.
39
+
40
+ ## API
41
+
42
+ ### `AgentAdapter` protocol
43
+
44
+ | Member | Description |
45
+ |--------|-------------|
46
+ | `name: str` | Display name used in logs and registration |
47
+ | `async stream(prompt, hooks, options)` | Stream a response, invoking hooks as the agent progresses |
48
+ | `get_config() -> dict` | Return `{"system_prompt": str, "tools": [...]}` for playground display |
49
+
50
+ ### `StreamHooks`
51
+
52
+ Call these inside `stream()` as the agent produces output:
53
+
54
+ | Method | When to call |
55
+ |--------|-------------|
56
+ | `on_chunk(text)` | Each text token or fragment from the LLM |
57
+ | `on_status_update({"status": "..."})` | Agent state change — valid values: `THINKING`, `SEARCHING`, `GENERATING`, `PROCESSING`, `ANALYZING`, `CUSTOM` |
58
+ | `on_finish()` | Response complete — call exactly once per request |
59
+ | `on_error(exception)` | Error occurred — call instead of `on_finish` |
60
+
61
+ For `CUSTOM` status, include `"custom_message"` in the dict:
62
+
63
+ ```python
64
+ hooks.on_status_update({"status": "CUSTOM", "custom_message": "Fetching data..."})
65
+ ```
66
+
67
+ ### `StreamOptions`
68
+
69
+ Per-request context passed to `stream()`:
70
+
71
+ | Field | Description |
72
+ |-------|-------------|
73
+ | `conversation_id` | Stable ID for the conversation thread |
74
+ | `user_id` | ID of the user who sent the message |
75
+
76
+ ### `serve(adapter, options?)`
77
+
78
+ Connects the adapter to the messaging service and blocks until shutdown.
79
+
80
+ ```python
81
+ from astropods_adapter_core import ServeOptions, serve
82
+
83
+ # Override the gRPC address (default: GRPC_SERVER_ADDR env var or localhost:9090)
84
+ serve(adapter, ServeOptions(server_address="astro-messaging:9090"))
85
+ ```
86
+
87
+ ### `MessagingBridge`
88
+
89
+ `serve()` is a thin wrapper around `MessagingBridge`. Use it directly if you need lifecycle control:
90
+
91
+ ```python
92
+ import asyncio
93
+ from astropods_adapter_core import MessagingBridge
94
+
95
+ bridge = MessagingBridge(adapter)
96
+ asyncio.run(bridge.start())
97
+ ```
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "astropods-adapter-core"
7
+ version = "0.1.0"
8
+ description = "Framework-agnostic bridge between agent adapters and the Astropods messaging service"
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "astropods-messaging>=0.0.4",
14
+ "grpcio>=1.62",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = [
19
+ "pytest>=8.0",
20
+ "pytest-asyncio>=0.23",
21
+ ]
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/astropods_adapter_core"]
25
+
26
+ [tool.pytest.ini_options]
27
+ asyncio_mode = "auto"
28
+ testpaths = ["tests"]
@@ -0,0 +1,12 @@
1
+ from .types import AgentAdapter, StreamHooks, StreamOptions, ServeOptions
2
+ from .bridge import MessagingBridge
3
+ from .serve import serve
4
+
5
+ __all__ = [
6
+ "AgentAdapter",
7
+ "StreamHooks",
8
+ "StreamOptions",
9
+ "ServeOptions",
10
+ "MessagingBridge",
11
+ "serve",
12
+ ]
@@ -0,0 +1,304 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import signal
7
+ from typing import Optional
8
+
9
+ import grpc
10
+ import grpc.aio
11
+
12
+ from astropods_messaging import (
13
+ AgentMessagingStub,
14
+ AgentConfig,
15
+ AgentToolConfig,
16
+ AgentResponse,
17
+ ContentChunk,
18
+ ConversationRequest,
19
+ ErrorResponse,
20
+ HealthCheckRequest,
21
+ Message,
22
+ StatusUpdate,
23
+ User,
24
+ )
25
+
26
+ from .types import AgentAdapter, ServeOptions, StreamHooks, StreamOptions
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ DEFAULT_SERVER_ADDR = "localhost:9090"
31
+ MAX_RETRIES = 10
32
+ INITIAL_DELAY_MS = 500
33
+ MAX_DELAY_MS = 15000
34
+
35
+
36
+ def _debug(*args: object) -> None:
37
+ if os.environ.get("DEBUG"):
38
+ logger.debug(*args)
39
+
40
+
41
+ class _StreamHooksImpl:
42
+ """Concrete StreamHooks that enqueues gRPC messages for the writer task."""
43
+
44
+ def __init__(self, conversation_id: str, write_queue: asyncio.Queue) -> None:
45
+ self._conversation_id = conversation_id
46
+ self._write_queue = write_queue
47
+ self._finished = False
48
+
49
+ def _enqueue(self, request: ConversationRequest) -> None:
50
+ self._write_queue.put_nowait(request)
51
+
52
+ def on_chunk(self, text: str) -> None:
53
+ if self._finished:
54
+ return
55
+ chunk = ContentChunk(
56
+ type=ContentChunk.ChunkType.Value("DELTA"),
57
+ content=text,
58
+ )
59
+ response = AgentResponse(
60
+ conversation_id=self._conversation_id,
61
+ content=chunk,
62
+ )
63
+ self._enqueue(ConversationRequest(agent_response=response))
64
+
65
+ def on_status_update(self, status: dict) -> None:
66
+ if self._finished:
67
+ return
68
+ status_str = status.get("status", "THINKING")
69
+ custom_message = status.get("custom_message", "")
70
+ try:
71
+ status_value = StatusUpdate.Status.Value(status_str)
72
+ except ValueError:
73
+ status_value = StatusUpdate.Status.Value("THINKING")
74
+ update = StatusUpdate(status=status_value, custom_message=custom_message)
75
+ response = AgentResponse(
76
+ conversation_id=self._conversation_id,
77
+ status=update,
78
+ )
79
+ self._enqueue(ConversationRequest(agent_response=response))
80
+
81
+ def on_error(self, error: Exception) -> None:
82
+ if self._finished:
83
+ return
84
+ self._finished = True
85
+ err = ErrorResponse(
86
+ code=ErrorResponse.ErrorCode.Value("AGENT_ERROR"),
87
+ message=str(error),
88
+ )
89
+ response = AgentResponse(
90
+ conversation_id=self._conversation_id,
91
+ error=err,
92
+ )
93
+ self._enqueue(ConversationRequest(agent_response=response))
94
+ logger.error("Agent error: %s", error)
95
+
96
+ def on_finish(self) -> None:
97
+ if self._finished:
98
+ return
99
+ self._finished = True
100
+ chunk = ContentChunk(
101
+ type=ContentChunk.ChunkType.Value("END"),
102
+ content="",
103
+ )
104
+ response = AgentResponse(
105
+ conversation_id=self._conversation_id,
106
+ content=chunk,
107
+ )
108
+ self._enqueue(ConversationRequest(agent_response=response))
109
+ _debug("[bridge] Response complete: conversation=%s", self._conversation_id)
110
+
111
+ def on_transcript(self, text: str) -> None:
112
+ pass # Audio not supported in Phase 1
113
+
114
+ def on_audio_chunk(self, data: bytes) -> None:
115
+ pass # Audio not supported in Phase 1
116
+
117
+ def on_audio_end(self) -> None:
118
+ pass # Audio not supported in Phase 1
119
+
120
+
121
+ class MessagingBridge:
122
+ """Connects an agent adapter to the Astro messaging service via gRPC."""
123
+
124
+ def __init__(
125
+ self, adapter: AgentAdapter, options: Optional[ServeOptions] = None
126
+ ) -> None:
127
+ self._adapter = adapter
128
+ self._server_address: str = (
129
+ (options.server_address if options else None)
130
+ or os.environ.get("GRPC_SERVER_ADDR")
131
+ or DEFAULT_SERVER_ADDR
132
+ )
133
+ self._channel: Optional[grpc.aio.Channel] = None
134
+ self._stub: Optional[AgentMessagingStub] = None
135
+ self._write_queue: asyncio.Queue = asyncio.Queue()
136
+ self._stop_event: asyncio.Event = asyncio.Event()
137
+
138
+ async def _connect_with_retry(self) -> None:
139
+ for attempt in range(1, MAX_RETRIES + 1):
140
+ try:
141
+ self._channel = grpc.aio.insecure_channel(self._server_address)
142
+ self._stub = AgentMessagingStub(self._channel)
143
+ response = await self._stub.HealthCheck(HealthCheckRequest())
144
+ status_name = response.Status.Name(response.status)
145
+ logger.info("Connected to messaging service (health: %s)", status_name)
146
+ return
147
+ except Exception as error:
148
+ if self._channel:
149
+ await self._channel.close()
150
+ self._channel = None
151
+ self._stub = None
152
+ if attempt == MAX_RETRIES:
153
+ raise
154
+ delay_ms = min(INITIAL_DELAY_MS * (2 ** (attempt - 1)), MAX_DELAY_MS)
155
+ logger.info(
156
+ "Waiting for messaging service (attempt %d/%d, retry in %dms)...",
157
+ attempt,
158
+ MAX_RETRIES,
159
+ delay_ms,
160
+ )
161
+ await asyncio.sleep(delay_ms / 1000)
162
+
163
+ async def _writer_task(self, stream: grpc.aio.StreamStreamCall) -> None:
164
+ """Consumes the write queue and sends messages to the gRPC stream sequentially."""
165
+ while True:
166
+ item = await self._write_queue.get()
167
+ if item is None:
168
+ break
169
+ try:
170
+ await stream.write(item)
171
+ except Exception as e:
172
+ logger.error("Stream write error: %s", e)
173
+
174
+ async def start(self) -> None:
175
+ agent_name = self._adapter.name
176
+ agent_id = agent_name.lower().replace(" ", "-")
177
+
178
+ logger.info("Starting %s...", agent_name)
179
+ logger.info(" gRPC Server: %s", self._server_address)
180
+
181
+ await self._connect_with_retry()
182
+
183
+ stream = self._stub.ProcessConversation()
184
+
185
+ # Start the sequential writer task
186
+ writer = asyncio.create_task(self._writer_task(stream))
187
+
188
+ # Send agent config for playground display
189
+ config_dict = self._adapter.get_config()
190
+ tool_configs = [
191
+ AgentToolConfig(
192
+ name=t.get("name", ""),
193
+ title=t.get("name", ""),
194
+ description=t.get("description", ""),
195
+ type=t.get("type", "other"),
196
+ )
197
+ for t in config_dict.get("tools", [])
198
+ ]
199
+ agent_config = AgentConfig(
200
+ system_prompt=config_dict.get("system_prompt", ""),
201
+ tools=tool_configs,
202
+ )
203
+ await self._write_queue.put(ConversationRequest(agent_config=agent_config))
204
+ logger.info("Agent config sent")
205
+
206
+ # Register the agent
207
+ registration = Message(
208
+ conversation_id="agent-registration",
209
+ platform="grpc",
210
+ content="Agent ready",
211
+ user=User(id=agent_id, username=agent_name),
212
+ )
213
+ await self._write_queue.put(ConversationRequest(message=registration))
214
+ logger.info("%s is ready and listening for messages", agent_name)
215
+
216
+ # Register signal handlers for graceful shutdown
217
+ loop = asyncio.get_event_loop()
218
+ for sig in (signal.SIGINT, signal.SIGTERM):
219
+ loop.add_signal_handler(sig, self.stop)
220
+
221
+ # Read incoming messages from the server
222
+ try:
223
+ async for response in stream:
224
+ payload = response.WhichOneof("payload")
225
+ if payload != "incoming_message":
226
+ continue
227
+
228
+ message = response.incoming_message
229
+ is_audio = (
230
+ message.content == "[audio]"
231
+ or any(
232
+ a.type == a.Type.Value("AUDIO")
233
+ for a in message.attachments
234
+ )
235
+ )
236
+ if is_audio:
237
+ # Audio not supported in Phase 1 — reply with error message
238
+ hooks = _StreamHooksImpl(message.conversation_id, self._write_queue)
239
+ start_chunk = ContentChunk(
240
+ type=ContentChunk.ChunkType.Value("START"), content=""
241
+ )
242
+ await self._write_queue.put(
243
+ ConversationRequest(
244
+ agent_response=AgentResponse(
245
+ conversation_id=message.conversation_id,
246
+ content=start_chunk,
247
+ )
248
+ )
249
+ )
250
+ hooks.on_chunk(
251
+ "Sorry, I don't support audio input. Please send a text message."
252
+ )
253
+ hooks.on_finish()
254
+ continue
255
+
256
+ asyncio.create_task(self._handle_message(message))
257
+ except grpc.aio.AioRpcError as e:
258
+ if not self._stop_event.is_set():
259
+ logger.error("Stream error: %s", e)
260
+ finally:
261
+ # Drain the writer
262
+ await self._write_queue.put(None)
263
+ await writer
264
+
265
+ await self._stop_event.wait()
266
+
267
+ async def _handle_message(self, message: Message) -> None:
268
+ conversation_id = message.conversation_id
269
+
270
+ # Send START chunk before dispatching to adapter
271
+ start_chunk = ContentChunk(
272
+ type=ContentChunk.ChunkType.Value("START"), content=""
273
+ )
274
+ await self._write_queue.put(
275
+ ConversationRequest(
276
+ agent_response=AgentResponse(
277
+ conversation_id=conversation_id,
278
+ content=start_chunk,
279
+ )
280
+ )
281
+ )
282
+
283
+ hooks = _StreamHooksImpl(conversation_id, self._write_queue)
284
+ options = StreamOptions(
285
+ conversation_id=conversation_id,
286
+ user_id=message.user.id if message.user else "anonymous",
287
+ )
288
+
289
+ try:
290
+ await self._adapter.stream(message.content, hooks, options)
291
+ except Exception as error:
292
+ hooks.on_error(
293
+ error if isinstance(error, Exception) else Exception(str(error))
294
+ )
295
+
296
+ def stop(self) -> None:
297
+ logger.info("Shutting down...")
298
+ self._stop_event.set()
299
+ loop = asyncio.get_event_loop()
300
+ for sig in (signal.SIGINT, signal.SIGTERM):
301
+ try:
302
+ loop.remove_signal_handler(sig)
303
+ except Exception:
304
+ pass
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import sys
5
+ from typing import Optional
6
+
7
+ from .bridge import MessagingBridge
8
+ from .types import AgentAdapter, ServeOptions
9
+
10
+
11
+ def serve(adapter: AgentAdapter, options: Optional[ServeOptions] = None) -> None:
12
+ """Connect an agent adapter to the Astro messaging service and start listening.
13
+
14
+ Blocks until the process receives SIGINT or SIGTERM.
15
+ """
16
+ bridge = MessagingBridge(adapter, options)
17
+ try:
18
+ asyncio.run(bridge.start())
19
+ except Exception as e:
20
+ print(f"Fatal error: {e}", file=sys.stderr)
21
+ sys.exit(1)
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Optional, Protocol, runtime_checkable
4
+
5
+
6
+ @runtime_checkable
7
+ class StreamHooks(Protocol):
8
+ """Lifecycle callbacks called by an adapter as the agent streams a response."""
9
+
10
+ def on_chunk(self, text: str) -> None:
11
+ """Send a text token or fragment from the LLM."""
12
+ ...
13
+
14
+ def on_status_update(self, status: dict) -> None:
15
+ """Send an agent state change. status must contain a 'status' key with one of:
16
+ THINKING, SEARCHING, GENERATING, PROCESSING, ANALYZING, CUSTOM.
17
+ Optionally include 'custom_message' for CUSTOM status.
18
+ """
19
+ ...
20
+
21
+ def on_error(self, error: Exception) -> None:
22
+ """Signal that an error occurred during generation."""
23
+ ...
24
+
25
+ def on_finish(self) -> None:
26
+ """Signal that the response is complete. Must be called exactly once per request.
27
+ Must not be called if on_error has already been called for the same request.
28
+ """
29
+ ...
30
+
31
+ def on_transcript(self, text: str) -> None:
32
+ """Send the transcribed text of the user's audio input."""
33
+ ...
34
+
35
+ def on_audio_chunk(self, data: bytes) -> None:
36
+ """Send a chunk of TTS audio back to the client."""
37
+ ...
38
+
39
+ def on_audio_end(self) -> None:
40
+ """Signal the end of the current audio response segment."""
41
+ ...
42
+
43
+
44
+ @dataclass
45
+ class StreamOptions:
46
+ """Per-request context passed to the adapter's stream method."""
47
+
48
+ conversation_id: str
49
+ user_id: str
50
+
51
+
52
+ @dataclass
53
+ class ServeOptions:
54
+ """Options for the serve() entry point and MessagingBridge."""
55
+
56
+ server_address: Optional[str] = None
57
+
58
+
59
+ @runtime_checkable
60
+ class AgentAdapter(Protocol):
61
+ """Framework-agnostic interface that any agent adapter must implement."""
62
+
63
+ name: str
64
+
65
+ async def stream(
66
+ self, prompt: str, hooks: StreamHooks, options: StreamOptions
67
+ ) -> None:
68
+ """Stream a response for the given prompt, invoking hooks as the agent progresses."""
69
+ ...
70
+
71
+ def get_config(self) -> dict:
72
+ """Return agent metadata for playground display (system prompt, tool list)."""
73
+ ...
@@ -0,0 +1,17 @@
1
+ import pytest
2
+ from unittest.mock import AsyncMock, MagicMock
3
+ from astropods_adapter_core.types import StreamOptions
4
+
5
+
6
+ @pytest.fixture
7
+ def mock_adapter():
8
+ adapter = MagicMock()
9
+ adapter.name = "Test Agent"
10
+ adapter.stream = AsyncMock()
11
+ adapter.get_config.return_value = {"system_prompt": "You are a test agent.", "tools": []}
12
+ return adapter
13
+
14
+
15
+ @pytest.fixture
16
+ def stream_options():
17
+ return StreamOptions(conversation_id="conv-123", user_id="user-456")
@@ -0,0 +1,171 @@
1
+ import asyncio
2
+ import os
3
+ import pytest
4
+ from unittest.mock import AsyncMock, MagicMock, patch, call
5
+
6
+ from astropods_adapter_core.bridge import (
7
+ MessagingBridge,
8
+ _StreamHooksImpl,
9
+ DEFAULT_SERVER_ADDR,
10
+ )
11
+ from astropods_adapter_core.types import ServeOptions, StreamOptions
12
+ from astropods_messaging import ContentChunk, StatusUpdate, ErrorResponse
13
+
14
+
15
+ # --- _StreamHooksImpl tests ---
16
+
17
+ class TestStreamHooksImpl:
18
+ def setup_method(self):
19
+ self.queue = asyncio.Queue()
20
+ self.hooks = _StreamHooksImpl("conv-123", self.queue)
21
+
22
+ def _dequeue_all(self):
23
+ items = []
24
+ while not self.queue.empty():
25
+ items.append(self.queue.get_nowait())
26
+ return items
27
+
28
+ def test_on_chunk_enqueues_delta(self):
29
+ self.hooks.on_chunk("hello")
30
+ items = self._dequeue_all()
31
+ assert len(items) == 1
32
+ response = items[0].agent_response
33
+ assert response.content.type == ContentChunk.ChunkType.Value("DELTA")
34
+ assert response.content.content == "hello"
35
+ assert response.conversation_id == "conv-123"
36
+
37
+ def test_on_finish_enqueues_end(self):
38
+ self.hooks.on_finish()
39
+ items = self._dequeue_all()
40
+ assert len(items) == 1
41
+ response = items[0].agent_response
42
+ assert response.content.type == ContentChunk.ChunkType.Value("END")
43
+
44
+ def test_on_finish_sets_finished_flag(self):
45
+ self.hooks.on_finish()
46
+ self.hooks.on_chunk("should be ignored")
47
+ items = self._dequeue_all()
48
+ assert len(items) == 1 # only the END chunk, not the subsequent chunk
49
+
50
+ def test_on_error_enqueues_error_response(self):
51
+ self.hooks.on_error(ValueError("something went wrong"))
52
+ items = self._dequeue_all()
53
+ assert len(items) == 1
54
+ response = items[0].agent_response
55
+ assert response.error.code == ErrorResponse.ErrorCode.Value("AGENT_ERROR")
56
+ assert "something went wrong" in response.error.message
57
+
58
+ def test_on_error_sets_finished_flag(self):
59
+ self.hooks.on_error(Exception("err"))
60
+ self.hooks.on_finish() # should be ignored
61
+ items = self._dequeue_all()
62
+ assert len(items) == 1 # only the error
63
+
64
+ def test_on_status_update_enqueues_status(self):
65
+ self.hooks.on_status_update({"status": "THINKING"})
66
+ items = self._dequeue_all()
67
+ assert len(items) == 1
68
+ response = items[0].agent_response
69
+ assert response.status.status == StatusUpdate.Status.Value("THINKING")
70
+
71
+ def test_on_status_update_with_custom_message(self):
72
+ self.hooks.on_status_update({"status": "PROCESSING", "custom_message": "Running tool"})
73
+ items = self._dequeue_all()
74
+ response = items[0].agent_response
75
+ assert response.status.status == StatusUpdate.Status.Value("PROCESSING")
76
+ assert response.status.custom_message == "Running tool"
77
+
78
+ def test_on_status_update_unknown_status_defaults_to_thinking(self):
79
+ self.hooks.on_status_update({"status": "UNKNOWN_STATUS"})
80
+ items = self._dequeue_all()
81
+ response = items[0].agent_response
82
+ assert response.status.status == StatusUpdate.Status.Value("THINKING")
83
+
84
+ def test_on_chunk_ignored_after_finish(self):
85
+ self.hooks.on_finish()
86
+ self.hooks.on_chunk("late chunk")
87
+ self.hooks.on_status_update({"status": "THINKING"})
88
+ items = self._dequeue_all()
89
+ assert len(items) == 1 # only the END
90
+
91
+ def test_audio_hooks_are_noop(self):
92
+ self.hooks.on_transcript("text")
93
+ self.hooks.on_audio_chunk(b"data")
94
+ self.hooks.on_audio_end()
95
+ assert self.queue.empty()
96
+
97
+
98
+ # --- MessagingBridge constructor tests ---
99
+
100
+ class TestMessagingBridgeConstructor:
101
+ def test_uses_options_server_address(self, mock_adapter):
102
+ bridge = MessagingBridge(mock_adapter, ServeOptions(server_address="custom:1234"))
103
+ assert bridge._server_address == "custom:1234"
104
+
105
+ def test_uses_env_var(self, mock_adapter, monkeypatch):
106
+ monkeypatch.setenv("GRPC_SERVER_ADDR", "env-host:5678")
107
+ bridge = MessagingBridge(mock_adapter)
108
+ assert bridge._server_address == "env-host:5678"
109
+
110
+ def test_uses_default_when_no_config(self, mock_adapter, monkeypatch):
111
+ monkeypatch.delenv("GRPC_SERVER_ADDR", raising=False)
112
+ bridge = MessagingBridge(mock_adapter)
113
+ assert bridge._server_address == DEFAULT_SERVER_ADDR
114
+
115
+ def test_options_takes_precedence_over_env(self, mock_adapter, monkeypatch):
116
+ monkeypatch.setenv("GRPC_SERVER_ADDR", "env-host:5678")
117
+ bridge = MessagingBridge(mock_adapter, ServeOptions(server_address="options-host:9999"))
118
+ assert bridge._server_address == "options-host:9999"
119
+
120
+
121
+ # --- MessagingBridge agent ID derivation ---
122
+
123
+ class TestAgentIdDerivation:
124
+ def _get_registration_message(self, sent_messages):
125
+ """Find the registration ConversationRequest among sent messages."""
126
+ for msg in sent_messages:
127
+ if hasattr(msg, "message") and msg.WhichOneof("request") == "message":
128
+ m = msg.message
129
+ if m.conversation_id == "agent-registration":
130
+ return m
131
+ return None
132
+
133
+ @pytest.mark.asyncio
134
+ async def test_agent_id_is_lowercased_and_hyphenated(self, monkeypatch):
135
+ adapter = MagicMock()
136
+ adapter.name = "My Test Agent"
137
+ adapter.stream = AsyncMock()
138
+ adapter.get_config.return_value = {"system_prompt": "", "tools": []}
139
+
140
+ sent = []
141
+
142
+ mock_stream = MagicMock()
143
+ mock_stream.write = AsyncMock(side_effect=lambda msg: sent.append(msg))
144
+
145
+ async def fake_aiter(self):
146
+ return
147
+ yield # make it an async generator
148
+
149
+ mock_stream.__aiter__ = fake_aiter
150
+
151
+ mock_stub = MagicMock()
152
+ mock_stub.HealthCheck = AsyncMock(return_value=MagicMock(status=1))
153
+ mock_stub.ProcessConversation = MagicMock(return_value=mock_stream)
154
+
155
+ bridge = MessagingBridge(adapter, ServeOptions(server_address="localhost:9090"))
156
+ bridge._stub = mock_stub
157
+
158
+ with patch.object(bridge, "_connect_with_retry", new=AsyncMock()):
159
+ bridge._stub = mock_stub
160
+ # Run just enough to send registration
161
+ task = asyncio.create_task(bridge.start())
162
+ await asyncio.sleep(0.05)
163
+ bridge.stop()
164
+ try:
165
+ await asyncio.wait_for(task, timeout=1.0)
166
+ except (asyncio.TimeoutError, SystemExit):
167
+ pass
168
+
169
+ registration = self._get_registration_message(sent)
170
+ if registration:
171
+ assert registration.user.id == "my-test-agent"