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.
@@ -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
 
@@ -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
- memory_provider = MemoryRepository(
141
- mongo_adapter=db_adapter, zep_api_key=config["zep"].get("api_key")
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
- memory_provider = MemoryRepository(mongo_adapter=db_adapter)
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
- memory_provider = MemoryRepository(zep_api_key=config["zep"].get("api_key"))
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 # Import logging
2
- from copy import deepcopy
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
- """Initialize the combined memory provider."""
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}") # Use logger.error
39
+ logger.error(f"Error initializing MongoDB: {e}")
38
40
 
39
- self.zep = None
40
- # Initialize Zep
41
- if zep_api_key:
42
- self.zep = AsyncZepCloud(api_key=zep_api_key)
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
- """Store messages in both Zep and MongoDB."""
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(msg, dict) and "role" in msg and "content" in msg
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 msg in messages:
58
- if msg["role"] not in ["user", "assistant"]:
79
+ for m in messages:
80
+ if m["role"] not in ["user", "assistant"]:
59
81
  raise ValueError(
60
- f"Invalid role '{msg['role']}' in message. Only 'user' and 'assistant' roles are accepted."
82
+ "Invalid role in message. Only 'user' and 'assistant' are accepted."
61
83
  )
62
84
 
63
- # Store in MongoDB
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 msg in reversed(messages):
70
- if msg.get("role") == "user" and not user_msg:
71
- user_msg = msg.get("content")
72
- elif msg.get("role") == "assistant" and not assistant_msg:
73
- assistant_msg = msg.get("content")
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
- # Store truncated messages
79
- doc = {
80
- "user_id": user_id,
81
- "user_message": user_msg,
82
- "assistant_message": assistant_msg,
83
- "timestamp": datetime.now(timezone.utc),
84
- }
85
- self.mongo.insert_one(self.collection, doc)
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}") # Use logger.error
108
+ logger.error(f"MongoDB storage error: {e}")
88
109
 
89
- # Store in Zep
110
+ # Zep
90
111
  if not self.zep:
91
112
  return
92
113
 
93
- # Convert messages to Zep format
94
- zep_messages = []
95
- for msg in messages:
96
- if "role" in msg and "content" in msg:
97
- content = self._truncate(deepcopy(msg["content"]))
98
- zep_msg = Message(
99
- role=msg["role"],
100
- content=content,
101
- role_type=msg["role"],
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.memory.add(session_id=user_id, messages=zep_messages)
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.memory.add_session(
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
- f"Zep session creation error: {e}"
125
- ) # Use logger.error
126
- await self.zep.memory.add(session_id=user_id, messages=zep_messages)
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}") # Use logger.error
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.memory.get(session_id=user_id)
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}") # Use logger.error
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}") # Use logger.error
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.memory.delete(session_id=user_id)
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}") # Use logger.error
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}") # Use logger.error
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}") # Use logger.error
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
@@ -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
 
@@ -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: 30.0.9
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.9.2)
18
- Requires-Dist: llama-index-core (==0.12.48)
19
- Requires-Dist: llama-index-embeddings-openai (==0.3.1)
20
- Requires-Dist: logfire (==3.23.0)
21
- Requires-Dist: openai (==1.93.3)
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.13.2)
26
- Requires-Dist: pypdf (==5.7.0)
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.0)
30
- Requires-Dist: zep-cloud (==2.17.0)
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
- * Structured Outputs
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
- * Deterministically return structured outputs
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
- ### Structured Outputs
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": "researcher",
359
- "instructions": "You are a research expert.",
360
- "specialization": "Researcher",
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
- class ResearchProposal(BaseModel):
368
- title: str
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=ZNBRpougMi8xjkFQffSyrkJCnbRe7C2rKOjJfhQN5ug,9191
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=3Q1wg4eIul0CPpaYBOjEthKTfcdhf1SAiWc2R-IMGO8,2561
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=kduhtCMAxiPmCW_wx-hGGlhehRRGt4OBKY8r-R-LZnI,13246
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=h3HEOwWCiFGIuFBX49XOv1jFaQW3NGjyKPOfmQloevk,1011
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=Co-pThoT4Zz8jhwKt5fV3LH9MaE_lSyEwNFxsMnTU9Y,1737
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=wM6CSUwhXIbi4bCPiyhdbVfarjhdc6MLPX8sOzYPqFA,7680
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=EK4tGeG2nk-pIAvZMiKC1dlPVuybouGliONcJiB_2k8,19267
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=3v5Ym8UqL0rfOC-0MWHALAsS2jVWdpUR3A-YI9n0xyo,18771
37
+ solana_agent/services/query.py,sha256=sqWpan0qVrRm0MRxa_14ViuUTP2xsvxbaOl8EPgWJbg,23894
38
38
  solana_agent/services/routing.py,sha256=C5Ku4t9TqvY7S8wlUPMTC04HCrT4Ib3E8Q8yX0lVU_s,7137
39
- solana_agent-30.0.9.dist-info/LICENSE,sha256=BnSRc-NSFuyF2s496l_4EyrwAP6YimvxWcjPiJ0J7g4,1057
40
- solana_agent-30.0.9.dist-info/METADATA,sha256=x5zOtjpBZhPM2uiRgKiumI9MfooFMrqSMSudGaNZriY,29632
41
- solana_agent-30.0.9.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
42
- solana_agent-30.0.9.dist-info/entry_points.txt,sha256=-AuT_mfqk8dlZ0pHuAjx1ouAWpTRjpqvEUa6YV3lmc0,53
43
- solana_agent-30.0.9.dist-info/RECORD,,
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,,