solana-agent 30.0.9__py3-none-any.whl → 31.1.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.
- solana_agent/client/solana_agent.py +4 -0
- solana_agent/domains/agent.py +7 -1
- solana_agent/factories/agent_factory.py +30 -6
- solana_agent/interfaces/providers/memory.py +12 -0
- solana_agent/interfaces/services/query.py +2 -0
- solana_agent/repositories/memory.py +156 -88
- solana_agent/services/agent.py +33 -1
- solana_agent/services/query.py +116 -0
- {solana_agent-30.0.9.dist-info → solana_agent-31.1.0.dist-info}/METADATA +33 -28
- {solana_agent-30.0.9.dist-info → solana_agent-31.1.0.dist-info}/RECORD +13 -13
- {solana_agent-30.0.9.dist-info → solana_agent-31.1.0.dist-info}/LICENSE +0 -0
- {solana_agent-30.0.9.dist-info → solana_agent-31.1.0.dist-info}/WHEEL +0 -0
- {solana_agent-30.0.9.dist-info → solana_agent-31.1.0.dist-info}/entry_points.txt +0 -0
@@ -49,6 +49,8 @@ class SolanaAgent(SolanaAgentInterface):
|
|
49
49
|
user_id: str,
|
50
50
|
message: Union[str, bytes],
|
51
51
|
prompt: Optional[str] = None,
|
52
|
+
capture_schema: Optional[Dict[str, Any]] = None,
|
53
|
+
capture_name: Optional[str] = None,
|
52
54
|
output_format: Literal["text", "audio"] = "text",
|
53
55
|
audio_voice: Literal[
|
54
56
|
"alloy",
|
@@ -103,6 +105,8 @@ class SolanaAgent(SolanaAgentInterface):
|
|
103
105
|
prompt=prompt,
|
104
106
|
router=router,
|
105
107
|
output_model=output_model,
|
108
|
+
capture_schema=capture_schema,
|
109
|
+
capture_name=capture_name,
|
106
110
|
):
|
107
111
|
yield chunk
|
108
112
|
|
solana_agent/domains/agent.py
CHANGED
@@ -5,7 +5,7 @@ This module defines the core domain models for representing
|
|
5
5
|
AI agents, human agents, and business mission/values.
|
6
6
|
"""
|
7
7
|
|
8
|
-
from typing import List, Dict
|
8
|
+
from typing import List, Dict, Optional, Any
|
9
9
|
from pydantic import BaseModel, Field, field_validator
|
10
10
|
|
11
11
|
|
@@ -53,6 +53,12 @@ class AIAgent(BaseModel):
|
|
53
53
|
name: str = Field(..., description="Unique agent identifier name")
|
54
54
|
instructions: str = Field(..., description="Base instructions for the agent")
|
55
55
|
specialization: str = Field(..., description="Agent's specialized domain")
|
56
|
+
capture_name: Optional[str] = Field(
|
57
|
+
default=None, description="Optional capture name for structured data"
|
58
|
+
)
|
59
|
+
capture_schema: Optional[Dict[str, Any]] = Field(
|
60
|
+
default=None, description="Optional JSON schema for structured capture"
|
61
|
+
)
|
56
62
|
|
57
63
|
@field_validator("name", "specialization")
|
58
64
|
@classmethod
|
@@ -133,21 +133,38 @@ class SolanaAgentFactory:
|
|
133
133
|
voice=org_config.get("voice", ""),
|
134
134
|
)
|
135
135
|
|
136
|
+
# Build capture modes from agent config if provided
|
137
|
+
capture_modes: Dict[str, str] = {}
|
138
|
+
for agent in config.get("agents", []):
|
139
|
+
mode = agent.get("capture_mode")
|
140
|
+
if mode in {"once", "multiple"} and agent.get("name"):
|
141
|
+
capture_modes[agent["name"]] = mode
|
142
|
+
|
136
143
|
# Create repositories
|
137
144
|
memory_provider = None
|
138
145
|
|
139
146
|
if "zep" in config and "mongo" in config:
|
140
|
-
|
141
|
-
mongo_adapter
|
142
|
-
|
147
|
+
mem_kwargs: Dict[str, Any] = {
|
148
|
+
"mongo_adapter": db_adapter,
|
149
|
+
"zep_api_key": config["zep"].get("api_key"),
|
150
|
+
}
|
151
|
+
if capture_modes: # pragma: no cover
|
152
|
+
mem_kwargs["capture_modes"] = capture_modes
|
153
|
+
memory_provider = MemoryRepository(**mem_kwargs)
|
143
154
|
|
144
155
|
if "mongo" in config and "zep" not in config:
|
145
|
-
|
156
|
+
mem_kwargs = {"mongo_adapter": db_adapter}
|
157
|
+
if capture_modes:
|
158
|
+
mem_kwargs["capture_modes"] = capture_modes
|
159
|
+
memory_provider = MemoryRepository(**mem_kwargs)
|
146
160
|
|
147
161
|
if "zep" in config and "mongo" not in config:
|
148
162
|
if "api_key" not in config["zep"]:
|
149
163
|
raise ValueError("Zep API key is required.")
|
150
|
-
|
164
|
+
mem_kwargs = {"zep_api_key": config["zep"].get("api_key")}
|
165
|
+
if capture_modes: # pragma: no cover
|
166
|
+
mem_kwargs["capture_modes"] = capture_modes
|
167
|
+
memory_provider = MemoryRepository(**mem_kwargs)
|
151
168
|
|
152
169
|
guardrail_config = config.get("guardrails", {})
|
153
170
|
input_guardrails: List[InputGuardrail] = SolanaAgentFactory._create_guardrails(
|
@@ -191,11 +208,18 @@ class SolanaAgentFactory:
|
|
191
208
|
loaded_plugins = 0
|
192
209
|
|
193
210
|
# Register predefined agents
|
194
|
-
for agent_config in config.get("agents", []):
|
211
|
+
for agent_config in config.get("agents", []): # pragma: no cover
|
212
|
+
extra_kwargs = {}
|
213
|
+
if "capture_name" in agent_config:
|
214
|
+
extra_kwargs["capture_name"] = agent_config.get("capture_name")
|
215
|
+
if "capture_schema" in agent_config:
|
216
|
+
extra_kwargs["capture_schema"] = agent_config.get("capture_schema")
|
217
|
+
|
195
218
|
agent_service.register_ai_agent(
|
196
219
|
name=agent_config["name"],
|
197
220
|
instructions=agent_config["instructions"],
|
198
221
|
specialization=agent_config["specialization"],
|
222
|
+
**extra_kwargs,
|
199
223
|
)
|
200
224
|
|
201
225
|
# Register tools for this agent
|
@@ -36,3 +36,15 @@ class MemoryProvider(ABC):
|
|
36
36
|
def count_documents(self, collection: str, query: Dict) -> int:
|
37
37
|
"""Count documents matching query."""
|
38
38
|
pass
|
39
|
+
|
40
|
+
@abstractmethod
|
41
|
+
async def save_capture(
|
42
|
+
self,
|
43
|
+
user_id: str,
|
44
|
+
capture_name: str,
|
45
|
+
agent_name: Optional[str],
|
46
|
+
data: Dict[str, Any],
|
47
|
+
schema: Optional[Dict[str, Any]] = None,
|
48
|
+
) -> Optional[str]:
|
49
|
+
"""Persist a structured capture for a user and return its ID if available."""
|
50
|
+
pass
|
@@ -38,6 +38,8 @@ class QueryService(ABC):
|
|
38
38
|
router: Optional[RoutingInterface] = None,
|
39
39
|
images: Optional[List[Union[str, bytes]]] = None,
|
40
40
|
output_model: Optional[Type[BaseModel]] = None,
|
41
|
+
capture_schema: Optional[Dict[str, Any]] = None,
|
42
|
+
capture_name: Optional[str] = None,
|
41
43
|
) -> AsyncGenerator[Union[str, bytes, BaseModel], None]:
|
42
44
|
"""Process the user request and generate a response."""
|
43
45
|
pass
|
@@ -1,13 +1,14 @@
|
|
1
|
-
import logging
|
2
|
-
from
|
3
|
-
from typing import List, Dict, Any, Optional, Tuple
|
1
|
+
import logging
|
2
|
+
from typing import List, Dict, Optional, Tuple, Any
|
4
3
|
from datetime import datetime, timezone
|
4
|
+
from copy import deepcopy
|
5
|
+
|
5
6
|
from zep_cloud.client import AsyncZep as AsyncZepCloud
|
6
7
|
from zep_cloud.types import Message
|
8
|
+
|
7
9
|
from solana_agent.interfaces.providers.memory import MemoryProvider
|
8
10
|
from solana_agent.adapters.mongodb_adapter import MongoDBAdapter
|
9
11
|
|
10
|
-
# Setup logger for this module
|
11
12
|
logger = logging.getLogger(__name__)
|
12
13
|
|
13
14
|
|
@@ -18,151 +19,157 @@ class MemoryRepository(MemoryProvider):
|
|
18
19
|
self,
|
19
20
|
mongo_adapter: Optional[MongoDBAdapter] = None,
|
20
21
|
zep_api_key: Optional[str] = None,
|
22
|
+
capture_modes: Optional[Dict[str, str]] = None,
|
21
23
|
):
|
22
|
-
|
24
|
+
self.capture_modes: Dict[str, str] = capture_modes or {}
|
25
|
+
|
26
|
+
# Mongo setup
|
23
27
|
if not mongo_adapter:
|
24
28
|
self.mongo = None
|
25
29
|
self.collection = None
|
30
|
+
self.captures_collection = "captures"
|
26
31
|
else:
|
27
|
-
# Initialize MongoDB
|
28
32
|
self.mongo = mongo_adapter
|
29
33
|
self.collection = "conversations"
|
30
|
-
|
31
34
|
try:
|
32
|
-
# Ensure MongoDB collection and indexes
|
33
35
|
self.mongo.create_collection(self.collection)
|
34
36
|
self.mongo.create_index(self.collection, [("user_id", 1)])
|
35
37
|
self.mongo.create_index(self.collection, [("timestamp", 1)])
|
36
38
|
except Exception as e:
|
37
|
-
logger.error(f"Error initializing MongoDB: {e}")
|
39
|
+
logger.error(f"Error initializing MongoDB: {e}")
|
38
40
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
41
|
+
try:
|
42
|
+
self.captures_collection = "captures"
|
43
|
+
self.mongo.create_collection(self.captures_collection)
|
44
|
+
# Basic indexes
|
45
|
+
self.mongo.create_index(self.captures_collection, [("user_id", 1)])
|
46
|
+
self.mongo.create_index(self.captures_collection, [("capture_name", 1)])
|
47
|
+
self.mongo.create_index(self.captures_collection, [("agent_name", 1)])
|
48
|
+
self.mongo.create_index(self.captures_collection, [("timestamp", 1)])
|
49
|
+
# Unique only when mode == 'once'
|
50
|
+
try:
|
51
|
+
self.mongo.create_index(
|
52
|
+
self.captures_collection,
|
53
|
+
[("user_id", 1), ("agent_name", 1), ("capture_name", 1)],
|
54
|
+
unique=True,
|
55
|
+
partialFilterExpression={"mode": "once"},
|
56
|
+
)
|
57
|
+
except Exception as e:
|
58
|
+
logger.error(
|
59
|
+
f"Error creating partial unique index for captures: {e}"
|
60
|
+
)
|
61
|
+
except Exception as e:
|
62
|
+
logger.error(f"Error initializing MongoDB captures collection: {e}")
|
63
|
+
self.captures_collection = "captures"
|
64
|
+
|
65
|
+
# Zep setup
|
66
|
+
self.zep = AsyncZepCloud(api_key=zep_api_key) if zep_api_key else None
|
43
67
|
|
44
68
|
async def store(self, user_id: str, messages: List[Dict[str, Any]]) -> None:
|
45
|
-
|
46
|
-
if not user_id:
|
69
|
+
if not user_id or not isinstance(user_id, str):
|
47
70
|
raise ValueError("User ID cannot be None or empty")
|
48
71
|
if not messages or not isinstance(messages, list):
|
49
72
|
raise ValueError("Messages must be a non-empty list")
|
50
73
|
if not all(
|
51
|
-
isinstance(
|
52
|
-
for msg in messages
|
74
|
+
isinstance(m, dict) and "role" in m and "content" in m for m in messages
|
53
75
|
):
|
54
76
|
raise ValueError(
|
55
77
|
"All messages must be dictionaries with 'role' and 'content' keys"
|
56
78
|
)
|
57
|
-
for
|
58
|
-
if
|
79
|
+
for m in messages:
|
80
|
+
if m["role"] not in ["user", "assistant"]:
|
59
81
|
raise ValueError(
|
60
|
-
|
82
|
+
"Invalid role in message. Only 'user' and 'assistant' are accepted."
|
61
83
|
)
|
62
84
|
|
63
|
-
#
|
85
|
+
# Persist last user/assistant pair to Mongo
|
64
86
|
if self.mongo and len(messages) >= 2:
|
65
87
|
try:
|
66
|
-
# Get last user and assistant messages
|
67
88
|
user_msg = None
|
68
89
|
assistant_msg = None
|
69
|
-
for
|
70
|
-
if
|
71
|
-
user_msg =
|
72
|
-
elif
|
73
|
-
assistant_msg =
|
90
|
+
for m in reversed(messages):
|
91
|
+
if m.get("role") == "user" and not user_msg:
|
92
|
+
user_msg = m.get("content")
|
93
|
+
elif m.get("role") == "assistant" and not assistant_msg:
|
94
|
+
assistant_msg = m.get("content")
|
74
95
|
if user_msg and assistant_msg:
|
75
96
|
break
|
76
|
-
|
77
97
|
if user_msg and assistant_msg:
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
98
|
+
self.mongo.insert_one(
|
99
|
+
self.collection,
|
100
|
+
{
|
101
|
+
"user_id": user_id,
|
102
|
+
"user_message": user_msg,
|
103
|
+
"assistant_message": assistant_msg,
|
104
|
+
"timestamp": datetime.now(timezone.utc),
|
105
|
+
},
|
106
|
+
)
|
86
107
|
except Exception as e:
|
87
|
-
logger.error(f"MongoDB storage error: {e}")
|
108
|
+
logger.error(f"MongoDB storage error: {e}")
|
88
109
|
|
89
|
-
#
|
110
|
+
# Zep
|
90
111
|
if not self.zep:
|
91
112
|
return
|
92
113
|
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
)
|
103
|
-
zep_messages.append(zep_msg)
|
114
|
+
zep_messages: List[Message] = []
|
115
|
+
for m in messages:
|
116
|
+
content = (
|
117
|
+
self._truncate(deepcopy(m.get("content"))) if "content" in m else None
|
118
|
+
)
|
119
|
+
if content is None: # pragma: no cover
|
120
|
+
continue
|
121
|
+
role_type = "user" if m.get("role") == "user" else "assistant"
|
122
|
+
zep_messages.append(Message(content=content, role=role_type))
|
104
123
|
|
105
|
-
# Add messages to Zep memory
|
106
124
|
if zep_messages:
|
107
125
|
try:
|
108
|
-
await self.zep.
|
126
|
+
await self.zep.thread.add_messages(
|
127
|
+
thread_id=user_id, messages=zep_messages
|
128
|
+
)
|
109
129
|
except Exception:
|
110
130
|
try:
|
111
131
|
try:
|
112
132
|
await self.zep.user.add(user_id=user_id)
|
113
133
|
except Exception as e:
|
114
|
-
logger.error(
|
115
|
-
f"Zep user addition error: {e}"
|
116
|
-
) # Use logger.error
|
117
|
-
|
134
|
+
logger.error(f"Zep user addition error: {e}")
|
118
135
|
try:
|
119
|
-
await self.zep.
|
120
|
-
session_id=user_id, user_id=user_id
|
121
|
-
)
|
136
|
+
await self.zep.thread.create(thread_id=user_id, user_id=user_id)
|
122
137
|
except Exception as e:
|
123
|
-
logger.error(
|
124
|
-
|
125
|
-
|
126
|
-
|
138
|
+
logger.error(f"Zep thread creation error: {e}")
|
139
|
+
await self.zep.thread.add_messages(
|
140
|
+
thread_id=user_id, messages=zep_messages
|
141
|
+
)
|
127
142
|
except Exception as e:
|
128
|
-
logger.error(f"Zep memory addition error: {e}")
|
129
|
-
return
|
143
|
+
logger.error(f"Zep memory addition error: {e}")
|
130
144
|
|
131
145
|
async def retrieve(self, user_id: str) -> str:
|
132
|
-
"""Retrieve memory context from Zep."""
|
133
146
|
try:
|
134
147
|
memories = ""
|
135
148
|
if self.zep:
|
136
|
-
memory = await self.zep.
|
149
|
+
memory = await self.zep.thread.get_user_context(thread_id=user_id)
|
137
150
|
if memory and memory.context:
|
138
151
|
memories = memory.context
|
139
|
-
|
140
152
|
return memories
|
141
|
-
|
142
153
|
except Exception as e:
|
143
|
-
logger.error(f"Error retrieving memories: {e}")
|
154
|
+
logger.error(f"Error retrieving memories: {e}")
|
144
155
|
return ""
|
145
156
|
|
146
157
|
async def delete(self, user_id: str) -> None:
|
147
|
-
"""Delete memory from both systems."""
|
148
158
|
if self.mongo:
|
149
159
|
try:
|
150
160
|
self.mongo.delete_all(self.collection, {"user_id": user_id})
|
151
161
|
except Exception as e:
|
152
|
-
logger.error(f"MongoDB deletion error: {e}")
|
153
|
-
|
162
|
+
logger.error(f"MongoDB deletion error: {e}")
|
154
163
|
if not self.zep:
|
155
164
|
return
|
156
|
-
|
157
165
|
try:
|
158
|
-
await self.zep.
|
166
|
+
await self.zep.thread.delete(thread_id=user_id)
|
159
167
|
except Exception as e:
|
160
|
-
logger.error(f"Zep memory deletion error: {e}")
|
161
|
-
|
168
|
+
logger.error(f"Zep memory deletion error: {e}")
|
162
169
|
try:
|
163
170
|
await self.zep.user.delete(user_id=user_id)
|
164
171
|
except Exception as e:
|
165
|
-
logger.error(f"Zep user deletion error: {e}")
|
172
|
+
logger.error(f"Zep user deletion error: {e}")
|
166
173
|
|
167
174
|
def find(
|
168
175
|
self,
|
@@ -172,37 +179,98 @@ class MemoryRepository(MemoryProvider):
|
|
172
179
|
limit: int = 0,
|
173
180
|
skip: int = 0,
|
174
181
|
) -> List[Dict]: # pragma: no cover
|
175
|
-
"""Find documents in MongoDB."""
|
176
182
|
if not self.mongo:
|
177
183
|
return []
|
178
|
-
|
179
184
|
try:
|
180
185
|
return self.mongo.find(collection, query, sort=sort, limit=limit, skip=skip)
|
181
186
|
except Exception as e:
|
182
|
-
logger.error(f"MongoDB find error: {e}")
|
187
|
+
logger.error(f"MongoDB find error: {e}")
|
183
188
|
return []
|
184
189
|
|
185
190
|
def count_documents(self, collection: str, query: Dict) -> int:
|
186
|
-
"""Count documents in MongoDB."""
|
187
191
|
if not self.mongo:
|
188
192
|
return 0
|
189
193
|
return self.mongo.count_documents(collection, query)
|
190
194
|
|
191
195
|
def _truncate(self, text: str, limit: int = 2500) -> str:
|
192
|
-
"""Truncate text to be within limits."""
|
193
196
|
if text is None:
|
194
197
|
raise AttributeError("Cannot truncate None text")
|
195
|
-
|
196
198
|
if not text:
|
197
199
|
return ""
|
198
|
-
|
199
200
|
if len(text) <= limit:
|
200
201
|
return text
|
201
|
-
|
202
|
-
# Try to truncate at last period before limit
|
203
202
|
last_period = text.rfind(".", 0, limit)
|
204
203
|
if last_period > 0:
|
205
204
|
return text[: last_period + 1]
|
206
|
-
|
207
|
-
# If no period found, truncate at limit and add ellipsis
|
208
205
|
return text[: limit - 3] + "..."
|
206
|
+
|
207
|
+
async def save_capture(
|
208
|
+
self,
|
209
|
+
user_id: str,
|
210
|
+
capture_name: str,
|
211
|
+
agent_name: Optional[str],
|
212
|
+
data: Dict[str, Any],
|
213
|
+
schema: Optional[Dict[str, Any]] = None,
|
214
|
+
) -> Optional[str]:
|
215
|
+
if not self.mongo: # pragma: no cover
|
216
|
+
logger.warning("MongoDB not configured; cannot save capture.")
|
217
|
+
return None
|
218
|
+
if not user_id or not isinstance(user_id, str):
|
219
|
+
raise ValueError("user_id must be a non-empty string")
|
220
|
+
if not capture_name or not isinstance(capture_name, str):
|
221
|
+
raise ValueError("capture_name must be a non-empty string")
|
222
|
+
if not isinstance(data, dict):
|
223
|
+
raise ValueError("data must be a dictionary")
|
224
|
+
|
225
|
+
try:
|
226
|
+
mode = self.capture_modes.get(agent_name, "once") if agent_name else "once"
|
227
|
+
now = datetime.now(timezone.utc)
|
228
|
+
if mode == "multiple":
|
229
|
+
doc = {
|
230
|
+
"user_id": user_id,
|
231
|
+
"agent_name": agent_name,
|
232
|
+
"capture_name": capture_name,
|
233
|
+
"data": data or {},
|
234
|
+
"schema": schema or {},
|
235
|
+
"mode": "multiple",
|
236
|
+
"timestamp": now,
|
237
|
+
"created_at": now,
|
238
|
+
}
|
239
|
+
return self.mongo.insert_one(self.captures_collection, doc)
|
240
|
+
else:
|
241
|
+
key = {
|
242
|
+
"user_id": user_id,
|
243
|
+
"agent_name": agent_name,
|
244
|
+
"capture_name": capture_name,
|
245
|
+
}
|
246
|
+
existing = self.mongo.find_one(self.captures_collection, key)
|
247
|
+
merged_data: Dict[str, Any] = {}
|
248
|
+
if existing and isinstance(existing.get("data"), dict):
|
249
|
+
merged_data.update(existing.get("data", {}))
|
250
|
+
merged_data.update(data or {})
|
251
|
+
update_doc = {
|
252
|
+
"$set": {
|
253
|
+
"user_id": user_id,
|
254
|
+
"agent_name": agent_name,
|
255
|
+
"capture_name": capture_name,
|
256
|
+
"data": merged_data,
|
257
|
+
"schema": (
|
258
|
+
schema
|
259
|
+
if schema is not None
|
260
|
+
else existing.get("schema")
|
261
|
+
if existing
|
262
|
+
else {}
|
263
|
+
),
|
264
|
+
"mode": "once",
|
265
|
+
"timestamp": now,
|
266
|
+
},
|
267
|
+
"$setOnInsert": {"created_at": now},
|
268
|
+
}
|
269
|
+
self.mongo.update_one(
|
270
|
+
self.captures_collection, key, update_doc, upsert=True
|
271
|
+
)
|
272
|
+
doc = self.mongo.find_one(self.captures_collection, key)
|
273
|
+
return str(doc.get("_id")) if doc and doc.get("_id") else None
|
274
|
+
except Exception as e: # pragma: no cover
|
275
|
+
logger.error(f"MongoDB save_capture error: {e}")
|
276
|
+
return None
|
solana_agent/services/agent.py
CHANGED
@@ -73,6 +73,8 @@ class AgentService(AgentServiceInterface):
|
|
73
73
|
name: str,
|
74
74
|
instructions: str,
|
75
75
|
specialization: str,
|
76
|
+
capture_name: Optional[str] = None,
|
77
|
+
capture_schema: Optional[Dict[str, Any]] = None,
|
76
78
|
) -> None:
|
77
79
|
"""Register an AI agent with its specialization.
|
78
80
|
|
@@ -85,6 +87,8 @@ class AgentService(AgentServiceInterface):
|
|
85
87
|
name=name,
|
86
88
|
instructions=instructions,
|
87
89
|
specialization=specialization,
|
90
|
+
capture_name=capture_name,
|
91
|
+
capture_schema=capture_schema,
|
88
92
|
)
|
89
93
|
self.agents.append(agent)
|
90
94
|
logger.info(f"Registered AI agent: {name}")
|
@@ -98,7 +102,6 @@ class AgentService(AgentServiceInterface):
|
|
98
102
|
Returns:
|
99
103
|
System prompt
|
100
104
|
"""
|
101
|
-
|
102
105
|
# Get agent by name
|
103
106
|
agent = next((a for a in self.agents if a.name == agent_name), None)
|
104
107
|
|
@@ -130,8 +133,37 @@ class AgentService(AgentServiceInterface):
|
|
130
133
|
)
|
131
134
|
system_prompt += f"\n\nBUSINESS GOALS:\n{goals_text}"
|
132
135
|
|
136
|
+
# Add capture guidance if this agent has a capture schema
|
137
|
+
if getattr(agent, "capture_schema", None) and getattr(
|
138
|
+
agent, "capture_name", None
|
139
|
+
): # pragma: no cover
|
140
|
+
system_prompt += (
|
141
|
+
"\n\nSTRUCTURED DATA CAPTURE:\n"
|
142
|
+
f"You must collect the following fields for the form '{agent.capture_name}'. "
|
143
|
+
"Ask concise follow-up questions to fill any missing required fields one at a time. "
|
144
|
+
"Confirm values when ambiguous, and summarize the captured data before finalizing.\n\n"
|
145
|
+
"JSON Schema (authoritative definition of the fields):\n"
|
146
|
+
f"{agent.capture_schema}\n\n"
|
147
|
+
"Rules:\n"
|
148
|
+
"- Never invent values—ask the user.\n"
|
149
|
+
"- Validate types (emails look like emails, numbers are numbers, booleans are yes/no).\n"
|
150
|
+
"- If the user declines to provide a required value, note it clearly.\n"
|
151
|
+
"- When all required fields are provided, acknowledge completion.\n"
|
152
|
+
)
|
153
|
+
|
133
154
|
return system_prompt
|
134
155
|
|
156
|
+
def get_agent_capture(
|
157
|
+
self, agent_name: str
|
158
|
+
) -> Optional[Dict[str, Any]]: # pragma: no cover
|
159
|
+
"""Return capture metadata for the agent, if any."""
|
160
|
+
agent = next((a for a in self.agents if a.name == agent_name), None)
|
161
|
+
if not agent:
|
162
|
+
return None
|
163
|
+
if agent.capture_name and agent.capture_schema:
|
164
|
+
return {"name": agent.capture_name, "schema": agent.capture_schema}
|
165
|
+
return None
|
166
|
+
|
135
167
|
def get_all_ai_agents(self) -> Dict[str, AIAgent]:
|
136
168
|
"""Get all registered AI agents.
|
137
169
|
|
solana_agent/services/query.py
CHANGED
@@ -89,6 +89,8 @@ class QueryService(QueryServiceInterface):
|
|
89
89
|
prompt: Optional[str] = None,
|
90
90
|
router: Optional[RoutingServiceInterface] = None,
|
91
91
|
output_model: Optional[Type[BaseModel]] = None,
|
92
|
+
capture_schema: Optional[Dict[str, Any]] = None,
|
93
|
+
capture_name: Optional[str] = None,
|
92
94
|
) -> AsyncGenerator[Union[str, bytes, BaseModel], None]: # pragma: no cover
|
93
95
|
"""Process the user request with appropriate agent and apply input guardrails.
|
94
96
|
|
@@ -263,6 +265,47 @@ class QueryService(QueryServiceInterface):
|
|
263
265
|
)
|
264
266
|
else:
|
265
267
|
full_text_response = ""
|
268
|
+
# If capture_schema is provided, we run a structured output pass first
|
269
|
+
capture_data: Optional[BaseModel] = None
|
270
|
+
# If no explicit capture provided, use the agent's configured capture
|
271
|
+
if not capture_schema or not capture_name:
|
272
|
+
try:
|
273
|
+
cap = self.agent_service.get_agent_capture(agent_name)
|
274
|
+
if cap:
|
275
|
+
capture_name = cap.get("name")
|
276
|
+
capture_schema = cap.get("schema")
|
277
|
+
except Exception:
|
278
|
+
pass
|
279
|
+
|
280
|
+
if capture_schema and capture_name:
|
281
|
+
try:
|
282
|
+
# Build a dynamic Pydantic model from JSON schema
|
283
|
+
DynamicModel = self._build_model_from_json_schema(
|
284
|
+
capture_name, capture_schema
|
285
|
+
)
|
286
|
+
async for result in self.agent_service.generate_response(
|
287
|
+
agent_name=agent_name,
|
288
|
+
user_id=user_id,
|
289
|
+
query=user_text,
|
290
|
+
images=images,
|
291
|
+
memory_context=combined_context,
|
292
|
+
output_format="text",
|
293
|
+
prompt=(
|
294
|
+
(
|
295
|
+
prompt
|
296
|
+
+ "\n\nReturn only the JSON for the requested schema."
|
297
|
+
)
|
298
|
+
if prompt
|
299
|
+
else "Return only the JSON for the requested schema."
|
300
|
+
),
|
301
|
+
output_model=DynamicModel,
|
302
|
+
):
|
303
|
+
# This yields a pydantic model instance
|
304
|
+
capture_data = result # type: ignore
|
305
|
+
break
|
306
|
+
except Exception as e:
|
307
|
+
logger.error(f"Error during capture structured output: {e}")
|
308
|
+
|
266
309
|
async for chunk in self.agent_service.generate_response(
|
267
310
|
agent_name=agent_name,
|
268
311
|
user_id=user_id,
|
@@ -286,6 +329,30 @@ class QueryService(QueryServiceInterface):
|
|
286
329
|
assistant_message=full_text_response,
|
287
330
|
)
|
288
331
|
|
332
|
+
# Persist capture if available
|
333
|
+
if (
|
334
|
+
self.memory_provider
|
335
|
+
and capture_schema
|
336
|
+
and capture_name
|
337
|
+
and capture_data is not None
|
338
|
+
):
|
339
|
+
try:
|
340
|
+
# pydantic v2: model_dump
|
341
|
+
data_dict = (
|
342
|
+
capture_data.model_dump() # type: ignore[attr-defined]
|
343
|
+
if hasattr(capture_data, "model_dump")
|
344
|
+
else capture_data.dict() # type: ignore
|
345
|
+
)
|
346
|
+
await self.memory_provider.save_capture(
|
347
|
+
user_id=user_id,
|
348
|
+
capture_name=capture_name,
|
349
|
+
agent_name=agent_name,
|
350
|
+
data=data_dict,
|
351
|
+
schema=capture_schema,
|
352
|
+
)
|
353
|
+
except Exception as e:
|
354
|
+
logger.error(f"Error saving capture: {e}")
|
355
|
+
|
289
356
|
except Exception as e:
|
290
357
|
import traceback
|
291
358
|
|
@@ -458,3 +525,52 @@ class QueryService(QueryServiceInterface):
|
|
458
525
|
logger.debug(
|
459
526
|
"Memory provider not configured, skipping conversation storage."
|
460
527
|
)
|
528
|
+
|
529
|
+
def _build_model_from_json_schema(
|
530
|
+
self, name: str, schema: Dict[str, Any]
|
531
|
+
) -> Type[BaseModel]:
|
532
|
+
"""Create a Pydantic model dynamically from a JSON Schema subset.
|
533
|
+
|
534
|
+
Supports 'type' string, integer, number, boolean, object (flat), array (of simple types),
|
535
|
+
required fields, and default values. Nested objects/arrays can be extended later.
|
536
|
+
"""
|
537
|
+
from pydantic import create_model
|
538
|
+
|
539
|
+
def py_type(js: Dict[str, Any]):
|
540
|
+
t = js.get("type")
|
541
|
+
if isinstance(t, list):
|
542
|
+
# handle ["null", "string"] => Optional[str]
|
543
|
+
non_null = [x for x in t if x != "null"]
|
544
|
+
if not non_null:
|
545
|
+
return Optional[Any]
|
546
|
+
base = py_type({"type": non_null[0]})
|
547
|
+
return Optional[base]
|
548
|
+
if t == "string":
|
549
|
+
return str
|
550
|
+
if t == "integer":
|
551
|
+
return int
|
552
|
+
if t == "number":
|
553
|
+
return float
|
554
|
+
if t == "boolean":
|
555
|
+
return bool
|
556
|
+
if t == "array":
|
557
|
+
items = js.get("items", {"type": "string"})
|
558
|
+
return List[py_type(items)]
|
559
|
+
if t == "object":
|
560
|
+
# For now, represent as Dict[str, Any]
|
561
|
+
return Dict[str, Any]
|
562
|
+
return Any
|
563
|
+
|
564
|
+
properties: Dict[str, Any] = schema.get("properties", {})
|
565
|
+
required = set(schema.get("required", []))
|
566
|
+
fields = {}
|
567
|
+
for field_name, field_schema in properties.items():
|
568
|
+
typ = py_type(field_schema)
|
569
|
+
default = field_schema.get("default")
|
570
|
+
if field_name in required and default is None:
|
571
|
+
fields[field_name] = (typ, ...)
|
572
|
+
else:
|
573
|
+
fields[field_name] = (typ, default)
|
574
|
+
|
575
|
+
Model = create_model(name, **fields) # type: ignore
|
576
|
+
return Model
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.3
|
2
2
|
Name: solana-agent
|
3
|
-
Version:
|
3
|
+
Version: 31.1.0
|
4
4
|
Summary: AI Agents for Solana
|
5
5
|
License: MIT
|
6
6
|
Keywords: solana,solana ai,solana agent,ai,ai agent,ai agents
|
@@ -14,20 +14,20 @@ Classifier: Programming Language :: Python :: 3
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
16
16
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
17
|
-
Requires-Dist: instructor (==1.
|
18
|
-
Requires-Dist: llama-index-core (==0.
|
19
|
-
Requires-Dist: llama-index-embeddings-openai (==0.
|
20
|
-
Requires-Dist: logfire (==3.
|
21
|
-
Requires-Dist: openai (==1.
|
17
|
+
Requires-Dist: instructor (==1.10.0)
|
18
|
+
Requires-Dist: llama-index-core (==0.13.3)
|
19
|
+
Requires-Dist: llama-index-embeddings-openai (==0.5.0)
|
20
|
+
Requires-Dist: logfire (==4.3.5)
|
21
|
+
Requires-Dist: openai (==1.101.0)
|
22
22
|
Requires-Dist: pillow (==11.3.0)
|
23
|
-
Requires-Dist: pinecone (==7.3.0)
|
23
|
+
Requires-Dist: pinecone[asyncio] (==7.3.0)
|
24
24
|
Requires-Dist: pydantic (>=2)
|
25
|
-
Requires-Dist: pymongo (==4.
|
26
|
-
Requires-Dist: pypdf (==
|
25
|
+
Requires-Dist: pymongo (==4.14.1)
|
26
|
+
Requires-Dist: pypdf (==6.0.0)
|
27
27
|
Requires-Dist: rich (>=13,<14.0)
|
28
28
|
Requires-Dist: scrubadub (==2.0.1)
|
29
|
-
Requires-Dist: typer (==0.16.
|
30
|
-
Requires-Dist: zep-cloud (==
|
29
|
+
Requires-Dist: typer (==0.16.1)
|
30
|
+
Requires-Dist: zep-cloud (==3.4.1)
|
31
31
|
Project-URL: Documentation, https://docs.solana-agent.com
|
32
32
|
Project-URL: Homepage, https://solana-agent.com
|
33
33
|
Project-URL: Repository, https://github.com/truemagic-coder/solana-agent
|
@@ -63,7 +63,7 @@ Build your AI agents in three lines of code!
|
|
63
63
|
* Extensible Tooling
|
64
64
|
* Autonomous Operation
|
65
65
|
* Smart Workflows
|
66
|
-
*
|
66
|
+
* Agentic Forms
|
67
67
|
* Knowledge Base
|
68
68
|
* MCP Support
|
69
69
|
* Guardrails
|
@@ -112,7 +112,7 @@ Smart workflows are as easy as combining your tools and prompts.
|
|
112
112
|
* Integrated Knowledge Base with semantic search and automatic PDF chunking
|
113
113
|
* Input and output guardrails for content filtering, safety, and data sanitization
|
114
114
|
* Generate custom images based on text prompts with storage on S3 compatible services
|
115
|
-
*
|
115
|
+
* Deterministic agentic form filling in natural conversation
|
116
116
|
* Combine with event-driven systems to create autonomous agents
|
117
117
|
|
118
118
|
## Stack
|
@@ -344,7 +344,9 @@ async for response in solana_agent.process("user123", "What is in this image? De
|
|
344
344
|
print(response, end="")
|
345
345
|
```
|
346
346
|
|
347
|
-
###
|
347
|
+
### Agentic Forms
|
348
|
+
|
349
|
+
You can attach a JSON Schema to any agent in your config so it can collect structured data conversationally.
|
348
350
|
|
349
351
|
```python
|
350
352
|
from solana_agent import SolanaAgent
|
@@ -355,25 +357,28 @@ config = {
|
|
355
357
|
},
|
356
358
|
"agents": [
|
357
359
|
{
|
358
|
-
"name": "
|
359
|
-
"instructions": "You
|
360
|
-
"specialization": "
|
360
|
+
"name": "customer_support",
|
361
|
+
"instructions": "You provide friendly, helpful customer support responses.",
|
362
|
+
"specialization": "Customer inquiries",
|
363
|
+
"capture_name": "contact_info",
|
364
|
+
"capture_mode": "once",
|
365
|
+
"capture_schema": {
|
366
|
+
"type": "object",
|
367
|
+
"properties": {
|
368
|
+
"email": { "type": "string" },
|
369
|
+
"phone": { "type": "string" },
|
370
|
+
"newsletter_subscribe": { "type": "boolean" }
|
371
|
+
},
|
372
|
+
"required": ["email"]
|
373
|
+
}
|
361
374
|
}
|
362
|
-
]
|
375
|
+
]
|
363
376
|
}
|
364
377
|
|
365
378
|
solana_agent = SolanaAgent(config=config)
|
366
379
|
|
367
|
-
|
368
|
-
|
369
|
-
abstract: str
|
370
|
-
key_points: list[str]
|
371
|
-
|
372
|
-
full_response = None
|
373
|
-
async for response in solana_agent.process("user123", "Research the life of Ben Franklin - the founding Father.", output_model=ResearchProposal):
|
374
|
-
full_response = response
|
375
|
-
|
376
|
-
print(full_response.model_dump())
|
380
|
+
async for response in solana_agent.process("user123", "What are the latest AI developments?"):
|
381
|
+
print(response, end="")
|
377
382
|
```
|
378
383
|
|
379
384
|
### Command Line Interface (CLI)
|
@@ -5,12 +5,12 @@ solana_agent/adapters/openai_adapter.py,sha256=Vc2lizpJeyZH2T7GX-iLpUUmn2gsw5b2v
|
|
5
5
|
solana_agent/adapters/pinecone_adapter.py,sha256=XlfOpoKHwzpaU4KZnovO2TnEYbsw-3B53ZKQDtBeDgU,23847
|
6
6
|
solana_agent/cli.py,sha256=FGvTIQmKLp6XsQdyKtuhIIfbBtMmcCCXfigNrj4bzMc,4704
|
7
7
|
solana_agent/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
8
|
-
solana_agent/client/solana_agent.py,sha256=
|
8
|
+
solana_agent/client/solana_agent.py,sha256=Ivi18kQaHu8Jp395SNe-dr751AEjmNxkt2dGfVXzVew,9374
|
9
9
|
solana_agent/domains/__init__.py,sha256=HiC94wVPRy-QDJSSRywCRrhrFfTBeHjfi5z-QfZv46U,168
|
10
|
-
solana_agent/domains/agent.py,sha256=
|
10
|
+
solana_agent/domains/agent.py,sha256=8pAi1-kIgzFNANt3dyQjw-1zbThcNdpEllbAGWi79uI,2841
|
11
11
|
solana_agent/domains/routing.py,sha256=1yR4IswGcmREGgbOOI6TKCfuM7gYGOhQjLkBqnZ-rNo,582
|
12
12
|
solana_agent/factories/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
13
|
-
solana_agent/factories/agent_factory.py,sha256=
|
13
|
+
solana_agent/factories/agent_factory.py,sha256=_C6YO3rtfa20i09Wtrwy9go8DZGGe6r0Ko_r3hVfqRU,14379
|
14
14
|
solana_agent/guardrails/pii.py,sha256=FCz1IC3mmkr41QFFf5NaC0fwJrVkwFsxgyOCS2POO5I,4428
|
15
15
|
solana_agent/interfaces/__init__.py,sha256=IQs1WIM1FeKP1-kY2FEfyhol_dB-I-VAe2rD6jrVF6k,355
|
16
16
|
solana_agent/interfaces/client/client.py,sha256=9hg35-hp_CI-WVGOXehBE1ZCKYahLmbeAvtQOYmML4o,3245
|
@@ -18,11 +18,11 @@ solana_agent/interfaces/guardrails/guardrails.py,sha256=gZCQ1FrirW-mX6s7FoYrbRs6
|
|
18
18
|
solana_agent/interfaces/plugins/plugins.py,sha256=Rz52cWBLdotwf4kV-2mC79tRYlN29zHSu1z9-y1HVPk,3329
|
19
19
|
solana_agent/interfaces/providers/data_storage.py,sha256=Y92Cq8BtC55VlsYLD7bo3ofqQabNnlg7Q4H1Q6CDsLU,1713
|
20
20
|
solana_agent/interfaces/providers/llm.py,sha256=Naj8gTGi3GpIMFHKwQjw7EuAF_uSWwwz2-41iUYtov4,2908
|
21
|
-
solana_agent/interfaces/providers/memory.py,sha256=
|
21
|
+
solana_agent/interfaces/providers/memory.py,sha256=28X1LeS-bEac4yoIXdRPyuRU91oW9Kdt2NZtDmwSTxM,1360
|
22
22
|
solana_agent/interfaces/providers/vector_storage.py,sha256=XPYzvoWrlDVFCS9ItBmoqCFWXXWNYY-d9I7_pvP7YYk,1561
|
23
23
|
solana_agent/interfaces/services/agent.py,sha256=A-Hmgelr3g_qaNB0PEPMFHxB5nSCBK0WJ5hauJtIcmI,2257
|
24
24
|
solana_agent/interfaces/services/knowledge_base.py,sha256=Mu8lCGFXPmI_IW5LRGti7octLoWZIg4k5PmGwPfe7LQ,1479
|
25
|
-
solana_agent/interfaces/services/query.py,sha256=
|
25
|
+
solana_agent/interfaces/services/query.py,sha256=uu_qV-DcMEAjj-XQkIc29-inXgERohui4FXrbJj7tBo,1838
|
26
26
|
solana_agent/interfaces/services/routing.py,sha256=Qbn3-DQGVSQKaegHDekSFmn_XCklA0H2f0XUx9-o3wA,367
|
27
27
|
solana_agent/plugins/__init__.py,sha256=coZdgJKq1ExOaj6qB810i3rEhbjdVlrkN76ozt_Ojgo,193
|
28
28
|
solana_agent/plugins/manager.py,sha256=mO_dKSVJ8GToD3wZflMcpKDEBXRoaaMRtY267HENCI0,5542
|
@@ -30,14 +30,14 @@ solana_agent/plugins/registry.py,sha256=VAG0BWdUUIsEE-VpATtHi8qat7ziPuh7pKuzGXau
|
|
30
30
|
solana_agent/plugins/tools/__init__.py,sha256=VDjJxvUjefIy10VztQ9WDKgIegvDbIXBQWsHLhxdZ3o,125
|
31
31
|
solana_agent/plugins/tools/auto_tool.py,sha256=uihijtlc9CCqCIaRcwPuuN7o1SHIpWL2GV3vr33GG3E,1576
|
32
32
|
solana_agent/repositories/__init__.py,sha256=fP83w83CGzXLnSdq-C5wbw9EhWTYtqE2lQTgp46-X_4,163
|
33
|
-
solana_agent/repositories/memory.py,sha256=
|
33
|
+
solana_agent/repositories/memory.py,sha256=F46vZ-Uhj7PX2uFGCRKYsZ8JLmKteMN1d30qGee2vtU,11111
|
34
34
|
solana_agent/services/__init__.py,sha256=iko0c2MlF8b_SA_nuBGFllr2E3g_JowOrOzGcnU9tkA,162
|
35
|
-
solana_agent/services/agent.py,sha256=
|
35
|
+
solana_agent/services/agent.py,sha256=dotuINMtW3TQDLq2eNM5r1cAUwhzxbHBotw8p5CLsYU,20983
|
36
36
|
solana_agent/services/knowledge_base.py,sha256=ZvOPrSmcNDgUzz4bJIQ4LeRl9vMZiK9hOfs71IpB7Bk,32735
|
37
|
-
solana_agent/services/query.py,sha256=
|
37
|
+
solana_agent/services/query.py,sha256=sqWpan0qVrRm0MRxa_14ViuUTP2xsvxbaOl8EPgWJbg,23894
|
38
38
|
solana_agent/services/routing.py,sha256=C5Ku4t9TqvY7S8wlUPMTC04HCrT4Ib3E8Q8yX0lVU_s,7137
|
39
|
-
solana_agent-
|
40
|
-
solana_agent-
|
41
|
-
solana_agent-
|
42
|
-
solana_agent-
|
43
|
-
solana_agent-
|
39
|
+
solana_agent-31.1.0.dist-info/LICENSE,sha256=BnSRc-NSFuyF2s496l_4EyrwAP6YimvxWcjPiJ0J7g4,1057
|
40
|
+
solana_agent-31.1.0.dist-info/METADATA,sha256=GgLLBonDRWgNcbWYU0fRtgu_or5UrbRaei8lAEsEDS4,30013
|
41
|
+
solana_agent-31.1.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
42
|
+
solana_agent-31.1.0.dist-info/entry_points.txt,sha256=-AuT_mfqk8dlZ0pHuAjx1ouAWpTRjpqvEUa6YV3lmc0,53
|
43
|
+
solana_agent-31.1.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|