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,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}")
|