solana-agent 31.1.0__py3-none-any.whl → 31.1.2__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,405 @@ 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
+ # Two completion flags:
204
+ required_complete = False
205
+ form_complete = False # required + optional
206
+
207
+ # Helpers
208
+ def _non_empty(v: Any) -> bool:
209
+ if v is None:
210
+ return False
211
+ if isinstance(v, str):
212
+ s = v.strip().lower()
213
+ return s not in {"", "null", "none", "n/a", "na", "undefined", "."}
214
+ if isinstance(v, (list, dict, tuple, set)):
215
+ return len(v) > 0
216
+ return True
217
+
218
+ def _parse_numbers_list(s: str) -> List[str]:
219
+ nums = re.findall(r"\b(\d+)\b", s)
220
+ seen, out = set(), []
221
+ for n in nums:
222
+ if n not in seen:
223
+ seen.add(n)
224
+ out.append(n)
225
+ return out
226
+
227
+ def _extract_numbered_options(text: str) -> Dict[str, str]:
228
+ """Parse previous assistant message for lines like:
229
+ '1) Foo', '1. Foo', '- 1) Foo', '* 1. Foo' -> {'1': 'Foo'}"""
230
+ options: Dict[str, str] = {}
231
+ if not text:
232
+ return options
233
+ for raw in text.splitlines():
234
+ line = raw.strip()
235
+ if not line:
236
+ continue
237
+ m = re.match(r"^(?:[-*]\s*)?(\d+)[\.)]?\s+(.*)$", line)
238
+ if m:
239
+ idx, label = m.group(1), m.group(2).strip().rstrip()
240
+ if len(label) >= 1:
241
+ options[idx] = label
242
+ return options
243
+
244
+ def _detect_field_from_prev_question(
245
+ prev_text: str, schema: Optional[Dict[str, Any]]
246
+ ) -> Optional[str]:
247
+ if not prev_text or not isinstance(schema, dict):
248
+ return None
249
+ t = prev_text.lower()
250
+ patterns = [
251
+ ("ideas", ["which ideas attract you", "ideas"]),
252
+ ("description", ["please describe yourself", "describe yourself"]),
253
+ ("myself", ["tell us about yourself", "about yourself"]),
254
+ ("questions", ["do you have any questions"]),
255
+ ("rating", ["rating", "1 to 5", "how satisfied", "how happy"]),
256
+ ("email", ["email"]),
257
+ ("phone", ["phone"]),
258
+ ("name", ["name"]),
259
+ ("city", ["city"]),
260
+ ("state", ["state"]),
261
+ ]
262
+ candidates = set((schema.get("properties") or {}).keys())
263
+ for field, keys in patterns:
264
+ if field in candidates and any(key in t for key in keys):
265
+ return field
266
+ for field in candidates:
267
+ if field in t:
268
+ return field
269
+ return None
270
+
271
+ # Resolve active capture from args or agent config
272
+ active_capture_name = capture_name
273
+ active_capture_schema = capture_schema
274
+ if not active_capture_name or not active_capture_schema:
275
+ try:
276
+ cap_cfg = self.agent_service.get_agent_capture(agent_name)
277
+ if cap_cfg:
278
+ active_capture_name = active_capture_name or cap_cfg.get("name")
279
+ active_capture_schema = active_capture_schema or cap_cfg.get(
280
+ "schema"
281
+ )
282
+ except Exception:
283
+ pass
284
+
285
+ latest_by_name: Dict[str, Dict[str, Any]] = {}
286
+ if self.memory_provider:
287
+ try:
288
+ docs = self.memory_provider.find(
289
+ collection="captures",
290
+ query={"user_id": user_id},
291
+ sort=[("timestamp", -1)],
292
+ limit=100,
293
+ )
294
+ for d in docs or []:
295
+ name = (d or {}).get("capture_name")
296
+ if not name or name in latest_by_name:
297
+ continue
298
+ latest_by_name[name] = {
299
+ "data": (d or {}).get("data", {}) or {},
300
+ "mode": (d or {}).get("mode", "once"),
301
+ "agent": (d or {}).get("agent_name"),
302
+ }
303
+ except Exception:
304
+ pass
305
+
306
+ # Incremental save: use prev_assistant's numbered list to map numeric reply -> labels
307
+ incremental: Dict[str, Any] = {}
308
+ try:
309
+ if (
310
+ self.memory_provider
311
+ and active_capture_name
312
+ and isinstance(active_capture_schema, dict)
313
+ ):
314
+ props = (active_capture_schema or {}).get("properties", {})
315
+ required_fields = list(
316
+ (active_capture_schema or {}).get("required", []) or []
317
+ )
318
+ all_fields = list(props.keys())
319
+ optional_fields = [
320
+ f for f in all_fields if f not in set(required_fields)
321
+ ]
322
+
323
+ active_data_existing = (
324
+ latest_by_name.get(active_capture_name, {}) or {}
325
+ ).get("data", {}) or {}
326
+
327
+ def _missing(fields: List[str]) -> List[str]:
328
+ return [
329
+ f
330
+ for f in fields
331
+ if not _non_empty(active_data_existing.get(f))
332
+ ]
333
+
334
+ missing_required = _missing(required_fields)
335
+ missing_optional = _missing(optional_fields)
336
+
337
+ target_field: Optional[str] = _detect_field_from_prev_question(
338
+ prev_assistant, active_capture_schema
339
+ )
340
+ if not target_field:
341
+ # If exactly one required missing, target it; else if none required missing and exactly one optional missing, target it.
342
+ if len(missing_required) == 1:
343
+ target_field = missing_required[0]
344
+ elif len(missing_required) == 0 and len(missing_optional) == 1:
345
+ target_field = missing_optional[0]
346
+
347
+ if target_field and target_field in props:
348
+ f_schema = props.get(target_field, {}) or {}
349
+ f_type = f_schema.get("type")
350
+ number_to_label = _extract_numbered_options(prev_assistant)
351
+
352
+ if number_to_label:
353
+ nums = _parse_numbers_list(user_text)
354
+ labels = [
355
+ number_to_label[n] for n in nums if n in number_to_label
356
+ ]
357
+ if labels:
358
+ if f_type == "array":
359
+ incremental[target_field] = labels
360
+ else:
361
+ incremental[target_field] = labels[0]
362
+
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
+ parts = [
373
+ p.strip()
374
+ for p in re.split(r"[,\n;]+", user_text)
375
+ if p.strip()
376
+ ]
377
+ if parts:
378
+ incremental[target_field] = parts
379
+ else:
380
+ if user_text.strip():
381
+ incremental[target_field] = user_text.strip()
382
+
383
+ if incremental:
384
+ cleaned = {
385
+ k: v for k, v in incremental.items() if _non_empty(v)
386
+ }
387
+ if cleaned:
388
+ try:
389
+ await self.memory_provider.save_capture(
390
+ user_id=user_id,
391
+ capture_name=active_capture_name,
392
+ agent_name=agent_name,
393
+ data=cleaned,
394
+ schema=active_capture_schema,
395
+ )
396
+ except Exception as se:
397
+ logger.error(f"Error saving incremental capture: {se}")
398
+
224
399
  except Exception as e:
225
- logger.error(
226
- f"Error during routing, falling back to default agent: {e}",
227
- exc_info=True,
400
+ logger.debug(f"Incremental extraction skipped: {e}")
401
+
402
+ # Build capture context, merging in incremental immediately (avoid read lag)
403
+ def _get_active_data(name: Optional[str]) -> Dict[str, Any]:
404
+ if not name:
405
+ return {}
406
+ base = (latest_by_name.get(name, {}) or {}).get("data", {}) or {}
407
+ if incremental:
408
+ base = {**base, **incremental}
409
+ return base
410
+
411
+ lines: List[str] = []
412
+ if active_capture_name and isinstance(active_capture_schema, dict):
413
+ props = (active_capture_schema or {}).get("properties", {})
414
+ required_fields = list(
415
+ (active_capture_schema or {}).get("required", []) or []
228
416
  )
417
+ all_fields = list(props.keys())
418
+ optional_fields = [
419
+ f for f in all_fields if f not in set(required_fields)
420
+ ]
421
+
422
+ active_data = _get_active_data(active_capture_name)
423
+
424
+ def _missing_from(data: Dict[str, Any], fields: List[str]) -> List[str]:
425
+ return [f for f in fields if not _non_empty(data.get(f))]
229
426
 
230
- # --- 7. Combine Context ---
427
+ missing_required = _missing_from(active_data, required_fields)
428
+ missing_optional = _missing_from(active_data, optional_fields)
429
+
430
+ required_complete = (
431
+ len(missing_required) == 0 and len(required_fields) > 0
432
+ )
433
+ form_complete = required_complete and len(missing_optional) == 0
434
+
435
+ lines.append(
436
+ "CAPTURED FORM STATE (Authoritative; do not re-ask filled values):"
437
+ )
438
+ lines.append(f"- form_name: {active_capture_name}")
439
+
440
+ if active_data:
441
+ pairs = [
442
+ f"{k}: {v}" for k, v in active_data.items() if _non_empty(v)
443
+ ]
444
+ lines.append(
445
+ f"- filled_fields: {', '.join(pairs) if pairs else '(none)'}"
446
+ )
447
+ else:
448
+ lines.append("- filled_fields: (none)")
449
+
450
+ lines.append(
451
+ f"- missing_required_fields: {', '.join(missing_required) if missing_required else '(none)'}"
452
+ )
453
+ lines.append(
454
+ f"- missing_optional_fields: {', '.join(missing_optional) if missing_optional else '(none)'}"
455
+ )
456
+ lines.append("")
457
+
458
+ if latest_by_name:
459
+ lines.append("OTHER CAPTURED USER DATA (for reference):")
460
+ for cname, info in latest_by_name.items():
461
+ if cname == active_capture_name:
462
+ continue
463
+ data = info.get("data", {}) or {}
464
+ if data:
465
+ pairs = [f"{k}: {v}" for k, v in data.items() if _non_empty(v)]
466
+ lines.append(
467
+ f"- {cname}: {', '.join(pairs) if pairs else '(none)'}"
468
+ )
469
+ else:
470
+ lines.append(f"- {cname}: (none)")
471
+
472
+ if lines:
473
+ capture_context = "\n".join(lines) + "\n\n"
474
+
475
+ # Merge contexts + flow rules
231
476
  combined_context = ""
477
+ if capture_context:
478
+ combined_context += capture_context
232
479
  if memory_context:
233
- combined_context += f"CONVERSATION HISTORY (Use for context, but prioritize tools/KB for facts):\n{memory_context}\n\n"
480
+ combined_context += f"CONVERSATION HISTORY (Use for continuity; not authoritative for facts):\n{memory_context}\n\n"
234
481
  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)}")
482
+ combined_context += kb_context + "\n"
483
+ if combined_context:
484
+ combined_context += (
485
+ "PRIORITIZATION GUIDE:\n"
486
+ "- Prefer Captured User Data for user-specific fields.\n"
487
+ "- Prefer KB/tools for facts.\n"
488
+ "- History is for tone and continuity.\n\n"
489
+ "FORM FLOW RULES:\n"
490
+ "- Ask exactly one field per turn.\n"
491
+ "- If any required fields are missing, ask the next missing required field.\n"
492
+ "- If all required fields are filled but optional fields are missing, ask the next missing optional field.\n"
493
+ "- Do NOT re-ask or verify values present in Captured User Data (auto-saved, authoritative).\n"
494
+ "- Do NOT provide summaries until no required or optional fields are missing.\n\n"
495
+ )
240
496
 
241
- # --- 8. Generate Response ---
242
- # Pass the processed user_text and images to the agent service
497
+ # 8) Generate response
243
498
  if output_format == "audio":
244
499
  async for audio_chunk in self.agent_service.generate_response(
245
500
  agent_name=agent_name,
246
501
  user_id=user_id,
247
- query=user_text, # Pass processed text
502
+ query=user_text,
248
503
  images=images,
249
504
  memory_context=combined_context,
250
505
  output_format="audio",
@@ -254,20 +509,17 @@ class QueryService(QueryServiceInterface):
254
509
  prompt=prompt,
255
510
  ):
256
511
  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
512
  if self.memory_provider:
261
513
  await self._store_conversation(
262
514
  user_id=user_id,
263
- user_message=user_text, # Store only text part of user query
515
+ user_message=user_text,
264
516
  assistant_message=self.agent_service.last_text_response,
265
517
  )
266
518
  else:
267
519
  full_text_response = ""
268
- # If capture_schema is provided, we run a structured output pass first
269
520
  capture_data: Optional[BaseModel] = None
270
- # If no explicit capture provided, use the agent's configured capture
521
+
522
+ # Resolve agent capture if not provided
271
523
  if not capture_schema or not capture_name:
272
524
  try:
273
525
  cap = self.agent_service.get_agent_capture(agent_name)
@@ -277,9 +529,9 @@ class QueryService(QueryServiceInterface):
277
529
  except Exception:
278
530
  pass
279
531
 
280
- if capture_schema and capture_name:
532
+ # Only run final structured output when no required or optional fields are missing
533
+ if capture_schema and capture_name and form_complete:
281
534
  try:
282
- # Build a dynamic Pydantic model from JSON schema
283
535
  DynamicModel = self._build_model_from_json_schema(
284
536
  capture_name, capture_schema
285
537
  )
@@ -293,14 +545,13 @@ class QueryService(QueryServiceInterface):
293
545
  prompt=(
294
546
  (
295
547
  prompt
296
- + "\n\nReturn only the JSON for the requested schema."
548
+ + "\n\nUsing the captured user data above, return only the JSON for the requested schema. Do not invent values."
297
549
  )
298
550
  if prompt
299
- else "Return only the JSON for the requested schema."
551
+ else "Using the captured user data above, return only the JSON for the requested schema. Do not invent values."
300
552
  ),
301
553
  output_model=DynamicModel,
302
554
  ):
303
- # This yields a pydantic model instance
304
555
  capture_data = result # type: ignore
305
556
  break
306
557
  except Exception as e:
@@ -309,8 +560,8 @@ class QueryService(QueryServiceInterface):
309
560
  async for chunk in self.agent_service.generate_response(
310
561
  agent_name=agent_name,
311
562
  user_id=user_id,
312
- query=user_text, # Pass processed text
313
- images=images, # <-- Pass images
563
+ query=user_text,
564
+ images=images,
314
565
  memory_context=combined_context,
315
566
  output_format="text",
316
567
  prompt=prompt,
@@ -320,16 +571,14 @@ class QueryService(QueryServiceInterface):
320
571
  if output_model is None:
321
572
  full_text_response += chunk
322
573
 
323
- # Store conversation using processed user_text
324
- # Note: Storing images in history is not directly supported by current memory provider interface
325
574
  if self.memory_provider and full_text_response:
326
575
  await self._store_conversation(
327
576
  user_id=user_id,
328
- user_message=user_text, # Store only text part of user query
577
+ user_message=user_text,
329
578
  assistant_message=full_text_response,
330
579
  )
331
580
 
332
- # Persist capture if available
581
+ # Save final capture data if the model returned it
333
582
  if (
334
583
  self.memory_provider
335
584
  and capture_schema
@@ -337,11 +586,10 @@ class QueryService(QueryServiceInterface):
337
586
  and capture_data is not None
338
587
  ):
339
588
  try:
340
- # pydantic v2: model_dump
341
589
  data_dict = (
342
- capture_data.model_dump() # type: ignore[attr-defined]
590
+ capture_data.model_dump()
343
591
  if hasattr(capture_data, "model_dump")
344
- else capture_data.dict() # type: ignore
592
+ else capture_data.dict()
345
593
  )
346
594
  await self.memory_provider.save_capture(
347
595
  user_id=user_id,
@@ -371,52 +619,29 @@ class QueryService(QueryServiceInterface):
371
619
  yield chunk
372
620
  except Exception as tts_e:
373
621
  logger.error(f"Error during TTS for error message: {tts_e}")
374
- # Fallback to yielding text error if TTS fails
375
622
  yield error_msg + f" (TTS Error: {tts_e})"
376
623
  else:
377
624
  yield error_msg
378
625
 
379
626
  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
- """
627
+ """Delete all conversation history for a user."""
385
628
  if self.memory_provider:
386
629
  try:
387
630
  await self.memory_provider.delete(user_id)
388
- logger.info(f"Deleted conversation history for user: {user_id}")
389
631
  except Exception as e:
390
- logger.error(
391
- f"Error deleting user history for {user_id}: {e}", exc_info=True
392
- )
632
+ logger.error(f"Error deleting user history for {user_id}: {e}")
393
633
  else:
394
- logger.warning(
395
- "Attempted to delete user history, but no memory provider is configured."
396
- )
634
+ logger.debug("No memory provider; skip delete_user_history")
397
635
 
398
636
  async def get_user_history(
399
637
  self,
400
638
  user_id: str,
401
639
  page_num: int = 1,
402
640
  page_size: int = 20,
403
- sort_order: str = "desc", # "asc" for oldest-first, "desc" for newest-first
641
+ sort_order: str = "desc",
404
642
  ) -> 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
- """
643
+ """Get paginated message history for a user."""
416
644
  if not self.memory_provider:
417
- logger.warning(
418
- "Attempted to get user history, but no memory provider is configured."
419
- )
420
645
  return {
421
646
  "data": [],
422
647
  "total": 0,
@@ -425,20 +650,13 @@ class QueryService(QueryServiceInterface):
425
650
  "total_pages": 0,
426
651
  "error": "Memory provider not available",
427
652
  }
428
-
429
653
  try:
430
- # Calculate skip and limit for pagination
431
654
  skip = (page_num - 1) * page_size
432
-
433
- # Get total count of documents
434
655
  total = self.memory_provider.count_documents(
435
656
  collection="conversations", query={"user_id": user_id}
436
657
  )
437
-
438
- # Calculate total pages
439
658
  total_pages = (total + page_size - 1) // page_size if total > 0 else 0
440
659
 
441
- # Get paginated results
442
660
  conversations = self.memory_provider.find(
443
661
  collection="conversations",
444
662
  query={"user_id": user_id},
@@ -447,39 +665,27 @@ class QueryService(QueryServiceInterface):
447
665
  limit=page_size,
448
666
  )
449
667
 
450
- # Format the results
451
- formatted_conversations = []
668
+ formatted: List[Dict[str, Any]] = []
452
669
  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(
670
+ ts = conv.get("timestamp")
671
+ ts_epoch = int(ts.timestamp()) if ts else None
672
+ formatted.append(
461
673
  {
462
674
  "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,
675
+ "user_message": conv.get("user_message"),
676
+ "assistant_message": conv.get("assistant_message"),
677
+ "timestamp": ts_epoch,
468
678
  }
469
679
  )
470
680
 
471
- logger.info(
472
- f"Retrieved page {page_num}/{total_pages} of history for user {user_id}"
473
- )
474
681
  return {
475
- "data": formatted_conversations,
682
+ "data": formatted,
476
683
  "total": total,
477
684
  "page": page_num,
478
685
  "page_size": page_size,
479
686
  "total_pages": total_pages,
480
687
  "error": None,
481
688
  }
482
-
483
689
  except Exception as e:
484
690
  import traceback
485
691
 
@@ -498,48 +704,29 @@ class QueryService(QueryServiceInterface):
498
704
  async def _store_conversation(
499
705
  self, user_id: str, user_message: str, assistant_message: str
500
706
  ) -> 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."
707
+ """Store conversation history in memory provider."""
708
+ if not self.memory_provider:
709
+ return
710
+ try:
711
+ await self.memory_provider.store(
712
+ user_id,
713
+ [
714
+ {"role": "user", "content": user_message},
715
+ {"role": "assistant", "content": assistant_message},
716
+ ],
527
717
  )
718
+ except Exception as e:
719
+ logger.error(f"Store conversation error for {user_id}: {e}")
528
720
 
529
721
  def _build_model_from_json_schema(
530
722
  self, name: str, schema: Dict[str, Any]
531
723
  ) -> 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
- """
724
+ """Create a Pydantic model dynamically from a JSON Schema subset."""
537
725
  from pydantic import create_model
538
726
 
539
727
  def py_type(js: Dict[str, Any]):
540
728
  t = js.get("type")
541
729
  if isinstance(t, list):
542
- # handle ["null", "string"] => Optional[str]
543
730
  non_null = [x for x in t if x != "null"]
544
731
  if not non_null:
545
732
  return Optional[Any]
@@ -557,13 +744,12 @@ class QueryService(QueryServiceInterface):
557
744
  items = js.get("items", {"type": "string"})
558
745
  return List[py_type(items)]
559
746
  if t == "object":
560
- # For now, represent as Dict[str, Any]
561
747
  return Dict[str, Any]
562
748
  return Any
563
749
 
564
750
  properties: Dict[str, Any] = schema.get("properties", {})
565
751
  required = set(schema.get("required", []))
566
- fields = {}
752
+ fields: Dict[str, Any] = {}
567
753
  for field_name, field_schema in properties.items():
568
754
  typ = py_type(field_schema)
569
755
  default = field_schema.get("default")
@@ -572,5 +758,5 @@ class QueryService(QueryServiceInterface):
572
758
  else:
573
759
  fields[field_name] = (typ, default)
574
760
 
575
- Model = create_model(name, **fields) # type: ignore
761
+ Model = create_model(name, **fields)
576
762
  return Model
@@ -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.2
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=bmam8rvpWFEGg1uVABOqd1X2GRqRNwBu7JCCzZ93iIE,32328
38
+ solana_agent/services/routing.py,sha256=hsHe8HSGO_xFc0A17WIOGTidLTfLSfApQw3l2HHqkLo,7614
39
+ solana_agent-31.1.2.dist-info/LICENSE,sha256=BnSRc-NSFuyF2s496l_4EyrwAP6YimvxWcjPiJ0J7g4,1057
40
+ solana_agent-31.1.2.dist-info/METADATA,sha256=ic6JDKmY4bhO78QgX7sjtVV-BVuuxg2KR4sI58hSWP4,30013
41
+ solana_agent-31.1.2.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
42
+ solana_agent-31.1.2.dist-info/entry_points.txt,sha256=-AuT_mfqk8dlZ0pHuAjx1ouAWpTRjpqvEUa6YV3lmc0,53
43
+ solana_agent-31.1.2.dist-info/RECORD,,