solana-agent 31.1.0__py3-none-any.whl → 31.1.1__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.
@@ -186,9 +186,16 @@ class SolanaAgentFactory:
186
186
  )
187
187
 
188
188
  # Create routing service
189
+ # Optional routing model override (use small, cheap model by default in service)
190
+ routing_model = (
191
+ config.get("openai", {}).get("routing_model")
192
+ if isinstance(config.get("openai"), dict)
193
+ else None
194
+ )
189
195
  routing_service = RoutingService(
190
196
  llm_provider=llm_adapter,
191
197
  agent_service=agent_service,
198
+ model=routing_model,
192
199
  )
193
200
 
194
201
  # Debug the agent service tool registry
@@ -7,6 +7,7 @@ clean separation of concerns.
7
7
  """
8
8
 
9
9
  import logging
10
+ import re
10
11
  from typing import Any, AsyncGenerator, Dict, List, Literal, Optional, Type, Union
11
12
 
12
13
  from pydantic import BaseModel
@@ -22,9 +23,7 @@ from solana_agent.interfaces.providers.memory import (
22
23
  from solana_agent.interfaces.services.knowledge_base import (
23
24
  KnowledgeBaseService as KnowledgeBaseInterface,
24
25
  )
25
- from solana_agent.interfaces.guardrails.guardrails import (
26
- InputGuardrail,
27
- )
26
+ from solana_agent.interfaces.guardrails.guardrails import InputGuardrail
28
27
 
29
28
  from solana_agent.services.agent import AgentService
30
29
  from solana_agent.services.routing import RoutingService
@@ -44,16 +43,7 @@ class QueryService(QueryServiceInterface):
44
43
  kb_results_count: int = 3,
45
44
  input_guardrails: List[InputGuardrail] = None,
46
45
  ):
47
- """Initialize the query service.
48
-
49
- Args:
50
- agent_service: Service for AI agent management
51
- routing_service: Service for routing queries to appropriate agents
52
- memory_provider: Optional provider for memory storage and retrieval
53
- knowledge_base: Optional provider for knowledge base interactions
54
- kb_results_count: Number of results to retrieve from knowledge base
55
- input_guardrails: List of input guardrail instances
56
- """
46
+ """Initialize the query service."""
57
47
  self.agent_service = agent_service
58
48
  self.routing_service = routing_service
59
49
  self.memory_provider = memory_provider
@@ -92,26 +82,9 @@ class QueryService(QueryServiceInterface):
92
82
  capture_schema: Optional[Dict[str, Any]] = None,
93
83
  capture_name: Optional[str] = None,
94
84
  ) -> AsyncGenerator[Union[str, bytes, BaseModel], None]: # pragma: no cover
95
- """Process the user request with appropriate agent and apply input guardrails.
96
-
97
- Args:
98
- user_id: User ID
99
- query: Text query or audio bytes
100
- images: Optional list of image URLs (str) or image bytes.
101
- output_format: Response format ("text" or "audio")
102
- audio_voice: Voice for TTS (text-to-speech)
103
- audio_instructions: Audio voice instructions
104
- audio_output_format: Audio output format
105
- audio_input_format: Audio input format
106
- prompt: Optional prompt for the agent
107
- router: Optional routing service for processing
108
- output_model: Optional Pydantic model for structured output
109
-
110
- Yields:
111
- Response chunks (text strings or audio bytes)
112
- """
85
+ """Process the user request and generate a response."""
113
86
  try:
114
- # --- 1. Handle Audio Input & Extract Text ---
87
+ # 1) Transcribe audio or accept text
115
88
  user_text = ""
116
89
  if not isinstance(query, str):
117
90
  logger.info(
@@ -128,123 +101,386 @@ class QueryService(QueryServiceInterface):
128
101
  user_text = query
129
102
  logger.info(f"Received text input length: {len(user_text)}")
130
103
 
131
- # --- 2. Apply Input Guardrails ---
104
+ # 2) Input guardrails
132
105
  original_text = user_text
133
- processed_text = user_text
134
106
  for guardrail in self.input_guardrails:
135
107
  try:
136
- processed_text = await guardrail.process(processed_text)
137
- logger.debug(
138
- f"Applied input guardrail: {guardrail.__class__.__name__}"
139
- )
108
+ user_text = await guardrail.process(user_text)
140
109
  except Exception as e:
141
- logger.error(
142
- f"Error applying input guardrail {guardrail.__class__.__name__}: {e}",
143
- exc_info=True,
144
- )
145
- if processed_text != original_text:
110
+ logger.debug(f"Guardrail error: {e}")
111
+ if user_text != original_text:
146
112
  logger.info(
147
- f"Input guardrails modified user text. Original length: {len(original_text)}, New length: {len(processed_text)}"
113
+ f"Input guardrails modified user text. Original length: {len(original_text)}, New length: {len(user_text)}"
148
114
  )
149
- user_text = processed_text # Use the processed text going forward
150
- # --- End Apply Input Guardrails ---
151
115
 
152
- # --- 3. Handle Simple Greetings ---
153
- # Simple greetings typically don't involve images
154
- if not images and user_text.strip().lower() in [
155
- "test",
156
- "hello",
116
+ # 3) Greetings shortcut
117
+ if not images and user_text.strip().lower() in {
157
118
  "hi",
119
+ "hello",
158
120
  "hey",
159
121
  "ping",
160
- ]:
161
- response = "Hello! How can I help you today?"
162
- logger.info("Handling simple greeting.")
122
+ "test",
123
+ }:
124
+ greeting = "Hello! How can I help you today?"
163
125
  if output_format == "audio":
164
126
  async for chunk in self.agent_service.llm_provider.tts(
165
- text=response,
127
+ text=greeting,
166
128
  voice=audio_voice,
167
129
  response_format=audio_output_format,
168
130
  instructions=audio_instructions,
169
131
  ):
170
132
  yield chunk
171
133
  else:
172
- yield response
173
-
174
- # Store simple interaction in memory (using processed user_text)
134
+ yield greeting
175
135
  if self.memory_provider:
176
- await self._store_conversation(user_id, user_text, response)
136
+ await self._store_conversation(user_id, original_text, greeting)
177
137
  return
178
138
 
179
- # --- 4. Get Memory Context ---
139
+ # 4) Memory context (conversation history)
180
140
  memory_context = ""
181
141
  if self.memory_provider:
182
142
  try:
183
143
  memory_context = await self.memory_provider.retrieve(user_id)
184
- logger.info(
185
- f"Retrieved memory context length: {len(memory_context)}"
186
- )
187
- except Exception as e:
188
- logger.error(f"Error retrieving memory context: {e}", exc_info=True)
144
+ except Exception:
145
+ memory_context = ""
189
146
 
190
- # --- 5. Retrieve Relevant Knowledge ---
147
+ # 5) Knowledge base context
191
148
  kb_context = ""
192
149
  if self.knowledge_base:
193
150
  try:
194
- # Use processed user_text for KB query
195
151
  kb_results = await self.knowledge_base.query(
196
152
  query_text=user_text,
197
153
  top_k=self.kb_results_count,
198
154
  include_content=True,
199
- include_metadata=False, # Keep metadata minimal for context
155
+ include_metadata=False,
200
156
  )
201
-
202
157
  if kb_results:
203
- kb_context = "**KNOWLEDGE BASE (CRITICAL: MAKE THIS INFORMATION THE TOP PRIORITY):**\n"
204
- for i, result in enumerate(kb_results, 1):
205
- content = result.get("content", "").strip()
206
- kb_context += f"[{i}] {content}\n\n"
207
- logger.info(
208
- f"Retrieved {len(kb_results)} results from Knowledge Base."
209
- )
210
- else:
211
- logger.info("No relevant results found in Knowledge Base.")
212
- except Exception as e:
213
- logger.error(f"Error retrieving knowledge: {e}", exc_info=True)
214
-
215
- # --- 6. Route Query ---
216
- agent_name = "default" # Fallback agent
158
+ kb_lines = [
159
+ "**KNOWLEDGE BASE (CRITICAL: MAKE THIS INFORMATION THE TOP PRIORITY):**"
160
+ ]
161
+ for i, r in enumerate(kb_results, 1):
162
+ kb_lines.append(f"[{i}] {r.get('content', '').strip()}\n")
163
+ kb_context = "\n".join(kb_lines)
164
+ except Exception:
165
+ kb_context = ""
166
+
167
+ # 6) Route query (and fetch previous assistant message)
168
+ agent_name = "default"
169
+ prev_assistant = ""
170
+ routing_input = user_text
171
+ if self.memory_provider:
172
+ try:
173
+ prev_docs = self.memory_provider.find(
174
+ collection="conversations",
175
+ query={"user_id": user_id},
176
+ sort=[("timestamp", -1)],
177
+ limit=1,
178
+ )
179
+ if prev_docs:
180
+ prev_user_msg = (prev_docs[0] or {}).get(
181
+ "user_message", ""
182
+ ) or ""
183
+ prev_assistant = (prev_docs[0] or {}).get(
184
+ "assistant_message", ""
185
+ ) or ""
186
+ if prev_user_msg:
187
+ routing_input = (
188
+ f"previous_user_message: {prev_user_msg}\n"
189
+ f"current_user_message: {user_text}"
190
+ )
191
+ except Exception:
192
+ pass
217
193
  try:
218
- # Use processed user_text for routing (images generally don't affect routing logic here)
219
194
  if router:
220
- agent_name = await router.route_query(user_text)
195
+ agent_name = await router.route_query(routing_input)
221
196
  else:
222
- agent_name = await self.routing_service.route_query(user_text)
223
- logger.info(f"Routed query to agent: {agent_name}")
197
+ agent_name = await self.routing_service.route_query(routing_input)
198
+ except Exception:
199
+ agent_name = "default"
200
+
201
+ # 7) Captured data context + incremental save using previous assistant message
202
+ capture_context = ""
203
+ form_complete = False
204
+
205
+ # Helpers
206
+ def _non_empty(v: Any) -> bool:
207
+ if v is None:
208
+ return False
209
+ if isinstance(v, str):
210
+ s = v.strip().lower()
211
+ return s not in {"", "null", "none", "n/a", "na", "undefined", "."}
212
+ if isinstance(v, (list, dict, tuple, set)):
213
+ return len(v) > 0
214
+ return True
215
+
216
+ def _parse_numbers_list(s: str) -> List[str]:
217
+ nums = re.findall(r"\b(\d+)\b", s)
218
+ # dedupe keep order
219
+ seen, out = set(), []
220
+ for n in nums:
221
+ if n not in seen:
222
+ seen.add(n)
223
+ out.append(n)
224
+ return out
225
+
226
+ def _extract_numbered_options(text: str) -> Dict[str, str]:
227
+ """Parse previous assistant message for lines like:
228
+ '1) Foo', '2. Bar', '- 3) Baz', '* 4. Buzz'
229
+ Returns mapping '1' -> 'Foo', etc.
230
+ """
231
+ options: Dict[str, str] = {}
232
+ if not text:
233
+ return options
234
+ for raw in text.splitlines():
235
+ line = raw.strip()
236
+ if not line:
237
+ continue
238
+ # Common Markdown patterns: "1. Label", "1) Label", "- 1) Label", "* 1. Label"
239
+ m = re.match(r"^(?:[-*]\s*)?(\d+)[\.)]?\s+(.*)$", line)
240
+ if m:
241
+ idx, label = m.group(1), m.group(2).strip()
242
+ # Strip trailing markdown soft-break spaces
243
+ label = label.rstrip()
244
+ # Ignore labels that are too short or look like continuations
245
+ if len(label) >= 1:
246
+ options[idx] = label
247
+ return options
248
+
249
+ def _detect_field_from_prev_question(
250
+ prev_text: str, schema: Optional[Dict[str, Any]]
251
+ ) -> Optional[str]:
252
+ if not prev_text or not isinstance(schema, dict):
253
+ return None
254
+ t = prev_text.lower()
255
+ # Heuristic synonyms for your onboarding schema
256
+ patterns = [
257
+ ("ideas", ["which ideas attract you", "ideas"]),
258
+ ("description", ["please describe yourself", "describe yourself"]),
259
+ ("myself", ["tell us about yourself", "about yourself"]),
260
+ ("questions", ["do you have any questions"]),
261
+ ("rating", ["rating", "1 to 5", "how satisfied", "how happy"]),
262
+ ("email", ["email"]),
263
+ ("phone", ["phone"]),
264
+ ("name", ["name"]),
265
+ ("city", ["city"]),
266
+ ("state", ["state"]),
267
+ ]
268
+ candidates = set((schema.get("properties") or {}).keys())
269
+ for field, keys in patterns:
270
+ if field in candidates and any(key in t for key in keys):
271
+ return field
272
+ # Fallback: property name appears directly
273
+ for field in candidates:
274
+ if field in t:
275
+ return field
276
+ return None
277
+
278
+ # Resolve active capture from args or agent config
279
+ active_capture_name = capture_name
280
+ active_capture_schema = capture_schema
281
+ if not active_capture_name or not active_capture_schema:
282
+ try:
283
+ cap_cfg = self.agent_service.get_agent_capture(agent_name)
284
+ if cap_cfg:
285
+ active_capture_name = active_capture_name or cap_cfg.get("name")
286
+ active_capture_schema = active_capture_schema or cap_cfg.get(
287
+ "schema"
288
+ )
289
+ except Exception:
290
+ pass
291
+
292
+ latest_by_name: Dict[str, Dict[str, Any]] = {}
293
+ if self.memory_provider:
294
+ try:
295
+ docs = self.memory_provider.find(
296
+ collection="captures",
297
+ query={"user_id": user_id},
298
+ sort=[("timestamp", -1)],
299
+ limit=100,
300
+ )
301
+ for d in docs or []:
302
+ name = (d or {}).get("capture_name")
303
+ if not name or name in latest_by_name:
304
+ continue
305
+ latest_by_name[name] = {
306
+ "data": (d or {}).get("data", {}) or {},
307
+ "mode": (d or {}).get("mode", "once"),
308
+ "agent": (d or {}).get("agent_name"),
309
+ }
310
+ except Exception:
311
+ pass
312
+
313
+ # Incremental save: use prev_assistant's numbered list to map numeric reply -> labels
314
+ incremental: Dict[str, Any] = {}
315
+ try:
316
+ if (
317
+ self.memory_provider
318
+ and active_capture_name
319
+ and isinstance(active_capture_schema, dict)
320
+ ):
321
+ props = (active_capture_schema or {}).get("properties", {})
322
+ required_fields = list(
323
+ (active_capture_schema or {}).get("required", []) or []
324
+ )
325
+ # Prefer a field detected from prev assistant; else if exactly one required missing, use it
326
+ target_field: Optional[str] = _detect_field_from_prev_question(
327
+ prev_assistant, active_capture_schema
328
+ )
329
+ active_data_existing = (
330
+ latest_by_name.get(active_capture_name, {}) or {}
331
+ ).get("data", {}) or {}
332
+
333
+ def _missing_required() -> List[str]:
334
+ return [
335
+ f
336
+ for f in required_fields
337
+ if not _non_empty(active_data_existing.get(f))
338
+ ]
339
+
340
+ if not target_field:
341
+ missing = _missing_required()
342
+ if len(missing) == 1:
343
+ target_field = missing[0]
344
+
345
+ if target_field:
346
+ f_schema = props.get(target_field, {}) or {}
347
+ f_type = f_schema.get("type")
348
+ number_to_label = _extract_numbered_options(prev_assistant)
349
+
350
+ if number_to_label:
351
+ # Map any numbers in user's reply to their labels
352
+ nums = _parse_numbers_list(user_text)
353
+ labels = [
354
+ number_to_label[n] for n in nums if n in number_to_label
355
+ ]
356
+ if labels:
357
+ if f_type == "array":
358
+ incremental[target_field] = labels
359
+ else:
360
+ incremental[target_field] = labels[0]
361
+
362
+ # If we didn't map via options, fallback to type-based parse
363
+ if target_field not in incremental:
364
+ if f_type == "number":
365
+ m = re.search(r"\b([0-9]+(?:\.[0-9]+)?)\b", user_text)
366
+ if m:
367
+ try:
368
+ incremental[target_field] = float(m.group(1))
369
+ except Exception:
370
+ pass
371
+ elif f_type == "array":
372
+ # Accept CSV-style input as array of strings
373
+ parts = [
374
+ p.strip()
375
+ for p in re.split(r"[,\n;]+", user_text)
376
+ if p.strip()
377
+ ]
378
+ if parts:
379
+ incremental[target_field] = parts
380
+ else: # string/default
381
+ if user_text.strip():
382
+ incremental[target_field] = user_text.strip()
383
+
384
+ # Filter out empty junk and save
385
+ if incremental:
386
+ cleaned = {
387
+ k: v for k, v in incremental.items() if _non_empty(v)
388
+ }
389
+ if cleaned:
390
+ try:
391
+ await self.memory_provider.save_capture(
392
+ user_id=user_id,
393
+ capture_name=active_capture_name,
394
+ agent_name=agent_name,
395
+ data=cleaned,
396
+ schema=active_capture_schema,
397
+ )
398
+ except Exception as se:
399
+ logger.error(f"Error saving incremental capture: {se}")
224
400
  except Exception as e:
225
- logger.error(
226
- f"Error during routing, falling back to default agent: {e}",
227
- exc_info=True,
401
+ logger.debug(f"Incremental extraction skipped: {e}")
402
+
403
+ # Build capture context, merging in incremental immediately (avoid read lag)
404
+ def _get_active_data(name: Optional[str]) -> Dict[str, Any]:
405
+ if not name:
406
+ return {}
407
+ base = (latest_by_name.get(name, {}) or {}).get("data", {}) or {}
408
+ if incremental:
409
+ base = {**base, **incremental}
410
+ return base
411
+
412
+ lines: List[str] = []
413
+ if active_capture_name and isinstance(active_capture_schema, dict):
414
+ active_data = _get_active_data(active_capture_name)
415
+ required_fields = list(
416
+ (active_capture_schema or {}).get("required", []) or []
228
417
  )
418
+ missing = [
419
+ f for f in required_fields if not _non_empty(active_data.get(f))
420
+ ]
421
+ form_complete = len(missing) == 0 and len(required_fields) > 0
229
422
 
230
- # --- 7. Combine Context ---
423
+ lines.append(
424
+ "CAPTURED FORM STATE (Authoritative; do not re-ask filled values):"
425
+ )
426
+ lines.append(f"- form_name: {active_capture_name}")
427
+ if active_data:
428
+ pairs = [
429
+ f"{k}: {v}" for k, v in active_data.items() if _non_empty(v)
430
+ ]
431
+ lines.append(
432
+ f"- filled_fields: {', '.join(pairs) if pairs else '(none)'}"
433
+ )
434
+ else:
435
+ lines.append("- filled_fields: (none)")
436
+ lines.append(
437
+ f"- missing_required_fields: {', '.join(missing) if missing else '(none)'}"
438
+ )
439
+ lines.append("")
440
+
441
+ if latest_by_name:
442
+ lines.append("OTHER CAPTURED USER DATA (for reference):")
443
+ for cname, info in latest_by_name.items():
444
+ if cname == active_capture_name:
445
+ continue
446
+ data = info.get("data", {}) or {}
447
+ if data:
448
+ pairs = [f"{k}: {v}" for k, v in data.items() if _non_empty(v)]
449
+ lines.append(
450
+ f"- {cname}: {', '.join(pairs) if pairs else '(none)'}"
451
+ )
452
+ else:
453
+ lines.append(f"- {cname}: (none)")
454
+
455
+ if lines:
456
+ capture_context = "\n".join(lines) + "\n\n"
457
+
458
+ # Merge contexts
231
459
  combined_context = ""
460
+ if capture_context:
461
+ combined_context += capture_context
232
462
  if memory_context:
233
- combined_context += f"CONVERSATION HISTORY (Use for context, but prioritize tools/KB for facts):\n{memory_context}\n\n"
463
+ combined_context += f"CONVERSATION HISTORY (Use for continuity; not authoritative for facts):\n{memory_context}\n\n"
234
464
  if kb_context:
235
- combined_context += f"{kb_context}\n"
236
-
237
- if memory_context or kb_context:
238
- combined_context += "CRITICAL PRIORITIZATION GUIDE: For factual or current information, prioritize Knowledge Base results and Tool results (if applicable) over Conversation History.\n\n"
239
- logger.debug(f"Combined context length: {len(combined_context)}")
465
+ combined_context += kb_context + "\n"
466
+ if combined_context:
467
+ combined_context += (
468
+ "PRIORITIZATION GUIDE:\n"
469
+ "- Prefer Captured User Data for user-specific fields.\n"
470
+ "- Prefer KB/tools for facts.\n"
471
+ "- History is for tone and continuity.\n\n"
472
+ "FORM FLOW RULES:\n"
473
+ "- Ask exactly one missing required field per turn.\n"
474
+ "- Do NOT re-ask or verify values present in Captured User Data (auto-saved, authoritative).\n"
475
+ "- If no required fields are missing, proceed without further capture questions.\n\n"
476
+ )
240
477
 
241
- # --- 8. Generate Response ---
242
- # Pass the processed user_text and images to the agent service
478
+ # 8) Generate response
243
479
  if output_format == "audio":
244
480
  async for audio_chunk in self.agent_service.generate_response(
245
481
  agent_name=agent_name,
246
482
  user_id=user_id,
247
- query=user_text, # Pass processed text
483
+ query=user_text,
248
484
  images=images,
249
485
  memory_context=combined_context,
250
486
  output_format="audio",
@@ -254,20 +490,17 @@ class QueryService(QueryServiceInterface):
254
490
  prompt=prompt,
255
491
  ):
256
492
  yield audio_chunk
257
-
258
- # Store conversation using processed user_text
259
- # Note: Storing images in history is not directly supported by current memory provider interface
260
493
  if self.memory_provider:
261
494
  await self._store_conversation(
262
495
  user_id=user_id,
263
- user_message=user_text, # Store only text part of user query
496
+ user_message=user_text,
264
497
  assistant_message=self.agent_service.last_text_response,
265
498
  )
266
499
  else:
267
500
  full_text_response = ""
268
- # If capture_schema is provided, we run a structured output pass first
269
501
  capture_data: Optional[BaseModel] = None
270
- # If no explicit capture provided, use the agent's configured capture
502
+
503
+ # Resolve agent capture if not provided
271
504
  if not capture_schema or not capture_name:
272
505
  try:
273
506
  cap = self.agent_service.get_agent_capture(agent_name)
@@ -277,9 +510,9 @@ class QueryService(QueryServiceInterface):
277
510
  except Exception:
278
511
  pass
279
512
 
280
- if capture_schema and capture_name:
513
+ # If form is complete, ask for structured output JSON
514
+ if capture_schema and capture_name and form_complete:
281
515
  try:
282
- # Build a dynamic Pydantic model from JSON schema
283
516
  DynamicModel = self._build_model_from_json_schema(
284
517
  capture_name, capture_schema
285
518
  )
@@ -293,14 +526,13 @@ class QueryService(QueryServiceInterface):
293
526
  prompt=(
294
527
  (
295
528
  prompt
296
- + "\n\nReturn only the JSON for the requested schema."
529
+ + "\n\nUsing the captured user data above, return only the JSON for the requested schema. Do not invent values."
297
530
  )
298
531
  if prompt
299
- else "Return only the JSON for the requested schema."
532
+ else "Using the captured user data above, return only the JSON for the requested schema. Do not invent values."
300
533
  ),
301
534
  output_model=DynamicModel,
302
535
  ):
303
- # This yields a pydantic model instance
304
536
  capture_data = result # type: ignore
305
537
  break
306
538
  except Exception as e:
@@ -309,8 +541,8 @@ class QueryService(QueryServiceInterface):
309
541
  async for chunk in self.agent_service.generate_response(
310
542
  agent_name=agent_name,
311
543
  user_id=user_id,
312
- query=user_text, # Pass processed text
313
- images=images, # <-- Pass images
544
+ query=user_text,
545
+ images=images,
314
546
  memory_context=combined_context,
315
547
  output_format="text",
316
548
  prompt=prompt,
@@ -320,16 +552,14 @@ class QueryService(QueryServiceInterface):
320
552
  if output_model is None:
321
553
  full_text_response += chunk
322
554
 
323
- # Store conversation using processed user_text
324
- # Note: Storing images in history is not directly supported by current memory provider interface
325
555
  if self.memory_provider and full_text_response:
326
556
  await self._store_conversation(
327
557
  user_id=user_id,
328
- user_message=user_text, # Store only text part of user query
558
+ user_message=user_text,
329
559
  assistant_message=full_text_response,
330
560
  )
331
561
 
332
- # Persist capture if available
562
+ # Save final capture data if the model returned it
333
563
  if (
334
564
  self.memory_provider
335
565
  and capture_schema
@@ -337,11 +567,10 @@ class QueryService(QueryServiceInterface):
337
567
  and capture_data is not None
338
568
  ):
339
569
  try:
340
- # pydantic v2: model_dump
341
570
  data_dict = (
342
- capture_data.model_dump() # type: ignore[attr-defined]
571
+ capture_data.model_dump()
343
572
  if hasattr(capture_data, "model_dump")
344
- else capture_data.dict() # type: ignore
573
+ else capture_data.dict()
345
574
  )
346
575
  await self.memory_provider.save_capture(
347
576
  user_id=user_id,
@@ -371,52 +600,29 @@ class QueryService(QueryServiceInterface):
371
600
  yield chunk
372
601
  except Exception as tts_e:
373
602
  logger.error(f"Error during TTS for error message: {tts_e}")
374
- # Fallback to yielding text error if TTS fails
375
603
  yield error_msg + f" (TTS Error: {tts_e})"
376
604
  else:
377
605
  yield error_msg
378
606
 
379
607
  async def delete_user_history(self, user_id: str) -> None:
380
- """Delete all conversation history for a user.
381
-
382
- Args:
383
- user_id: User ID
384
- """
608
+ """Delete all conversation history for a user."""
385
609
  if self.memory_provider:
386
610
  try:
387
611
  await self.memory_provider.delete(user_id)
388
- logger.info(f"Deleted conversation history for user: {user_id}")
389
612
  except Exception as e:
390
- logger.error(
391
- f"Error deleting user history for {user_id}: {e}", exc_info=True
392
- )
613
+ logger.error(f"Error deleting user history for {user_id}: {e}")
393
614
  else:
394
- logger.warning(
395
- "Attempted to delete user history, but no memory provider is configured."
396
- )
615
+ logger.debug("No memory provider; skip delete_user_history")
397
616
 
398
617
  async def get_user_history(
399
618
  self,
400
619
  user_id: str,
401
620
  page_num: int = 1,
402
621
  page_size: int = 20,
403
- sort_order: str = "desc", # "asc" for oldest-first, "desc" for newest-first
622
+ sort_order: str = "desc",
404
623
  ) -> Dict[str, Any]:
405
- """Get paginated message history for a user.
406
-
407
- Args:
408
- user_id: User ID
409
- page_num: Page number (starting from 1)
410
- page_size: Number of messages per page
411
- sort_order: Sort order ("asc" or "desc")
412
-
413
- Returns:
414
- Dictionary with paginated results and metadata.
415
- """
624
+ """Get paginated message history for a user."""
416
625
  if not self.memory_provider:
417
- logger.warning(
418
- "Attempted to get user history, but no memory provider is configured."
419
- )
420
626
  return {
421
627
  "data": [],
422
628
  "total": 0,
@@ -425,20 +631,13 @@ class QueryService(QueryServiceInterface):
425
631
  "total_pages": 0,
426
632
  "error": "Memory provider not available",
427
633
  }
428
-
429
634
  try:
430
- # Calculate skip and limit for pagination
431
635
  skip = (page_num - 1) * page_size
432
-
433
- # Get total count of documents
434
636
  total = self.memory_provider.count_documents(
435
637
  collection="conversations", query={"user_id": user_id}
436
638
  )
437
-
438
- # Calculate total pages
439
639
  total_pages = (total + page_size - 1) // page_size if total > 0 else 0
440
640
 
441
- # Get paginated results
442
641
  conversations = self.memory_provider.find(
443
642
  collection="conversations",
444
643
  query={"user_id": user_id},
@@ -447,39 +646,27 @@ class QueryService(QueryServiceInterface):
447
646
  limit=page_size,
448
647
  )
449
648
 
450
- # Format the results
451
- formatted_conversations = []
649
+ formatted: List[Dict[str, Any]] = []
452
650
  for conv in conversations:
453
- timestamp = (
454
- int(conv.get("timestamp").timestamp())
455
- if conv.get("timestamp")
456
- else None
457
- )
458
- # Assuming the stored format matches what _store_conversation saves
459
- # (which currently only stores text messages)
460
- formatted_conversations.append(
651
+ ts = conv.get("timestamp")
652
+ ts_epoch = int(ts.timestamp()) if ts else None
653
+ formatted.append(
461
654
  {
462
655
  "id": str(conv.get("_id")),
463
- "user_message": conv.get("user_message"), # Or how it's stored
464
- "assistant_message": conv.get(
465
- "assistant_message"
466
- ), # Or how it's stored
467
- "timestamp": timestamp,
656
+ "user_message": conv.get("user_message"),
657
+ "assistant_message": conv.get("assistant_message"),
658
+ "timestamp": ts_epoch,
468
659
  }
469
660
  )
470
661
 
471
- logger.info(
472
- f"Retrieved page {page_num}/{total_pages} of history for user {user_id}"
473
- )
474
662
  return {
475
- "data": formatted_conversations,
663
+ "data": formatted,
476
664
  "total": total,
477
665
  "page": page_num,
478
666
  "page_size": page_size,
479
667
  "total_pages": total_pages,
480
668
  "error": None,
481
669
  }
482
-
483
670
  except Exception as e:
484
671
  import traceback
485
672
 
@@ -498,48 +685,29 @@ class QueryService(QueryServiceInterface):
498
685
  async def _store_conversation(
499
686
  self, user_id: str, user_message: str, assistant_message: str
500
687
  ) -> None:
501
- """Store conversation history in memory provider.
502
-
503
- Args:
504
- user_id: User ID
505
- user_message: User message (text part, potentially processed by input guardrails)
506
- assistant_message: Assistant message (potentially processed by output guardrails)
507
- """
508
- if self.memory_provider:
509
- try:
510
- # Store only the text parts for now, as memory provider interface
511
- # doesn't explicitly handle image data storage in history.
512
- await self.memory_provider.store(
513
- user_id,
514
- [
515
- {"role": "user", "content": user_message},
516
- {"role": "assistant", "content": assistant_message},
517
- ],
518
- )
519
- logger.info(f"Stored conversation for user {user_id}")
520
- except Exception as e:
521
- logger.error(
522
- f"Error storing conversation for user {user_id}: {e}", exc_info=True
523
- )
524
- else:
525
- logger.debug(
526
- "Memory provider not configured, skipping conversation storage."
688
+ """Store conversation history in memory provider."""
689
+ if not self.memory_provider:
690
+ return
691
+ try:
692
+ await self.memory_provider.store(
693
+ user_id,
694
+ [
695
+ {"role": "user", "content": user_message},
696
+ {"role": "assistant", "content": assistant_message},
697
+ ],
527
698
  )
699
+ except Exception as e:
700
+ logger.error(f"Store conversation error for {user_id}: {e}")
528
701
 
529
702
  def _build_model_from_json_schema(
530
703
  self, name: str, schema: Dict[str, Any]
531
704
  ) -> 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
- """
705
+ """Create a Pydantic model dynamically from a JSON Schema subset."""
537
706
  from pydantic import create_model
538
707
 
539
708
  def py_type(js: Dict[str, Any]):
540
709
  t = js.get("type")
541
710
  if isinstance(t, list):
542
- # handle ["null", "string"] => Optional[str]
543
711
  non_null = [x for x in t if x != "null"]
544
712
  if not non_null:
545
713
  return Optional[Any]
@@ -557,13 +725,12 @@ class QueryService(QueryServiceInterface):
557
725
  items = js.get("items", {"type": "string"})
558
726
  return List[py_type(items)]
559
727
  if t == "object":
560
- # For now, represent as Dict[str, Any]
561
728
  return Dict[str, Any]
562
729
  return Any
563
730
 
564
731
  properties: Dict[str, Any] = schema.get("properties", {})
565
732
  required = set(schema.get("required", []))
566
- fields = {}
733
+ fields: Dict[str, Any] = {}
567
734
  for field_name, field_schema in properties.items():
568
735
  typ = py_type(field_schema)
569
736
  default = field_schema.get("default")
@@ -1,8 +1,9 @@
1
1
  """
2
2
  Routing service implementation.
3
3
 
4
- This service manages query routing to appropriate agents based on
5
- specializations and query analysis.
4
+ This service manages query routing to appropriate agents using an LLM-based
5
+ analysis. It defaults to a small, low-cost model for routing to minimize
6
+ overhead while maintaining quality.
6
7
  """
7
8
 
8
9
  import logging
@@ -28,7 +29,7 @@ class RoutingService(RoutingServiceInterface):
28
29
  api_key: Optional[str] = None,
29
30
  base_url: Optional[str] = None,
30
31
  model: Optional[str] = None,
31
- ):
32
+ ) -> None:
32
33
  """Initialize the routing service.
33
34
 
34
35
  Args:
@@ -39,7 +40,10 @@ class RoutingService(RoutingServiceInterface):
39
40
  self.agent_service = agent_service
40
41
  self.api_key = api_key
41
42
  self.base_url = base_url
42
- self.model = model
43
+ # Use a small, cheap model for routing unless explicitly provided
44
+ self.model = model or "gpt-4.1-mini"
45
+ # Simple sticky session: remember last routed agent in-process
46
+ self._last_agent = None
43
47
 
44
48
  async def _analyze_query(self, query: str) -> Dict[str, Any]:
45
49
  """Analyze a query to determine routing information.
@@ -82,9 +86,6 @@ class RoutingService(RoutingServiceInterface):
82
86
  2. Any secondary agents that might be helpful (must be from the listed agents)
83
87
  3. The complexity level (1-5, where 5 is most complex)
84
88
  4. Any key topics or technologies mentioned
85
-
86
- Think carefully about whether the query is more technical/development-focused or more
87
- financial/market-focused to match with the appropriate agent.
88
89
  """
89
90
 
90
91
  try:
@@ -131,18 +132,23 @@ class RoutingService(RoutingServiceInterface):
131
132
  if len(agents) == 1:
132
133
  agent_name = next(iter(agents.keys()))
133
134
  logger.info(f"Only one agent available: {agent_name}") # Use logger.info
135
+ self._last_agent = agent_name
134
136
  return agent_name
135
137
 
136
- # Analyze query
137
- analysis = await self._analyze_query(query)
138
+ # Short reply bypass and default stickiness
139
+ short = query.strip().lower()
140
+ short_replies = {"", "yes", "no", "ok", "k", "y", "n", "1", "0"}
141
+ if short in short_replies and self._last_agent:
142
+ return self._last_agent
138
143
 
139
- # Find best agent based on analysis
144
+ # Always analyze with a small model to select the best agent
145
+ analysis = await self._analyze_query(query)
140
146
  best_agent = await self._find_best_ai_agent(
141
147
  analysis["primary_specialization"], analysis["secondary_specializations"]
142
148
  )
143
-
144
- # Return best agent
145
- return best_agent
149
+ chosen = best_agent or next(iter(agents.keys()))
150
+ self._last_agent = chosen
151
+ return chosen
146
152
 
147
153
  async def _find_best_ai_agent(
148
154
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: solana-agent
3
- Version: 31.1.0
3
+ Version: 31.1.1
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,11 +14,11 @@ 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.10.0)
17
+ Requires-Dist: instructor (==1.11.2)
18
18
  Requires-Dist: llama-index-core (==0.13.3)
19
19
  Requires-Dist: llama-index-embeddings-openai (==0.5.0)
20
- Requires-Dist: logfire (==4.3.5)
21
- Requires-Dist: openai (==1.101.0)
20
+ Requires-Dist: logfire (==4.3.6)
21
+ Requires-Dist: openai (==1.102.0)
22
22
  Requires-Dist: pillow (==11.3.0)
23
23
  Requires-Dist: pinecone[asyncio] (==7.3.0)
24
24
  Requires-Dist: pydantic (>=2)
@@ -26,8 +26,8 @@ Requires-Dist: pymongo (==4.14.1)
26
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.1)
30
- Requires-Dist: zep-cloud (==3.4.1)
29
+ Requires-Dist: typer (==0.17.3)
30
+ Requires-Dist: zep-cloud (==3.4.3)
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
@@ -10,7 +10,7 @@ solana_agent/domains/__init__.py,sha256=HiC94wVPRy-QDJSSRywCRrhrFfTBeHjfi5z-QfZv
10
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=_C6YO3rtfa20i09Wtrwy9go8DZGGe6r0Ko_r3hVfqRU,14379
13
+ solana_agent/factories/agent_factory.py,sha256=i20C57hxF7juWuYy9lru28OwCC-xD4g5UhzmQtPe474,14671
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
@@ -34,10 +34,10 @@ solana_agent/repositories/memory.py,sha256=F46vZ-Uhj7PX2uFGCRKYsZ8JLmKteMN1d30qG
34
34
  solana_agent/services/__init__.py,sha256=iko0c2MlF8b_SA_nuBGFllr2E3g_JowOrOzGcnU9tkA,162
35
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=sqWpan0qVrRm0MRxa_14ViuUTP2xsvxbaOl8EPgWJbg,23894
38
- solana_agent/services/routing.py,sha256=C5Ku4t9TqvY7S8wlUPMTC04HCrT4Ib3E8Q8yX0lVU_s,7137
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,,
37
+ solana_agent/services/query.py,sha256=FwqZMmuLUI7TmAkzv91D363PfnWajww35s8j5ubatY4,31544
38
+ solana_agent/services/routing.py,sha256=hsHe8HSGO_xFc0A17WIOGTidLTfLSfApQw3l2HHqkLo,7614
39
+ solana_agent-31.1.1.dist-info/LICENSE,sha256=BnSRc-NSFuyF2s496l_4EyrwAP6YimvxWcjPiJ0J7g4,1057
40
+ solana_agent-31.1.1.dist-info/METADATA,sha256=Ke1JR65TFAZ3YAE4V_x-WbVXvMfkF-2aj_Hs2xlgcxQ,30013
41
+ solana_agent-31.1.1.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
42
+ solana_agent-31.1.1.dist-info/entry_points.txt,sha256=-AuT_mfqk8dlZ0pHuAjx1ouAWpTRjpqvEUa6YV3lmc0,53
43
+ solana_agent-31.1.1.dist-info/RECORD,,