basion-agent 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- basion_agent/__init__.py +62 -0
- basion_agent/agent.py +360 -0
- basion_agent/agent_state_client.py +149 -0
- basion_agent/app.py +502 -0
- basion_agent/artifact.py +58 -0
- basion_agent/attachment_client.py +153 -0
- basion_agent/checkpoint_client.py +169 -0
- basion_agent/checkpointer.py +16 -0
- basion_agent/cli.py +139 -0
- basion_agent/conversation.py +103 -0
- basion_agent/conversation_client.py +86 -0
- basion_agent/conversation_message.py +48 -0
- basion_agent/exceptions.py +36 -0
- basion_agent/extensions/__init__.py +1 -0
- basion_agent/extensions/langgraph.py +526 -0
- basion_agent/extensions/pydantic_ai.py +180 -0
- basion_agent/gateway_client.py +531 -0
- basion_agent/gateway_pb2.py +73 -0
- basion_agent/gateway_pb2_grpc.py +101 -0
- basion_agent/heartbeat.py +84 -0
- basion_agent/loki_handler.py +355 -0
- basion_agent/memory.py +73 -0
- basion_agent/memory_client.py +155 -0
- basion_agent/message.py +333 -0
- basion_agent/py.typed +0 -0
- basion_agent/streamer.py +184 -0
- basion_agent/structural/__init__.py +6 -0
- basion_agent/structural/artifact.py +94 -0
- basion_agent/structural/base.py +71 -0
- basion_agent/structural/stepper.py +125 -0
- basion_agent/structural/surface.py +90 -0
- basion_agent/structural/text_block.py +96 -0
- basion_agent/tools/__init__.py +19 -0
- basion_agent/tools/container.py +46 -0
- basion_agent/tools/knowledge_graph.py +306 -0
- basion_agent-0.4.0.dist-info/METADATA +880 -0
- basion_agent-0.4.0.dist-info/RECORD +41 -0
- basion_agent-0.4.0.dist-info/WHEEL +5 -0
- basion_agent-0.4.0.dist-info/entry_points.txt +2 -0
- basion_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
- basion_agent-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""HTTP client for AI Memory service."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Dict, Any, Optional, List
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
import aiohttp
|
|
8
|
+
|
|
9
|
+
from .exceptions import APIException
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MemoryMessage:
|
|
16
|
+
"""A message from ai-memory storage.
|
|
17
|
+
|
|
18
|
+
Exactly matches the ai-memory API MessageStored schema.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
role: str # "user", "assistant", "system"
|
|
23
|
+
content: str
|
|
24
|
+
tenant_id: Optional[str] = None # Agent scope/namespace
|
|
25
|
+
user_id: Optional[str] = None
|
|
26
|
+
conversation_id: Optional[str] = None
|
|
27
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
28
|
+
timestamp: Optional[str] = None
|
|
29
|
+
sequence_number: Optional[int] = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_dict(cls, data: Dict[str, Any]) -> "MemoryMessage":
|
|
33
|
+
"""Create from ai-memory API response."""
|
|
34
|
+
return cls(
|
|
35
|
+
id=data.get("id", ""),
|
|
36
|
+
role=data.get("role", ""),
|
|
37
|
+
content=data.get("content", ""),
|
|
38
|
+
tenant_id=data.get("tenant_id"),
|
|
39
|
+
user_id=data.get("user_id"),
|
|
40
|
+
conversation_id=data.get("conversation_id"),
|
|
41
|
+
metadata=data.get("metadata"),
|
|
42
|
+
timestamp=data.get("timestamp"),
|
|
43
|
+
sequence_number=data.get("sequence_number"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class MemorySearchResult:
|
|
49
|
+
"""A memory search result with similarity score."""
|
|
50
|
+
|
|
51
|
+
message: MemoryMessage
|
|
52
|
+
score: float
|
|
53
|
+
context: List[MemoryMessage] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class UserSummary:
|
|
58
|
+
"""User conversation summary."""
|
|
59
|
+
|
|
60
|
+
text: str
|
|
61
|
+
last_updated: str
|
|
62
|
+
message_count: int
|
|
63
|
+
version: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class MemoryClient:
|
|
67
|
+
"""Low-level async HTTP client for AI Memory service."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, base_url: str):
|
|
70
|
+
self.base_url = base_url.rstrip("/")
|
|
71
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
72
|
+
|
|
73
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
74
|
+
if self._session is None or self._session.closed:
|
|
75
|
+
timeout = aiohttp.ClientTimeout(total=30.0)
|
|
76
|
+
self._session = aiohttp.ClientSession(timeout=timeout)
|
|
77
|
+
return self._session
|
|
78
|
+
|
|
79
|
+
async def close(self):
|
|
80
|
+
if self._session and not self._session.closed:
|
|
81
|
+
await self._session.close()
|
|
82
|
+
|
|
83
|
+
async def search(
|
|
84
|
+
self,
|
|
85
|
+
query: str,
|
|
86
|
+
user_id: Optional[str] = None,
|
|
87
|
+
conversation_id: Optional[str] = None,
|
|
88
|
+
tenant_id: Optional[str] = None,
|
|
89
|
+
limit: int = 10,
|
|
90
|
+
min_similarity: int = 70,
|
|
91
|
+
context_messages: int = 0,
|
|
92
|
+
) -> List[MemorySearchResult]:
|
|
93
|
+
"""Search long-term memory with semantic search."""
|
|
94
|
+
params = {
|
|
95
|
+
"q": query,
|
|
96
|
+
"limit": limit,
|
|
97
|
+
"min_similarity": min_similarity,
|
|
98
|
+
"context_messages": context_messages,
|
|
99
|
+
}
|
|
100
|
+
if user_id:
|
|
101
|
+
params["user_id"] = user_id
|
|
102
|
+
if conversation_id:
|
|
103
|
+
params["conversation_id"] = conversation_id
|
|
104
|
+
if tenant_id:
|
|
105
|
+
params["tenant_id"] = tenant_id
|
|
106
|
+
|
|
107
|
+
url = f"{self.base_url}/conversation/search?{urlencode(params)}"
|
|
108
|
+
session = await self._get_session()
|
|
109
|
+
try:
|
|
110
|
+
async with session.get(url) as response:
|
|
111
|
+
if response.status >= 400:
|
|
112
|
+
error_text = await response.text()
|
|
113
|
+
raise APIException(
|
|
114
|
+
f"Memory search failed: {response.status} - {error_text}"
|
|
115
|
+
)
|
|
116
|
+
data = await response.json()
|
|
117
|
+
|
|
118
|
+
results = []
|
|
119
|
+
for item in data.get("results", []):
|
|
120
|
+
msg = MemoryMessage.from_dict(item["message"])
|
|
121
|
+
context = [MemoryMessage.from_dict(c) for c in item.get("context", [])]
|
|
122
|
+
results.append(
|
|
123
|
+
MemorySearchResult(message=msg, score=item["score"], context=context)
|
|
124
|
+
)
|
|
125
|
+
return results
|
|
126
|
+
except aiohttp.ClientError as e:
|
|
127
|
+
raise APIException(f"Memory search failed: {e}")
|
|
128
|
+
|
|
129
|
+
async def get_user_summary(self, user_id: str) -> Optional[UserSummary]:
|
|
130
|
+
"""Get user's conversation summary."""
|
|
131
|
+
url = f"{self.base_url}/conversation/user/{user_id}/summary"
|
|
132
|
+
session = await self._get_session()
|
|
133
|
+
try:
|
|
134
|
+
async with session.get(url) as response:
|
|
135
|
+
if response.status == 404:
|
|
136
|
+
return None
|
|
137
|
+
if response.status >= 400:
|
|
138
|
+
error_text = await response.text()
|
|
139
|
+
raise APIException(
|
|
140
|
+
f"Summary fetch failed: {response.status} - {error_text}"
|
|
141
|
+
)
|
|
142
|
+
data = await response.json()
|
|
143
|
+
|
|
144
|
+
if not data.get("has_summary") or not data.get("summary"):
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
s = data["summary"]
|
|
148
|
+
return UserSummary(
|
|
149
|
+
text=s["text"],
|
|
150
|
+
last_updated=s["last_updated"],
|
|
151
|
+
message_count=s["message_count"],
|
|
152
|
+
version=s.get("version", 1),
|
|
153
|
+
)
|
|
154
|
+
except aiohttp.ClientError as e:
|
|
155
|
+
raise APIException(f"Summary fetch failed: {e}")
|
basion_agent/message.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Message data structure."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from typing import Dict, Any, Optional, List, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .conversation import Conversation
|
|
10
|
+
from .conversation_client import ConversationClient
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .memory import Memory
|
|
14
|
+
from .memory_client import MemoryClient
|
|
15
|
+
from .attachment_client import AttachmentClient, AttachmentInfo
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Message:
|
|
20
|
+
"""Represents an incoming message."""
|
|
21
|
+
|
|
22
|
+
content: str
|
|
23
|
+
conversation_id: str
|
|
24
|
+
user_id: str
|
|
25
|
+
headers: Dict[str, str]
|
|
26
|
+
raw_body: Dict[str, Any]
|
|
27
|
+
conversation: Optional[Conversation] = None
|
|
28
|
+
memory: Optional["Memory"] = None
|
|
29
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
30
|
+
schema: Optional[Dict[str, Any]] = None
|
|
31
|
+
# Keep singular for backward compatibility
|
|
32
|
+
attachment: Optional["AttachmentInfo"] = None
|
|
33
|
+
# Array of all attachments
|
|
34
|
+
attachments: List["AttachmentInfo"] = field(default_factory=list)
|
|
35
|
+
_attachment_client: Optional["AttachmentClient"] = field(default=None, repr=False)
|
|
36
|
+
|
|
37
|
+
def __post_init__(self):
|
|
38
|
+
"""Ensure backward compatibility between attachment and attachments."""
|
|
39
|
+
# If attachments is empty but attachment exists, populate attachments
|
|
40
|
+
if not self.attachments and self.attachment:
|
|
41
|
+
self.attachments = [self.attachment]
|
|
42
|
+
# If attachment is None but attachments exist, set attachment to first
|
|
43
|
+
if self.attachment is None and self.attachments:
|
|
44
|
+
self.attachment = self.attachments[0]
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_kafka_message(
|
|
48
|
+
cls,
|
|
49
|
+
body: Dict[str, Any],
|
|
50
|
+
headers: Dict[str, str],
|
|
51
|
+
conversation_client: Optional[ConversationClient] = None,
|
|
52
|
+
memory_client: Optional["MemoryClient"] = None,
|
|
53
|
+
attachment_client: Optional["AttachmentClient"] = None,
|
|
54
|
+
agent_name: Optional[str] = None,
|
|
55
|
+
):
|
|
56
|
+
"""Create Message from Kafka message."""
|
|
57
|
+
from .memory import Memory
|
|
58
|
+
from .attachment_client import AttachmentInfo
|
|
59
|
+
|
|
60
|
+
conversation_id = headers.get("conversationId")
|
|
61
|
+
user_id = headers.get("userId", "anonymous")
|
|
62
|
+
|
|
63
|
+
# Parse metadata from header
|
|
64
|
+
metadata = None
|
|
65
|
+
metadata_header = headers.get("messageMetadata")
|
|
66
|
+
if metadata_header:
|
|
67
|
+
try:
|
|
68
|
+
metadata = json.loads(metadata_header)
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# Parse schema from header
|
|
73
|
+
schema = None
|
|
74
|
+
schema_header = headers.get("messageSchema")
|
|
75
|
+
if schema_header:
|
|
76
|
+
try:
|
|
77
|
+
schema = json.loads(schema_header)
|
|
78
|
+
except json.JSONDecodeError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# Parse attachments from header or body (prefer plural, fallback to singular)
|
|
82
|
+
attachments: List[AttachmentInfo] = []
|
|
83
|
+
|
|
84
|
+
# Try parsing plural 'attachments' header first (new format)
|
|
85
|
+
attachments_header = headers.get("attachments")
|
|
86
|
+
if attachments_header:
|
|
87
|
+
try:
|
|
88
|
+
attachments_data = json.loads(attachments_header)
|
|
89
|
+
attachments = [AttachmentInfo.from_dict(a) for a in attachments_data]
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# Fallback to body attachments (plural)
|
|
94
|
+
if not attachments and body.get("attachments"):
|
|
95
|
+
try:
|
|
96
|
+
attachments = [
|
|
97
|
+
AttachmentInfo.from_dict(a) for a in body["attachments"]
|
|
98
|
+
]
|
|
99
|
+
except (TypeError, KeyError):
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
# BACKWARD COMPATIBILITY: Fallback to singular 'attachment' header
|
|
103
|
+
if not attachments:
|
|
104
|
+
attachment_header = headers.get("attachment")
|
|
105
|
+
if attachment_header:
|
|
106
|
+
try:
|
|
107
|
+
attachment_data = json.loads(attachment_header)
|
|
108
|
+
attachments = [AttachmentInfo.from_dict(attachment_data)]
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
# Fallback to singular body attachment
|
|
113
|
+
if not attachments and body.get("attachment"):
|
|
114
|
+
try:
|
|
115
|
+
attachments = [AttachmentInfo.from_dict(body["attachment"])]
|
|
116
|
+
except (TypeError, KeyError):
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
conversation = None
|
|
120
|
+
if conversation_id and conversation_client:
|
|
121
|
+
conversation = Conversation(conversation_id, conversation_client, agent_name)
|
|
122
|
+
|
|
123
|
+
memory = None
|
|
124
|
+
if memory_client and user_id and conversation_id:
|
|
125
|
+
memory = Memory(user_id, conversation_id, memory_client)
|
|
126
|
+
|
|
127
|
+
return cls(
|
|
128
|
+
content=body.get("content", ""),
|
|
129
|
+
conversation_id=conversation_id,
|
|
130
|
+
user_id=user_id,
|
|
131
|
+
headers=headers,
|
|
132
|
+
raw_body=body,
|
|
133
|
+
conversation=conversation,
|
|
134
|
+
memory=memory,
|
|
135
|
+
metadata=metadata,
|
|
136
|
+
schema=schema,
|
|
137
|
+
attachment=attachments[0] if attachments else None,
|
|
138
|
+
attachments=attachments,
|
|
139
|
+
_attachment_client=attachment_client,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def has_attachment(self) -> bool:
|
|
143
|
+
"""Check if message has at least one attachment."""
|
|
144
|
+
return len(self.attachments) > 0
|
|
145
|
+
|
|
146
|
+
def has_attachments(self) -> bool:
|
|
147
|
+
"""Check if message has any attachments. Alias for has_attachment()."""
|
|
148
|
+
return len(self.attachments) > 0
|
|
149
|
+
|
|
150
|
+
def get_attachment_count(self) -> int:
|
|
151
|
+
"""Get the number of attachments."""
|
|
152
|
+
return len(self.attachments)
|
|
153
|
+
|
|
154
|
+
def get_attachments(self) -> List["AttachmentInfo"]:
|
|
155
|
+
"""Get all attachments."""
|
|
156
|
+
from .attachment_client import AttachmentInfo
|
|
157
|
+
|
|
158
|
+
return list(self.attachments)
|
|
159
|
+
|
|
160
|
+
async def get_attachment_bytes(self) -> bytes:
|
|
161
|
+
"""
|
|
162
|
+
Download first attachment into memory as bytes.
|
|
163
|
+
For backward compatibility with single-attachment code.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
File content as bytes
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValueError: If no attachment is available
|
|
170
|
+
"""
|
|
171
|
+
if not self.attachments:
|
|
172
|
+
raise ValueError("No attachment available")
|
|
173
|
+
if not self._attachment_client:
|
|
174
|
+
raise ValueError("AttachmentClient not available")
|
|
175
|
+
return await self._attachment_client.download(self.attachments[0].url)
|
|
176
|
+
|
|
177
|
+
async def get_attachment_base64(self) -> str:
|
|
178
|
+
"""
|
|
179
|
+
Get first attachment as base64 string (for LLM vision APIs).
|
|
180
|
+
For backward compatibility with single-attachment code.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Base64 encoded string of the file content
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If no attachment is available
|
|
187
|
+
"""
|
|
188
|
+
if not self.attachments:
|
|
189
|
+
raise ValueError("No attachment available")
|
|
190
|
+
if not self._attachment_client:
|
|
191
|
+
raise ValueError("AttachmentClient not available")
|
|
192
|
+
return await self._attachment_client.download_as_base64(self.attachments[0].url)
|
|
193
|
+
|
|
194
|
+
async def get_attachment_buffer(self) -> BytesIO:
|
|
195
|
+
"""
|
|
196
|
+
Get first attachment as BytesIO buffer (for document processing libraries).
|
|
197
|
+
For backward compatibility with single-attachment code.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
BytesIO buffer containing the file content
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
ValueError: If no attachment is available
|
|
204
|
+
"""
|
|
205
|
+
if not self.attachments:
|
|
206
|
+
raise ValueError("No attachment available")
|
|
207
|
+
if not self._attachment_client:
|
|
208
|
+
raise ValueError("AttachmentClient not available")
|
|
209
|
+
return await self._attachment_client.download_to_buffer(self.attachments[0].url)
|
|
210
|
+
|
|
211
|
+
async def get_attachment_bytes_at(self, index: int) -> bytes:
|
|
212
|
+
"""
|
|
213
|
+
Download a specific attachment by index as bytes.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
index: Zero-based index of the attachment
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
File content as bytes
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
ValueError: If index is out of bounds or no attachment client
|
|
223
|
+
"""
|
|
224
|
+
if index < 0 or index >= len(self.attachments):
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Invalid attachment index: {index}. "
|
|
227
|
+
f"Valid range: 0-{len(self.attachments) - 1}"
|
|
228
|
+
)
|
|
229
|
+
if not self._attachment_client:
|
|
230
|
+
raise ValueError("AttachmentClient not available")
|
|
231
|
+
return await self._attachment_client.download(self.attachments[index].url)
|
|
232
|
+
|
|
233
|
+
async def get_attachment_base64_at(self, index: int) -> str:
|
|
234
|
+
"""
|
|
235
|
+
Get a specific attachment by index as base64 string.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
index: Zero-based index of the attachment
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Base64 encoded string of the file content
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
ValueError: If index is out of bounds or no attachment client
|
|
245
|
+
"""
|
|
246
|
+
if index < 0 or index >= len(self.attachments):
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"Invalid attachment index: {index}. "
|
|
249
|
+
f"Valid range: 0-{len(self.attachments) - 1}"
|
|
250
|
+
)
|
|
251
|
+
if not self._attachment_client:
|
|
252
|
+
raise ValueError("AttachmentClient not available")
|
|
253
|
+
return await self._attachment_client.download_as_base64(
|
|
254
|
+
self.attachments[index].url
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def get_attachment_buffer_at(self, index: int) -> BytesIO:
|
|
258
|
+
"""
|
|
259
|
+
Get a specific attachment by index as BytesIO buffer.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
index: Zero-based index of the attachment
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
BytesIO buffer containing the file content
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
ValueError: If index is out of bounds or no attachment client
|
|
269
|
+
"""
|
|
270
|
+
if index < 0 or index >= len(self.attachments):
|
|
271
|
+
raise ValueError(
|
|
272
|
+
f"Invalid attachment index: {index}. "
|
|
273
|
+
f"Valid range: 0-{len(self.attachments) - 1}"
|
|
274
|
+
)
|
|
275
|
+
if not self._attachment_client:
|
|
276
|
+
raise ValueError("AttachmentClient not available")
|
|
277
|
+
return await self._attachment_client.download_to_buffer(
|
|
278
|
+
self.attachments[index].url
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def get_all_attachment_bytes(self) -> List[bytes]:
|
|
282
|
+
"""
|
|
283
|
+
Download all attachments as bytes.
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
List of file contents as bytes in the same order as attachments
|
|
287
|
+
|
|
288
|
+
Raises:
|
|
289
|
+
ValueError: If no attachment client available
|
|
290
|
+
"""
|
|
291
|
+
if not self._attachment_client:
|
|
292
|
+
raise ValueError("AttachmentClient not available")
|
|
293
|
+
return await asyncio.gather(
|
|
294
|
+
*[self._attachment_client.download(a.url) for a in self.attachments]
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
async def get_all_attachment_base64(self) -> List[str]:
|
|
298
|
+
"""
|
|
299
|
+
Get all attachments as base64 strings.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
List of base64 encoded strings in the same order as attachments
|
|
303
|
+
|
|
304
|
+
Raises:
|
|
305
|
+
ValueError: If no attachment client available
|
|
306
|
+
"""
|
|
307
|
+
if not self._attachment_client:
|
|
308
|
+
raise ValueError("AttachmentClient not available")
|
|
309
|
+
return await asyncio.gather(
|
|
310
|
+
*[
|
|
311
|
+
self._attachment_client.download_as_base64(a.url)
|
|
312
|
+
for a in self.attachments
|
|
313
|
+
]
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
async def get_all_attachment_buffers(self) -> List[BytesIO]:
|
|
317
|
+
"""
|
|
318
|
+
Get all attachments as BytesIO buffers.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
List of BytesIO buffers in the same order as attachments
|
|
322
|
+
|
|
323
|
+
Raises:
|
|
324
|
+
ValueError: If no attachment client available
|
|
325
|
+
"""
|
|
326
|
+
if not self._attachment_client:
|
|
327
|
+
raise ValueError("AttachmentClient not available")
|
|
328
|
+
return await asyncio.gather(
|
|
329
|
+
*[
|
|
330
|
+
self._attachment_client.download_to_buffer(a.url)
|
|
331
|
+
for a in self.attachments
|
|
332
|
+
]
|
|
333
|
+
)
|
basion_agent/py.typed
ADDED
|
File without changes
|
basion_agent/streamer.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Streamer class for streaming responses with context manager support."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, Dict, Any, TypeVar, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .structural.base import StructuralStreamer
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .gateway_client import GatewayClient
|
|
11
|
+
from .message import Message
|
|
12
|
+
from .conversation_client import ConversationClient
|
|
13
|
+
|
|
14
|
+
T = TypeVar('T', bound=StructuralStreamer)
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Streamer:
|
|
20
|
+
"""Handles streaming responses back to the conversation via the gateway (async)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
agent_name: str,
|
|
25
|
+
original_message: "Message",
|
|
26
|
+
gateway_client: "GatewayClient",
|
|
27
|
+
conversation_client: Optional["ConversationClient"] = None,
|
|
28
|
+
awaiting: bool = False,
|
|
29
|
+
send_to: str = "user",
|
|
30
|
+
):
|
|
31
|
+
self._agent_name = agent_name
|
|
32
|
+
self._original_message = original_message
|
|
33
|
+
self._gateway_client = gateway_client
|
|
34
|
+
self._conversation_client = conversation_client
|
|
35
|
+
self._awaiting = awaiting # Used by context manager
|
|
36
|
+
self._send_to = send_to
|
|
37
|
+
self._finished = False
|
|
38
|
+
self._response_schema: Optional[Dict[str, Any]] = None
|
|
39
|
+
self._message_metadata: Optional[Dict[str, Any]] = None
|
|
40
|
+
|
|
41
|
+
def set_response_schema(self, schema: Dict[str, Any]) -> "Streamer":
|
|
42
|
+
"""Set response schema (fluent API)."""
|
|
43
|
+
self._response_schema = schema
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def set_message_metadata(self, metadata: Dict[str, Any]) -> "Streamer":
|
|
47
|
+
"""Set message metadata (fluent API)."""
|
|
48
|
+
self._message_metadata = metadata
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def stream_by(self, structural: T) -> T:
|
|
52
|
+
"""Bind a structural streamer to this streamer for event sending.
|
|
53
|
+
|
|
54
|
+
Returns the structural object itself with streaming capability enabled.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
structural: StructuralStreamer instance (Artifact, Surface, etc.)
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The same structural object, now bound to this streamer
|
|
61
|
+
|
|
62
|
+
Example:
|
|
63
|
+
artifact = Artifact()
|
|
64
|
+
s.stream_by(artifact).generating("Creating...")
|
|
65
|
+
s.stream_by(artifact).done(url="...", type="image")
|
|
66
|
+
"""
|
|
67
|
+
return structural._bind_streamer(self)
|
|
68
|
+
|
|
69
|
+
def stream(
|
|
70
|
+
self, content: str, persist: bool = True, event_type: Optional[str] = None
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Stream a content chunk (not done).
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
content: The content to stream
|
|
76
|
+
persist: If True, this content will be saved to DB. If False, only streamed.
|
|
77
|
+
event_type: Optional event type for the chunk (e.g., 'thinking', 'tool_call').
|
|
78
|
+
"""
|
|
79
|
+
if self._finished:
|
|
80
|
+
raise RuntimeError("Streamer already finished")
|
|
81
|
+
self._send(content, done=False, persist=persist, event_type=event_type)
|
|
82
|
+
|
|
83
|
+
def write(self, content: str, persist: bool = True) -> None:
|
|
84
|
+
"""Alias for stream() - write a content chunk.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
content: The content to write
|
|
88
|
+
persist: If True, this content will be saved to DB. If False, only streamed.
|
|
89
|
+
"""
|
|
90
|
+
self.stream(content, persist=persist)
|
|
91
|
+
|
|
92
|
+
async def finish(self, awaiting: Optional[bool] = None) -> None:
|
|
93
|
+
"""Finish streaming with empty done=True chunk.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
awaiting: If True, sets awaiting_route to this agent's name.
|
|
97
|
+
If None, uses the awaiting value from constructor.
|
|
98
|
+
If response_schema was set and awaiting is not explicitly False,
|
|
99
|
+
awaiting is automatically set to True.
|
|
100
|
+
"""
|
|
101
|
+
if self._finished:
|
|
102
|
+
return # Idempotent
|
|
103
|
+
|
|
104
|
+
self._send("", done=True, persist=True)
|
|
105
|
+
self._finished = True
|
|
106
|
+
|
|
107
|
+
# Determine if we should set awaiting_route
|
|
108
|
+
# Priority: explicit awaiting arg > constructor awaiting > auto-await for schema
|
|
109
|
+
if awaiting is not None:
|
|
110
|
+
should_await = awaiting
|
|
111
|
+
elif self._awaiting:
|
|
112
|
+
should_await = True
|
|
113
|
+
elif self._response_schema is not None:
|
|
114
|
+
# Auto-await when response_schema is set (so next message comes back to this agent)
|
|
115
|
+
should_await = True
|
|
116
|
+
else:
|
|
117
|
+
should_await = False
|
|
118
|
+
|
|
119
|
+
# Store pending_response_schema if schema was set
|
|
120
|
+
if self._response_schema and self._conversation_client:
|
|
121
|
+
await self._set_pending_response_schema()
|
|
122
|
+
|
|
123
|
+
if should_await and self._conversation_client:
|
|
124
|
+
await self._set_awaiting_route()
|
|
125
|
+
|
|
126
|
+
def _send(
|
|
127
|
+
self,
|
|
128
|
+
content: str,
|
|
129
|
+
done: bool,
|
|
130
|
+
persist: bool = True,
|
|
131
|
+
event_type: Optional[str] = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Send message to router via gateway (sync - uses gRPC)."""
|
|
134
|
+
message_body = {"content": content, "done": done, "persist": persist}
|
|
135
|
+
if event_type:
|
|
136
|
+
message_body["eventType"] = event_type
|
|
137
|
+
headers = {
|
|
138
|
+
"conversationId": self._original_message.conversation_id,
|
|
139
|
+
"userId": self._original_message.user_id,
|
|
140
|
+
"from_": self._agent_name,
|
|
141
|
+
"to_": self._send_to,
|
|
142
|
+
"nextRoute": self._send_to,
|
|
143
|
+
}
|
|
144
|
+
if self._response_schema:
|
|
145
|
+
headers["responseSchema"] = json.dumps(self._response_schema)
|
|
146
|
+
if self._message_metadata:
|
|
147
|
+
headers["messageMetadata"] = json.dumps(self._message_metadata)
|
|
148
|
+
|
|
149
|
+
# Produce via gateway gRPC (synchronous operation)
|
|
150
|
+
self._gateway_client.produce(
|
|
151
|
+
topic="router.inbox",
|
|
152
|
+
key=self._original_message.conversation_id,
|
|
153
|
+
headers=headers,
|
|
154
|
+
body=message_body,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def _set_pending_response_schema(self) -> None:
|
|
158
|
+
"""Store response schema on conversation for form validation."""
|
|
159
|
+
try:
|
|
160
|
+
await self._conversation_client.update_conversation(
|
|
161
|
+
self._original_message.conversation_id,
|
|
162
|
+
{"pending_response_schema": self._response_schema},
|
|
163
|
+
)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.error(f"Failed to set pending_response_schema: {e}")
|
|
166
|
+
|
|
167
|
+
async def _set_awaiting_route(self) -> None:
|
|
168
|
+
"""Set awaiting_route to this agent's name."""
|
|
169
|
+
try:
|
|
170
|
+
await self._conversation_client.update_conversation(
|
|
171
|
+
self._original_message.conversation_id,
|
|
172
|
+
{"awaiting_route": self._agent_name},
|
|
173
|
+
)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.error(f"Failed to set awaiting_route: {e}")
|
|
176
|
+
|
|
177
|
+
async def __aenter__(self) -> "Streamer":
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
181
|
+
if not self._finished:
|
|
182
|
+
# Pass None to let finish() determine awaiting based on schema or constructor value
|
|
183
|
+
await self.finish(awaiting=None)
|
|
184
|
+
return False # Don't suppress exceptions
|