solana-agent 31.1.4__tar.gz → 31.1.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {solana_agent-31.1.4 → solana_agent-31.1.5}/PKG-INFO +1 -2
  2. {solana_agent-31.1.4 → solana_agent-31.1.5}/README.md +0 -1
  3. {solana_agent-31.1.4 → solana_agent-31.1.5}/pyproject.toml +1 -1
  4. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/factories/agent_factory.py +1 -12
  5. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/repositories/memory.py +30 -51
  6. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/services/query.py +208 -15
  7. {solana_agent-31.1.4 → solana_agent-31.1.5}/LICENSE +0 -0
  8. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/__init__.py +0 -0
  9. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/adapters/__init__.py +0 -0
  10. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/adapters/mongodb_adapter.py +0 -0
  11. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/adapters/openai_adapter.py +0 -0
  12. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/adapters/pinecone_adapter.py +0 -0
  13. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/cli.py +0 -0
  14. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/client/__init__.py +0 -0
  15. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/client/solana_agent.py +0 -0
  16. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/domains/__init__.py +0 -0
  17. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/domains/agent.py +0 -0
  18. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/domains/routing.py +0 -0
  19. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/factories/__init__.py +0 -0
  20. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/guardrails/pii.py +0 -0
  21. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/__init__.py +0 -0
  22. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/client/client.py +0 -0
  23. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/guardrails/guardrails.py +0 -0
  24. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/plugins/plugins.py +0 -0
  25. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/providers/data_storage.py +0 -0
  26. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/providers/llm.py +0 -0
  27. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/providers/memory.py +0 -0
  28. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/providers/vector_storage.py +0 -0
  29. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/services/agent.py +0 -0
  30. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/services/knowledge_base.py +0 -0
  31. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/services/query.py +0 -0
  32. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/interfaces/services/routing.py +0 -0
  33. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/plugins/__init__.py +0 -0
  34. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/plugins/manager.py +0 -0
  35. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/plugins/registry.py +0 -0
  36. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/plugins/tools/__init__.py +0 -0
  37. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/plugins/tools/auto_tool.py +0 -0
  38. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/repositories/__init__.py +0 -0
  39. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/services/__init__.py +0 -0
  40. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/services/agent.py +0 -0
  41. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/services/knowledge_base.py +0 -0
  42. {solana_agent-31.1.4 → solana_agent-31.1.5}/solana_agent/services/routing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: solana-agent
3
- Version: 31.1.4
3
+ Version: 31.1.5
4
4
  Summary: AI Agents for Solana
5
5
  License: MIT
6
6
  Keywords: solana,solana ai,solana agent,ai,ai agent,ai agents
@@ -361,7 +361,6 @@ config = {
361
361
  "instructions": "You provide friendly, helpful customer support responses.",
362
362
  "specialization": "Customer inquiries",
363
363
  "capture_name": "contact_info",
364
- "capture_mode": "once",
365
364
  "capture_schema": {
366
365
  "type": "object",
367
366
  "properties": {
@@ -326,7 +326,6 @@ config = {
326
326
  "instructions": "You provide friendly, helpful customer support responses.",
327
327
  "specialization": "Customer inquiries",
328
328
  "capture_name": "contact_info",
329
- "capture_mode": "once",
330
329
  "capture_schema": {
331
330
  "type": "object",
332
331
  "properties": {
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "solana-agent"
3
- version = "31.1.4"
3
+ version = "31.1.5"
4
4
  description = "AI Agents for Solana"
5
5
  authors = ["Bevan Hunt <bevan@bevanhunt.com>"]
6
6
  license = "MIT"
@@ -133,12 +133,7 @@ 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
136
+ # capture_mode removed: repository now always upserts/merges per capture
142
137
 
143
138
  # Create repositories
144
139
  memory_provider = None
@@ -148,22 +143,16 @@ class SolanaAgentFactory:
148
143
  "mongo_adapter": db_adapter,
149
144
  "zep_api_key": config["zep"].get("api_key"),
150
145
  }
151
- if capture_modes: # pragma: no cover
152
- mem_kwargs["capture_modes"] = capture_modes
153
146
  memory_provider = MemoryRepository(**mem_kwargs)
154
147
 
155
148
  if "mongo" in config and "zep" not in config:
156
149
  mem_kwargs = {"mongo_adapter": db_adapter}
157
- if capture_modes:
158
- mem_kwargs["capture_modes"] = capture_modes
159
150
  memory_provider = MemoryRepository(**mem_kwargs)
160
151
 
161
152
  if "zep" in config and "mongo" not in config:
162
153
  if "api_key" not in config["zep"]:
163
154
  raise ValueError("Zep API key is required.")
164
155
  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
156
  memory_provider = MemoryRepository(**mem_kwargs)
168
157
 
169
158
  guardrail_config = config.get("guardrails", {})
@@ -19,10 +19,7 @@ class MemoryRepository(MemoryProvider):
19
19
  self,
20
20
  mongo_adapter: Optional[MongoDBAdapter] = None,
21
21
  zep_api_key: Optional[str] = None,
22
- capture_modes: Optional[Dict[str, str]] = None,
23
22
  ):
24
- self.capture_modes: Dict[str, str] = capture_modes or {}
25
-
26
23
  # Mongo setup
27
24
  if not mongo_adapter:
28
25
  self.mongo = None
@@ -46,18 +43,15 @@ class MemoryRepository(MemoryProvider):
46
43
  self.mongo.create_index(self.captures_collection, [("capture_name", 1)])
47
44
  self.mongo.create_index(self.captures_collection, [("agent_name", 1)])
48
45
  self.mongo.create_index(self.captures_collection, [("timestamp", 1)])
49
- # Unique only when mode == 'once'
46
+ # Unique per user/agent/capture combo
50
47
  try:
51
48
  self.mongo.create_index(
52
49
  self.captures_collection,
53
50
  [("user_id", 1), ("agent_name", 1), ("capture_name", 1)],
54
51
  unique=True,
55
- partialFilterExpression={"mode": "once"},
56
52
  )
57
53
  except Exception as e:
58
- logger.error(
59
- f"Error creating partial unique index for captures: {e}"
60
- )
54
+ logger.error(f"Error creating unique index for captures: {e}")
61
55
  except Exception as e:
62
56
  logger.error(f"Error initializing MongoDB captures collection: {e}")
63
57
  self.captures_collection = "captures"
@@ -223,54 +217,39 @@ class MemoryRepository(MemoryProvider):
223
217
  raise ValueError("data must be a dictionary")
224
218
 
225
219
  try:
226
- mode = self.capture_modes.get(agent_name, "once") if agent_name else "once"
227
220
  now = datetime.now(timezone.utc)
228
- if mode == "multiple":
229
- doc = {
221
+ key = {
222
+ "user_id": user_id,
223
+ "agent_name": agent_name,
224
+ "capture_name": capture_name,
225
+ }
226
+ existing = self.mongo.find_one(self.captures_collection, key)
227
+ merged_data: Dict[str, Any] = {}
228
+ if existing and isinstance(existing.get("data"), dict):
229
+ merged_data.update(existing.get("data", {}))
230
+ merged_data.update(data or {})
231
+ update_doc = {
232
+ "$set": {
230
233
  "user_id": user_id,
231
234
  "agent_name": agent_name,
232
235
  "capture_name": capture_name,
233
- "data": data or {},
234
- "schema": schema or {},
235
- "mode": "multiple",
236
+ "data": merged_data,
237
+ "schema": (
238
+ schema
239
+ if schema is not None
240
+ else existing.get("schema")
241
+ if existing
242
+ else {}
243
+ ),
236
244
  "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
245
+ },
246
+ "$setOnInsert": {"created_at": now},
247
+ }
248
+ self.mongo.update_one(
249
+ self.captures_collection, key, update_doc, upsert=True
250
+ )
251
+ doc = self.mongo.find_one(self.captures_collection, key)
252
+ return str(doc.get("_id")) if doc and doc.get("_id") else None
274
253
  except Exception as e: # pragma: no cover
275
254
  logger.error(f"MongoDB save_capture error: {e}")
276
255
  return None
@@ -8,7 +8,18 @@ clean separation of concerns.
8
8
 
9
9
  import logging
10
10
  import re
11
- from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Type, Union
11
+ import time
12
+ from typing import (
13
+ Any,
14
+ AsyncGenerator,
15
+ Dict,
16
+ List,
17
+ Literal,
18
+ Optional,
19
+ Type,
20
+ Union,
21
+ Tuple,
22
+ )
12
23
 
13
24
  from pydantic import BaseModel
14
25
 
@@ -50,6 +61,151 @@ class QueryService(QueryServiceInterface):
50
61
  self.knowledge_base = knowledge_base
51
62
  self.kb_results_count = kb_results_count
52
63
  self.input_guardrails = input_guardrails or []
64
+ # Per-user sticky sessions (in-memory)
65
+ # { user_id: { 'agent': str, 'started_at': float, 'last_updated': float, 'required_complete': bool } }
66
+ self._sticky_sessions: Dict[str, Dict[str, Any]] = {}
67
+
68
+ def _get_sticky_agent(self, user_id: str) -> Optional[str]:
69
+ sess = self._sticky_sessions.get(user_id)
70
+ return sess.get("agent") if isinstance(sess, dict) else None
71
+
72
+ def _set_sticky_agent(
73
+ self, user_id: str, agent_name: str, required_complete: bool = False
74
+ ) -> None:
75
+ self._sticky_sessions[user_id] = {
76
+ "agent": agent_name,
77
+ "started_at": self._sticky_sessions.get(user_id, {}).get(
78
+ "started_at", time.time()
79
+ ),
80
+ "last_updated": time.time(),
81
+ "required_complete": required_complete,
82
+ }
83
+
84
+ def _update_sticky_required_complete(
85
+ self, user_id: str, required_complete: bool
86
+ ) -> None:
87
+ if user_id in self._sticky_sessions:
88
+ self._sticky_sessions[user_id]["required_complete"] = required_complete
89
+ self._sticky_sessions[user_id]["last_updated"] = time.time()
90
+
91
+ def _clear_sticky_agent(self, user_id: str) -> None:
92
+ if user_id in self._sticky_sessions:
93
+ del self._sticky_sessions[user_id]
94
+
95
+ # LLM-backed switch intent detection (gpt-4.1-mini)
96
+ class _SwitchIntentModel(BaseModel):
97
+ switch: bool = False
98
+ target_agent: Optional[str] = None
99
+ start_new: bool = False
100
+
101
+ async def _detect_switch_intent(
102
+ self, text: str, available_agents: List[str]
103
+ ) -> Tuple[bool, Optional[str], bool]:
104
+ """Detect if the user is asking to switch agents or start a new conversation.
105
+
106
+ Returns: (switch_requested, target_agent_name_or_none, start_new_conversation)
107
+ Implemented as an LLM call to gpt-4.1-mini with structured output.
108
+ """
109
+ if not text:
110
+ return (False, None, False)
111
+
112
+ # Instruction and user prompt for the classifier
113
+ instruction = (
114
+ "You are a strict intent classifier for agent routing. "
115
+ "Decide if the user's message requests switching to another agent or starting a new conversation. "
116
+ "Only return JSON with keys: switch (bool), target_agent (string|null), start_new (bool). "
117
+ "If a target agent is mentioned, it MUST be one of the provided agent names (case-insensitive). "
118
+ "If none clearly applies, set switch=false and start_new=false and target_agent=null."
119
+ )
120
+ user_prompt = (
121
+ f"Available agents (choose only from these if a target is specified): {available_agents}\n\n"
122
+ f"User message:\n{text}\n\n"
123
+ 'Return JSON only, like: {"switch": true|false, "target_agent": "<one_of_available_or_null>", "start_new": true|false}'
124
+ )
125
+
126
+ # Primary: use llm_provider.parse_structured_output
127
+ try:
128
+ if hasattr(self.agent_service.llm_provider, "parse_structured_output"):
129
+ try:
130
+ result = (
131
+ await self.agent_service.llm_provider.parse_structured_output(
132
+ prompt=user_prompt,
133
+ system_prompt=instruction,
134
+ model_class=QueryService._SwitchIntentModel,
135
+ model="gpt-4.1-mini",
136
+ )
137
+ )
138
+ except TypeError:
139
+ # Provider may not accept 'model' kwarg
140
+ result = (
141
+ await self.agent_service.llm_provider.parse_structured_output(
142
+ prompt=user_prompt,
143
+ system_prompt=instruction,
144
+ model_class=QueryService._SwitchIntentModel,
145
+ )
146
+ )
147
+ switch = bool(getattr(result, "switch", False))
148
+ target = getattr(result, "target_agent", None)
149
+ start_new = bool(getattr(result, "start_new", False))
150
+ # Normalize target to available agent name
151
+ if target:
152
+ target_lower = target.lower()
153
+ norm = None
154
+ for a in available_agents:
155
+ if a.lower() == target_lower or target_lower in a.lower():
156
+ norm = a
157
+ break
158
+ target = norm
159
+ if not switch:
160
+ target = None
161
+ return (switch, target, start_new)
162
+ except Exception as e:
163
+ logger.debug(f"LLM switch intent parse_structured_output failed: {e}")
164
+
165
+ # Fallback: generate_response with output_model
166
+ try:
167
+ async for r in self.agent_service.generate_response(
168
+ agent_name="default",
169
+ user_id="router",
170
+ query="",
171
+ images=None,
172
+ memory_context="",
173
+ output_format="text",
174
+ prompt=f"{instruction}\n\n{user_prompt}",
175
+ output_model=QueryService._SwitchIntentModel,
176
+ ):
177
+ result = r
178
+ switch = False
179
+ target = None
180
+ start_new = False
181
+ try:
182
+ switch = bool(result.switch) # type: ignore[attr-defined]
183
+ target = result.target_agent # type: ignore[attr-defined]
184
+ start_new = bool(result.start_new) # type: ignore[attr-defined]
185
+ except Exception:
186
+ try:
187
+ d = result.model_dump()
188
+ switch = bool(d.get("switch", False))
189
+ target = d.get("target_agent")
190
+ start_new = bool(d.get("start_new", False))
191
+ except Exception:
192
+ pass
193
+ if target:
194
+ target_lower = str(target).lower()
195
+ norm = None
196
+ for a in available_agents:
197
+ if a.lower() == target_lower or target_lower in a.lower():
198
+ norm = a
199
+ break
200
+ target = norm
201
+ if not switch:
202
+ target = None
203
+ return (switch, target, start_new)
204
+ except Exception as e:
205
+ logger.debug(f"LLM switch intent generate_response failed: {e}")
206
+
207
+ # Last resort: no switch
208
+ return (False, None, False)
53
209
 
54
210
  async def process(
55
211
  self,
@@ -80,7 +236,7 @@ class QueryService(QueryServiceInterface):
80
236
  router: Optional[RoutingServiceInterface] = None,
81
237
  output_model: Optional[Type[BaseModel]] = None,
82
238
  capture_schema: Optional[Dict[str, Any]] = None,
83
- capture_name: Optional[Dict[str, Any]] = None,
239
+ capture_name: Optional[str] = None,
84
240
  ) -> AsyncGenerator[Union[str, bytes, BaseModel], None]: # pragma: no cover
85
241
  """Process the user request and generate a response."""
86
242
  try:
@@ -164,7 +320,7 @@ class QueryService(QueryServiceInterface):
164
320
  except Exception:
165
321
  kb_context = ""
166
322
 
167
- # 6) Route query (and fetch previous assistant message)
323
+ # 6) Determine agent (sticky session aware; allow explicit switch/new conversation)
168
324
  agent_name = "default"
169
325
  prev_assistant = ""
170
326
  routing_input = user_text
@@ -184,19 +340,52 @@ class QueryService(QueryServiceInterface):
184
340
  "assistant_message", ""
185
341
  ) or ""
186
342
  if prev_user_msg:
187
- routing_input = (
188
- f"previous_user_message: {prev_user_msg}\n"
189
- f"current_user_message: {user_text}"
190
- )
343
+ routing_input = f"previous_user_message: {prev_user_msg}\ncurrent_user_message: {user_text}"
191
344
  except Exception:
192
345
  pass
193
- try:
194
- if router:
195
- agent_name = await router.route_query(routing_input)
196
- else:
197
- agent_name = await self.routing_service.route_query(routing_input)
198
- except Exception:
199
- agent_name = "default"
346
+
347
+ # Get available agents first so the LLM can select a valid target
348
+ agents = self.agent_service.get_all_ai_agents() or {}
349
+ available_agent_names = list(agents.keys())
350
+
351
+ # LLM detects switch intent
352
+ (
353
+ switch_requested,
354
+ requested_agent_raw,
355
+ start_new,
356
+ ) = await self._detect_switch_intent(user_text, available_agent_names)
357
+
358
+ # Normalize requested agent to an exact available key
359
+ requested_agent = None
360
+ if requested_agent_raw:
361
+ raw_lower = requested_agent_raw.lower()
362
+ for a in available_agent_names:
363
+ if a.lower() == raw_lower or raw_lower in a.lower():
364
+ requested_agent = a
365
+ break
366
+
367
+ sticky_agent = self._get_sticky_agent(user_id)
368
+
369
+ if sticky_agent and not switch_requested:
370
+ agent_name = sticky_agent
371
+ else:
372
+ try:
373
+ if start_new:
374
+ # Start fresh
375
+ self._clear_sticky_agent(user_id)
376
+ if requested_agent:
377
+ agent_name = requested_agent
378
+ else:
379
+ # Route if no explicit target
380
+ if router:
381
+ agent_name = await router.route_query(routing_input)
382
+ else:
383
+ agent_name = await self.routing_service.route_query(
384
+ routing_input
385
+ )
386
+ except Exception:
387
+ agent_name = next(iter(agents.keys())) if agents else "default"
388
+ self._set_sticky_agent(user_id, agent_name, required_complete=False)
200
389
 
201
390
  # 7) Captured data context + incremental save using previous assistant message
202
391
  capture_context = ""
@@ -285,7 +474,6 @@ class QueryService(QueryServiceInterface):
285
474
  system_prompt=instruction,
286
475
  model_class=_FieldDetect,
287
476
  )
288
- # Read result
289
477
  sel = None
290
478
  try:
291
479
  sel = getattr(result, "field", None)
@@ -544,6 +732,11 @@ class QueryService(QueryServiceInterface):
544
732
 
545
733
  if lines:
546
734
  capture_context = "\n".join(lines) + "\n\n"
735
+ # Update sticky session completion flag
736
+ try:
737
+ self._update_sticky_required_complete(user_id, required_complete)
738
+ except Exception:
739
+ pass
547
740
 
548
741
  # Merge contexts + flow rules
549
742
  combined_context = ""
File without changes