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.
- astropods_adapter_core-0.1.0/.gitignore +4 -0
- astropods_adapter_core-0.1.0/PKG-INFO +110 -0
- astropods_adapter_core-0.1.0/README.md +97 -0
- astropods_adapter_core-0.1.0/pyproject.toml +28 -0
- astropods_adapter_core-0.1.0/src/astropods_adapter_core/__init__.py +12 -0
- astropods_adapter_core-0.1.0/src/astropods_adapter_core/bridge.py +304 -0
- astropods_adapter_core-0.1.0/src/astropods_adapter_core/serve.py +21 -0
- astropods_adapter_core-0.1.0/src/astropods_adapter_core/types.py +73 -0
- astropods_adapter_core-0.1.0/tests/conftest.py +17 -0
- astropods_adapter_core-0.1.0/tests/test_bridge.py +171 -0
|
@@ -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"
|