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.
- solana_agent/factories/agent_factory.py +7 -0
- solana_agent/services/query.py +376 -209
- solana_agent/services/routing.py +19 -13
- {solana_agent-31.1.0.dist-info → solana_agent-31.1.1.dist-info}/METADATA +6 -6
- {solana_agent-31.1.0.dist-info → solana_agent-31.1.1.dist-info}/RECORD +8 -8
- {solana_agent-31.1.0.dist-info → solana_agent-31.1.1.dist-info}/LICENSE +0 -0
- {solana_agent-31.1.0.dist-info → solana_agent-31.1.1.dist-info}/WHEEL +0 -0
- {solana_agent-31.1.0.dist-info → solana_agent-31.1.1.dist-info}/entry_points.txt +0 -0
@@ -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
|
solana_agent/services/query.py
CHANGED
@@ -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
|
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
|
-
#
|
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
|
-
#
|
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
|
-
|
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
|
-
|
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(
|
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
|
-
#
|
153
|
-
|
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
|
-
|
162
|
-
|
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=
|
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
|
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,
|
136
|
+
await self._store_conversation(user_id, original_text, greeting)
|
177
137
|
return
|
178
138
|
|
179
|
-
#
|
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
|
-
|
185
|
-
|
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
|
-
#
|
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,
|
155
|
+
include_metadata=False,
|
200
156
|
)
|
201
|
-
|
202
157
|
if kb_results:
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
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(
|
195
|
+
agent_name = await router.route_query(routing_input)
|
221
196
|
else:
|
222
|
-
agent_name = await self.routing_service.route_query(
|
223
|
-
|
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.
|
226
|
-
|
227
|
-
|
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
|
-
|
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
|
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 +=
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
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
|
-
#
|
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,
|
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,
|
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
|
-
|
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
|
-
|
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\
|
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 "
|
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,
|
313
|
-
images=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,
|
558
|
+
user_message=user_text,
|
329
559
|
assistant_message=full_text_response,
|
330
560
|
)
|
331
561
|
|
332
|
-
#
|
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()
|
571
|
+
capture_data.model_dump()
|
343
572
|
if hasattr(capture_data, "model_dump")
|
344
|
-
else capture_data.dict()
|
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.
|
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",
|
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
|
-
|
451
|
-
formatted_conversations = []
|
649
|
+
formatted: List[Dict[str, Any]] = []
|
452
650
|
for conv in conversations:
|
453
|
-
|
454
|
-
|
455
|
-
|
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"),
|
464
|
-
"assistant_message": conv.get(
|
465
|
-
|
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":
|
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
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
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")
|
solana_agent/services/routing.py
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
"""
|
2
2
|
Routing service implementation.
|
3
3
|
|
4
|
-
This service manages query routing to appropriate agents based
|
5
|
-
|
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
|
-
|
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
|
-
#
|
137
|
-
|
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
|
-
#
|
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
|
-
|
145
|
-
return
|
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.
|
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.
|
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.
|
21
|
-
Requires-Dist: openai (==1.
|
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.
|
30
|
-
Requires-Dist: zep-cloud (==3.4.
|
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=
|
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=
|
38
|
-
solana_agent/services/routing.py,sha256=
|
39
|
-
solana_agent-31.1.
|
40
|
-
solana_agent-31.1.
|
41
|
-
solana_agent-31.1.
|
42
|
-
solana_agent-31.1.
|
43
|
-
solana_agent-31.1.
|
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,,
|
File without changes
|
File without changes
|
File without changes
|