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.
Files changed (41) hide show
  1. basion_agent/__init__.py +62 -0
  2. basion_agent/agent.py +360 -0
  3. basion_agent/agent_state_client.py +149 -0
  4. basion_agent/app.py +502 -0
  5. basion_agent/artifact.py +58 -0
  6. basion_agent/attachment_client.py +153 -0
  7. basion_agent/checkpoint_client.py +169 -0
  8. basion_agent/checkpointer.py +16 -0
  9. basion_agent/cli.py +139 -0
  10. basion_agent/conversation.py +103 -0
  11. basion_agent/conversation_client.py +86 -0
  12. basion_agent/conversation_message.py +48 -0
  13. basion_agent/exceptions.py +36 -0
  14. basion_agent/extensions/__init__.py +1 -0
  15. basion_agent/extensions/langgraph.py +526 -0
  16. basion_agent/extensions/pydantic_ai.py +180 -0
  17. basion_agent/gateway_client.py +531 -0
  18. basion_agent/gateway_pb2.py +73 -0
  19. basion_agent/gateway_pb2_grpc.py +101 -0
  20. basion_agent/heartbeat.py +84 -0
  21. basion_agent/loki_handler.py +355 -0
  22. basion_agent/memory.py +73 -0
  23. basion_agent/memory_client.py +155 -0
  24. basion_agent/message.py +333 -0
  25. basion_agent/py.typed +0 -0
  26. basion_agent/streamer.py +184 -0
  27. basion_agent/structural/__init__.py +6 -0
  28. basion_agent/structural/artifact.py +94 -0
  29. basion_agent/structural/base.py +71 -0
  30. basion_agent/structural/stepper.py +125 -0
  31. basion_agent/structural/surface.py +90 -0
  32. basion_agent/structural/text_block.py +96 -0
  33. basion_agent/tools/__init__.py +19 -0
  34. basion_agent/tools/container.py +46 -0
  35. basion_agent/tools/knowledge_graph.py +306 -0
  36. basion_agent-0.4.0.dist-info/METADATA +880 -0
  37. basion_agent-0.4.0.dist-info/RECORD +41 -0
  38. basion_agent-0.4.0.dist-info/WHEEL +5 -0
  39. basion_agent-0.4.0.dist-info/entry_points.txt +2 -0
  40. basion_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
  41. 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}")
@@ -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
@@ -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
@@ -0,0 +1,6 @@
1
+ from .artifact import Artifact
2
+ from .surface import Surface
3
+ from .text_block import TextBlock
4
+ from .stepper import Stepper
5
+
6
+ __all__ = ["Artifact", "Surface", "TextBlock", "Stepper"]