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,153 @@
1
+ """HTTP client for downloading attachments via gateway proxy."""
2
+
3
+ import base64
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from io import BytesIO
7
+ from typing import Optional
8
+
9
+ import aiohttp
10
+
11
+ from .exceptions import APIException
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class AttachmentInfo:
18
+ """Attachment metadata from message."""
19
+
20
+ id: str
21
+ url: str
22
+ filename: str
23
+ content_type: str
24
+ size: int
25
+ object_key: Optional[str] = None
26
+
27
+ @property
28
+ def file_extension(self) -> str:
29
+ """Get file extension without dot (e.g., 'pdf', 'png')."""
30
+ if "." in self.filename:
31
+ return self.filename.rsplit(".", 1)[-1].lower()
32
+ return ""
33
+
34
+ @property
35
+ def file_type(self) -> str:
36
+ """Get plain text file type (e.g., 'png', 'jpeg', 'pdf')."""
37
+ # Map common MIME types to simple names
38
+ mime_to_type = {
39
+ "image/png": "png",
40
+ "image/jpeg": "jpeg",
41
+ "image/jpg": "jpeg",
42
+ "image/gif": "gif",
43
+ "image/webp": "webp",
44
+ "image/svg+xml": "svg",
45
+ "application/pdf": "pdf",
46
+ }
47
+ if self.content_type in mime_to_type:
48
+ return mime_to_type[self.content_type]
49
+ # Fallback to extension or content_type subtype
50
+ if self.file_extension:
51
+ return self.file_extension
52
+ if "/" in self.content_type:
53
+ return self.content_type.split("/")[-1]
54
+ return "unknown"
55
+
56
+ def is_image(self) -> bool:
57
+ """Check if attachment is an image."""
58
+ return self.content_type.startswith("image/")
59
+
60
+ def is_pdf(self) -> bool:
61
+ """Check if attachment is a PDF."""
62
+ return self.content_type == "application/pdf"
63
+
64
+ @classmethod
65
+ def from_dict(cls, data: dict) -> "AttachmentInfo":
66
+ """Create AttachmentInfo from dictionary."""
67
+ return cls(
68
+ id=data.get("id", ""),
69
+ url=data.get("url", ""),
70
+ filename=data.get("filename", ""),
71
+ content_type=data.get("content_type", "application/octet-stream"),
72
+ size=data.get("size", 0),
73
+ object_key=data.get("object_key"),
74
+ )
75
+
76
+
77
+ class AttachmentClient:
78
+ """Client for downloading attachments into memory via gateway proxy."""
79
+
80
+ def __init__(self, base_url: str):
81
+ """
82
+ Initialize the attachment client.
83
+
84
+ Args:
85
+ base_url: Base URL for the attachment service proxy (e.g., "http://gateway:8080/s/attachment")
86
+ """
87
+ self.base_url = base_url.rstrip("/")
88
+ self._session: Optional[aiohttp.ClientSession] = None
89
+
90
+ async def _get_session(self) -> aiohttp.ClientSession:
91
+ """Get or create aiohttp session."""
92
+ if self._session is None or self._session.closed:
93
+ timeout = aiohttp.ClientTimeout(total=120.0) # 2 min for large files
94
+ self._session = aiohttp.ClientSession(timeout=timeout)
95
+ return self._session
96
+
97
+ async def close(self):
98
+ """Close the HTTP session."""
99
+ if self._session and not self._session.closed:
100
+ await self._session.close()
101
+
102
+ async def download(self, url: str) -> bytes:
103
+ """
104
+ Download file into memory as bytes.
105
+
106
+ Args:
107
+ url: URL path to the attachment (e.g., "/attachments/file/tenant/conv/id_file.pdf")
108
+
109
+ Returns:
110
+ File content as bytes
111
+ """
112
+ # Normalize URL - remove leading slash if present
113
+ url_path = url.lstrip("/")
114
+ full_url = f"{self.base_url}/{url_path}"
115
+ logger.info(f"Downloading attachment from: {full_url}")
116
+ session = await self._get_session()
117
+
118
+ try:
119
+ async with session.get(full_url) as response:
120
+ if response.status == 404:
121
+ raise APIException(f"Attachment not found: {url}")
122
+ response.raise_for_status()
123
+ data = await response.read()
124
+ logger.debug(f"Downloaded attachment: {url} ({len(data)} bytes)")
125
+ return data
126
+ except aiohttp.ClientError as e:
127
+ raise APIException(f"Failed to download attachment: {e}")
128
+
129
+ async def download_as_base64(self, url: str) -> str:
130
+ """
131
+ Download and encode as base64 (for LLM vision APIs).
132
+
133
+ Args:
134
+ url: URL path to the attachment
135
+
136
+ Returns:
137
+ Base64 encoded string of the file content
138
+ """
139
+ data = await self.download(url)
140
+ return base64.b64encode(data).decode("utf-8")
141
+
142
+ async def download_to_buffer(self, url: str) -> BytesIO:
143
+ """
144
+ Download to BytesIO buffer (for PDF/document processing libraries).
145
+
146
+ Args:
147
+ url: URL path to the attachment
148
+
149
+ Returns:
150
+ BytesIO buffer containing the file content
151
+ """
152
+ data = await self.download(url)
153
+ return BytesIO(data)
@@ -0,0 +1,169 @@
1
+ """HTTP client for checkpoint storage API."""
2
+
3
+ import logging
4
+ from typing import List, Dict, Any, Optional
5
+ import aiohttp
6
+
7
+ from .exceptions import APIException
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class CheckpointClient:
13
+ """Async HTTP client for checkpoint-store API.
14
+
15
+ This client communicates with the conversation-store's checkpoint endpoints
16
+ to store and retrieve LangGraph-compatible checkpoints.
17
+ """
18
+
19
+ def __init__(self, base_url: str):
20
+ """Initialize checkpoint client.
21
+
22
+ Args:
23
+ base_url: Base URL for the checkpoint API (e.g., http://gateway/s/conversation-store)
24
+ """
25
+ self.base_url = base_url.rstrip("/")
26
+ self._session: Optional[aiohttp.ClientSession] = None
27
+
28
+ async def _get_session(self) -> aiohttp.ClientSession:
29
+ """Get or create aiohttp session."""
30
+ if self._session is None or self._session.closed:
31
+ timeout = aiohttp.ClientTimeout(total=30.0)
32
+ self._session = aiohttp.ClientSession(timeout=timeout)
33
+ return self._session
34
+
35
+ async def close(self):
36
+ """Close the aiohttp session."""
37
+ if self._session and not self._session.closed:
38
+ await self._session.close()
39
+
40
+ async def put(
41
+ self,
42
+ config: Dict[str, Any],
43
+ checkpoint: Dict[str, Any],
44
+ metadata: Dict[str, Any],
45
+ new_versions: Optional[Dict[str, Any]] = None,
46
+ ) -> Dict[str, Any]:
47
+ """Store a checkpoint.
48
+
49
+ Args:
50
+ config: RunnableConfig with configurable containing thread_id, checkpoint_ns, checkpoint_id
51
+ checkpoint: Checkpoint data (channel_values, channel_versions, etc.)
52
+ metadata: Checkpoint metadata (source, step, parents, writes)
53
+ new_versions: New channel versions (optional)
54
+
55
+ Returns:
56
+ Config of the stored checkpoint
57
+ """
58
+ url = f"{self.base_url}/checkpoints/"
59
+ payload = {
60
+ "config": config,
61
+ "checkpoint": checkpoint,
62
+ "metadata": metadata,
63
+ "new_versions": new_versions or {},
64
+ }
65
+
66
+ session = await self._get_session()
67
+ try:
68
+ async with session.put(url, json=payload) as response:
69
+ if response.status >= 400:
70
+ error_text = await response.text()
71
+ raise APIException(f"Failed to store checkpoint: {response.status} - {error_text}")
72
+ return await response.json()
73
+ except aiohttp.ClientError as e:
74
+ raise APIException(f"Failed to store checkpoint: {e}")
75
+
76
+ async def get_tuple(
77
+ self,
78
+ thread_id: str,
79
+ checkpoint_ns: str = "",
80
+ checkpoint_id: Optional[str] = None,
81
+ ) -> Optional[Dict[str, Any]]:
82
+ """Get a checkpoint tuple.
83
+
84
+ Args:
85
+ thread_id: Thread identifier
86
+ checkpoint_ns: Checkpoint namespace (default "")
87
+ checkpoint_id: Specific checkpoint ID (latest if not provided)
88
+
89
+ Returns:
90
+ CheckpointTuple dict or None if not found
91
+ """
92
+ url = f"{self.base_url}/checkpoints/{thread_id}"
93
+ params = {"checkpoint_ns": checkpoint_ns}
94
+ if checkpoint_id:
95
+ params["checkpoint_id"] = checkpoint_id
96
+
97
+ session = await self._get_session()
98
+ try:
99
+ async with session.get(url, params=params) as response:
100
+ if response.status == 404:
101
+ return None
102
+ if response.status >= 400:
103
+ error_text = await response.text()
104
+ raise APIException(f"Failed to get checkpoint: {response.status} - {error_text}")
105
+ result = await response.json()
106
+ # API returns null for not found
107
+ return result if result else None
108
+ except aiohttp.ClientError as e:
109
+ raise APIException(f"Failed to get checkpoint: {e}")
110
+
111
+ async def list(
112
+ self,
113
+ thread_id: Optional[str] = None,
114
+ checkpoint_ns: Optional[str] = None,
115
+ before: Optional[str] = None,
116
+ limit: Optional[int] = None,
117
+ ) -> List[Dict[str, Any]]:
118
+ """List checkpoints.
119
+
120
+ Args:
121
+ thread_id: Filter by thread ID
122
+ checkpoint_ns: Filter by namespace
123
+ before: Return checkpoints before this checkpoint_id
124
+ limit: Maximum number of checkpoints to return
125
+
126
+ Returns:
127
+ List of CheckpointTuple dicts
128
+ """
129
+ url = f"{self.base_url}/checkpoints/"
130
+ params = {}
131
+ if thread_id:
132
+ params["thread_id"] = thread_id
133
+ if checkpoint_ns is not None:
134
+ params["checkpoint_ns"] = checkpoint_ns
135
+ if before:
136
+ params["before"] = before
137
+ if limit:
138
+ params["limit"] = limit
139
+
140
+ session = await self._get_session()
141
+ try:
142
+ async with session.get(url, params=params) as response:
143
+ if response.status >= 400:
144
+ error_text = await response.text()
145
+ raise APIException(f"Failed to list checkpoints: {response.status} - {error_text}")
146
+ return await response.json()
147
+ except aiohttp.ClientError as e:
148
+ raise APIException(f"Failed to list checkpoints: {e}")
149
+
150
+ async def delete_thread(self, thread_id: str) -> Dict[str, Any]:
151
+ """Delete all checkpoints for a thread.
152
+
153
+ Args:
154
+ thread_id: Thread identifier
155
+
156
+ Returns:
157
+ Dict with thread_id and deleted count
158
+ """
159
+ url = f"{self.base_url}/checkpoints/{thread_id}"
160
+
161
+ session = await self._get_session()
162
+ try:
163
+ async with session.delete(url) as response:
164
+ if response.status >= 400:
165
+ error_text = await response.text()
166
+ raise APIException(f"Failed to delete checkpoints: {response.status} - {error_text}")
167
+ return await response.json()
168
+ except aiohttp.ClientError as e:
169
+ raise APIException(f"Failed to delete checkpoints: {e}")
@@ -0,0 +1,16 @@
1
+ """Checkpoint client module.
2
+
3
+ This module provides the low-level HTTP client for checkpoint operations.
4
+ For the LangGraph-compatible checkpointer, see basion_agent.extensions.langgraph.
5
+
6
+ Usage:
7
+ from basion_agent.extensions.langgraph import HTTPCheckpointSaver
8
+
9
+ app = BasionAgentApp(gateway_url="agent-gateway:8080", api_key="key")
10
+ checkpointer = HTTPCheckpointSaver(app=app)
11
+ """
12
+
13
+ # Re-export CheckpointClient for backwards compatibility
14
+ from .checkpoint_client import CheckpointClient
15
+
16
+ __all__ = ["CheckpointClient"]
basion_agent/cli.py ADDED
@@ -0,0 +1,139 @@
1
+ """CLI interface for Basion AI Agent framework.
2
+
3
+ Inspired by uvicorn's simple pattern: basion-agent run main:app
4
+ """
5
+
6
+ import sys
7
+ import argparse
8
+ import importlib
9
+ from pathlib import Path
10
+
11
+
12
+ def import_from_string(import_str: str):
13
+ """
14
+ Import an attribute from a module using uvicorn-style string.
15
+
16
+ Examples:
17
+ "main:app" → imports 'app' from 'main' module
18
+ "main:setup" → imports 'setup' from 'main' module
19
+ "main" → imports 'main' module and looks for 'app' attribute
20
+
21
+ Args:
22
+ import_str: Import string in format "module:attribute" or "module"
23
+
24
+ Returns:
25
+ tuple: (module, attribute_name, attribute_value)
26
+ """
27
+ # Add current directory to path
28
+ sys.path.insert(0, str(Path.cwd()))
29
+
30
+ # Parse import string
31
+ if ":" in import_str:
32
+ module_str, attr_str = import_str.split(":", 1)
33
+ else:
34
+ module_str = import_str
35
+ attr_str = "app" # Default to 'app' like uvicorn
36
+
37
+ # Import module
38
+ try:
39
+ module = importlib.import_module(module_str)
40
+ except ImportError as e:
41
+ raise ImportError(f"Could not import module '{module_str}': {e}")
42
+
43
+ # Get attribute
44
+ if not hasattr(module, attr_str):
45
+ raise AttributeError(
46
+ f"Module '{module_str}' has no attribute '{attr_str}'. "
47
+ f"Please ensure you have '{attr_str} = BasionAgentApp(...)' in your file."
48
+ )
49
+
50
+ attr = getattr(module, attr_str)
51
+ return module, attr_str, attr
52
+
53
+
54
+ def run_agent(import_str: str) -> None:
55
+ """
56
+ Load and run an agent using uvicorn-style import string.
57
+
58
+ Args:
59
+ import_str: Import string like "main:app" or "main"
60
+ """
61
+ from basion_agent import BasionAgentApp
62
+
63
+ print(f"Loading agent from: {import_str}")
64
+
65
+ # Import the app
66
+ module, attr_name, app = import_from_string(import_str)
67
+
68
+ # Validate it's a BasionAgentApp
69
+ if not isinstance(app, BasionAgentApp):
70
+ raise TypeError(
71
+ f"Attribute '{attr_name}' must be a BasionAgentApp instance, "
72
+ f"got {type(app).__name__} instead."
73
+ )
74
+
75
+ print(f"Running {attr_name}.run()...")
76
+ app.run()
77
+
78
+
79
+ def main() -> None:
80
+ """Main CLI entry point."""
81
+ parser = argparse.ArgumentParser(
82
+ prog="basion-agent",
83
+ description="Basion AI Agent CLI - Run AI agents with uvicorn-style imports",
84
+ formatter_class=argparse.RawDescriptionHelpFormatter,
85
+ epilog="""
86
+ Examples:
87
+ basion-agent run main:app Run 'app' from 'main' module (recommended)
88
+ basion-agent run main Run 'app' from 'main' module (defaults to :app)
89
+ basion-agent run myagent:application Run 'application' from 'myagent' module
90
+
91
+ Your agent file should have:
92
+ app = BasionAgentApp(...)
93
+
94
+ @app.agent(name="...", about="...", document="...")
95
+ def my_agent(message):
96
+ # Handle message
97
+ """,
98
+ )
99
+
100
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
101
+
102
+ # run command
103
+ run_parser = subparsers.add_parser(
104
+ "run",
105
+ help="Run an agent",
106
+ description="Load and run a Basion AI agent using module:attribute syntax",
107
+ )
108
+ run_parser.add_argument(
109
+ "app",
110
+ help="Import path in format 'module:attribute' (e.g., main:app)",
111
+ )
112
+
113
+ # version command
114
+ version_parser = subparsers.add_parser(
115
+ "version",
116
+ help="Show version information",
117
+ )
118
+
119
+ args = parser.parse_args()
120
+
121
+ # Handle commands
122
+ if args.command == "run":
123
+ try:
124
+ run_agent(args.app)
125
+ except Exception as e:
126
+ print(f"Error: {e}", file=sys.stderr)
127
+ sys.exit(1)
128
+
129
+ elif args.command == "version":
130
+ from basion_agent import __version__
131
+ print(f"basion-agent version {__version__}")
132
+
133
+ else:
134
+ parser.print_help()
135
+ sys.exit(1)
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
@@ -0,0 +1,103 @@
1
+ """Conversation context for accessing message history."""
2
+
3
+ import asyncio
4
+ from typing import List, Dict, Any, Optional
5
+
6
+ from .conversation_client import ConversationClient
7
+ from .conversation_message import ConversationMessage
8
+
9
+
10
+ class Conversation:
11
+ """Lazy-loaded conversation context (async)."""
12
+
13
+ def __init__(
14
+ self,
15
+ conversation_id: str,
16
+ client: ConversationClient,
17
+ agent_name: Optional[str] = None,
18
+ ):
19
+ self.id = conversation_id
20
+ self._client = client
21
+ self._agent_name = agent_name
22
+
23
+ async def get_history(
24
+ self,
25
+ role: Optional[str] = None,
26
+ limit: int = 100,
27
+ offset: int = 0,
28
+ ) -> List[ConversationMessage]:
29
+ """Fetch conversation message history.
30
+
31
+ Args:
32
+ role: Optional filter by role ('user', 'assistant', 'system')
33
+ limit: Maximum messages to fetch
34
+ offset: Pagination offset
35
+
36
+ Returns:
37
+ List of ConversationMessage objects
38
+ """
39
+ messages = await self._client.get_messages(
40
+ conversation_id=self.id, role=role, limit=limit, offset=offset
41
+ )
42
+ return ConversationMessage.from_list(messages)
43
+
44
+ async def get_metadata(self) -> Dict[str, Any]:
45
+ """Fetch conversation metadata."""
46
+ return await self._client.get_conversation(self.id)
47
+
48
+ async def get_agent_history(
49
+ self,
50
+ agent_name: Optional[str] = None,
51
+ role: Optional[str] = None,
52
+ limit: int = 100,
53
+ offset: int = 0,
54
+ ) -> List[ConversationMessage]:
55
+ """Fetch messages where agent was sender or recipient.
56
+
57
+ Makes two API calls concurrently to get both sent and received messages,
58
+ then merges and sorts by sequence number.
59
+
60
+ Args:
61
+ agent_name: The agent name to filter by. Defaults to the current agent
62
+ if not specified (requires agent_name to be set during Conversation init).
63
+ role: Optional role filter ('user', 'assistant', 'system')
64
+ limit: Maximum messages to fetch per query
65
+ offset: Pagination offset
66
+
67
+ Returns:
68
+ List of ConversationMessage where from_==agent_name OR to_==agent_name
69
+
70
+ Raises:
71
+ ValueError: If agent_name is not provided and no default is set.
72
+ """
73
+ # Use provided agent_name or fall back to the default from init
74
+ agent_name = agent_name or self._agent_name
75
+ if not agent_name:
76
+ raise ValueError(
77
+ "agent_name must be provided either as argument or set during Conversation initialization"
78
+ )
79
+ # Run both queries concurrently
80
+ sent_task = self._client.get_messages(
81
+ conversation_id=self.id,
82
+ role=role,
83
+ from_=agent_name,
84
+ limit=limit,
85
+ offset=offset,
86
+ )
87
+
88
+ received_task = self._client.get_messages(
89
+ conversation_id=self.id,
90
+ role=role,
91
+ to_=agent_name,
92
+ limit=limit,
93
+ offset=offset,
94
+ )
95
+
96
+ sent, received = await asyncio.gather(sent_task, received_task)
97
+
98
+ # Merge and dedupe (in case from_==to_)
99
+ all_msgs = {msg["id"]: msg for msg in sent + received}
100
+
101
+ # Sort by sequence_number and convert to typed messages
102
+ sorted_msgs = sorted(all_msgs.values(), key=lambda m: m.get("sequence_number", 0))
103
+ return ConversationMessage.from_list(sorted_msgs)
@@ -0,0 +1,86 @@
1
+ """Conversation store client for fetching conversation history."""
2
+
3
+ import logging
4
+ from typing import List, Dict, Any, Optional
5
+ import aiohttp
6
+
7
+ from .exceptions import APIException
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ConversationClient:
13
+ """Client for interacting with conversation-store API (async)."""
14
+
15
+ def __init__(self, base_url: str):
16
+ self.base_url = base_url.rstrip("/")
17
+ self._session: Optional[aiohttp.ClientSession] = None
18
+
19
+ async def _get_session(self) -> aiohttp.ClientSession:
20
+ """Get or create aiohttp session."""
21
+ if self._session is None or self._session.closed:
22
+ timeout = aiohttp.ClientTimeout(total=10.0)
23
+ self._session = aiohttp.ClientSession(timeout=timeout)
24
+ return self._session
25
+
26
+ async def close(self):
27
+ """Close the aiohttp session."""
28
+ if self._session and not self._session.closed:
29
+ await self._session.close()
30
+
31
+ async def get_conversation(self, conversation_id: str) -> Dict[str, Any]:
32
+ """Get conversation metadata."""
33
+ url = f"{self.base_url}/conversations/{conversation_id}"
34
+ session = await self._get_session()
35
+ try:
36
+ async with session.get(url) as response:
37
+ if response.status == 404:
38
+ raise APIException(f"Conversation {conversation_id} not found")
39
+ response.raise_for_status()
40
+ return await response.json()
41
+ except aiohttp.ClientError as e:
42
+ raise APIException(f"Failed to fetch conversation: {e}")
43
+
44
+ async def get_messages(
45
+ self,
46
+ conversation_id: str,
47
+ role: Optional[str] = None,
48
+ from_: Optional[str] = None,
49
+ to_: Optional[str] = None,
50
+ limit: int = 100,
51
+ offset: int = 0,
52
+ ) -> List[Dict[str, Any]]:
53
+ """Get messages from a conversation."""
54
+ url = f"{self.base_url}/conversations/{conversation_id}/messages"
55
+ params = {"limit": min(limit, 1000), "offset": offset}
56
+ if role:
57
+ params["role"] = role
58
+ if from_:
59
+ params["from"] = from_
60
+ if to_:
61
+ params["to"] = to_
62
+
63
+ session = await self._get_session()
64
+ try:
65
+ async with session.get(url, params=params) as response:
66
+ if response.status == 404:
67
+ raise APIException(f"Conversation {conversation_id} not found")
68
+ response.raise_for_status()
69
+ return await response.json()
70
+ except aiohttp.ClientError as e:
71
+ raise APIException(f"Failed to fetch messages: {e}")
72
+
73
+ async def update_conversation(
74
+ self,
75
+ conversation_id: str,
76
+ data: Dict[str, Any],
77
+ ) -> Dict[str, Any]:
78
+ """Update conversation fields (PATCH)."""
79
+ url = f"{self.base_url}/conversations/{conversation_id}"
80
+ session = await self._get_session()
81
+ try:
82
+ async with session.patch(url, json=data) as response:
83
+ response.raise_for_status()
84
+ return await response.json()
85
+ except aiohttp.ClientError as e:
86
+ raise APIException(f"Failed to update conversation: {e}")