aurora-actr 0.3.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.
- aurora/__init__.py +11 -0
- aurora/cli/__init__.py +42 -0
- aurora/cli/py.typed +0 -0
- aurora/context_code/__init__.py +42 -0
- aurora/context_code/py.typed +0 -0
- aurora/core/__init__.py +56 -0
- aurora/core/py.typed +0 -0
- aurora/mcp/__init__.py +10 -0
- aurora/mcp/config.py +116 -0
- aurora/mcp/server.py +385 -0
- aurora/mcp/tools.py +1198 -0
- aurora/reasoning/__init__.py +42 -0
- aurora/reasoning/py.typed +0 -0
- aurora/scripts/__init__.py +1 -0
- aurora/scripts/uninstall.py +105 -0
- aurora/soar/__init__.py +42 -0
- aurora/soar/py.typed +0 -0
- aurora/testing/__init__.py +42 -0
- aurora/testing/py.typed +0 -0
- aurora_actr-0.3.1.dist-info/METADATA +480 -0
- aurora_actr-0.3.1.dist-info/RECORD +24 -0
- aurora_actr-0.3.1.dist-info/WHEEL +5 -0
- aurora_actr-0.3.1.dist-info/entry_points.txt +4 -0
- aurora_actr-0.3.1.dist-info/top_level.txt +1 -0
aurora/mcp/tools.py
ADDED
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AURORA MCP Tools - Implementation of MCP tools for code indexing and search.
|
|
3
|
+
|
|
4
|
+
This module provides the actual implementation of the MCP tools:
|
|
5
|
+
- aurora_search: Search indexed codebase
|
|
6
|
+
- aurora_index: Index directory of code files
|
|
7
|
+
- aurora_context: Retrieve code context from file
|
|
8
|
+
- aurora_related: Find related chunks using ACT-R spreading activation
|
|
9
|
+
- aurora_list_agents: List all discovered agents
|
|
10
|
+
- aurora_search_agents: Search agents by keyword
|
|
11
|
+
- aurora_show_agent: Show full agent details
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from aurora_cli.agent_discovery.manifest import ManifestManager
|
|
22
|
+
from aurora_cli.memory_manager import MemoryManager
|
|
23
|
+
|
|
24
|
+
from aurora.mcp.config import log_performance, setup_mcp_logging
|
|
25
|
+
from aurora_context_code.languages.python import PythonParser
|
|
26
|
+
from aurora_context_code.registry import get_global_registry
|
|
27
|
+
from aurora_context_code.semantic import EmbeddingProvider
|
|
28
|
+
from aurora_context_code.semantic.hybrid_retriever import HybridRetriever
|
|
29
|
+
from aurora_core.activation.engine import ActivationEngine
|
|
30
|
+
from aurora_core.store.sqlite import SQLiteStore
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Setup MCP logging
|
|
36
|
+
mcp_logger = setup_mcp_logging()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuroraMCPTools:
|
|
40
|
+
"""Implementation of AURORA MCP tools."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, db_path: str, config_path: str | None = None):
|
|
43
|
+
"""
|
|
44
|
+
Initialize AURORA MCP Tools.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
db_path: Path to SQLite database
|
|
48
|
+
config_path: Path to AURORA config file (currently unused)
|
|
49
|
+
"""
|
|
50
|
+
self.db_path = db_path
|
|
51
|
+
self.config_path = config_path
|
|
52
|
+
|
|
53
|
+
# Initialize components lazily (on first use)
|
|
54
|
+
self._store: SQLiteStore | None = None
|
|
55
|
+
self._activation_engine: ActivationEngine | None = None
|
|
56
|
+
self._embedding_provider: EmbeddingProvider | None = None
|
|
57
|
+
self._retriever: HybridRetriever | None = None
|
|
58
|
+
self._memory_manager: MemoryManager | None = None
|
|
59
|
+
self._parser_registry = None # Lazy initialization
|
|
60
|
+
|
|
61
|
+
# Session cache for aurora_get (Task 7.1, 7.2)
|
|
62
|
+
self._last_search_results: list = []
|
|
63
|
+
self._last_search_timestamp: float | None = None
|
|
64
|
+
|
|
65
|
+
def _ensure_initialized(self) -> None:
|
|
66
|
+
"""Ensure all components are initialized."""
|
|
67
|
+
if self._store is None:
|
|
68
|
+
self._store = SQLiteStore(self.db_path)
|
|
69
|
+
|
|
70
|
+
if self._activation_engine is None:
|
|
71
|
+
self._activation_engine = ActivationEngine()
|
|
72
|
+
|
|
73
|
+
if self._embedding_provider is None:
|
|
74
|
+
self._embedding_provider = EmbeddingProvider()
|
|
75
|
+
|
|
76
|
+
if self._retriever is None:
|
|
77
|
+
self._retriever = HybridRetriever(
|
|
78
|
+
self._store, self._activation_engine, self._embedding_provider
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if self._parser_registry is None:
|
|
82
|
+
self._parser_registry = get_global_registry()
|
|
83
|
+
|
|
84
|
+
if self._memory_manager is None:
|
|
85
|
+
self._memory_manager = MemoryManager(
|
|
86
|
+
self._store, self._parser_registry, self._embedding_provider
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@log_performance("aurora_search")
|
|
90
|
+
def aurora_search(self, query: str, limit: int = 10) -> str:
|
|
91
|
+
"""
|
|
92
|
+
Search AURORA indexed codebase using hybrid retrieval.
|
|
93
|
+
|
|
94
|
+
No API key required. Uses local index only.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
query: Search query string
|
|
98
|
+
limit: Maximum number of results (default: 10)
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
JSON string with search results containing:
|
|
102
|
+
- file_path: Path to source file
|
|
103
|
+
- function_name: Name of function/class (if applicable)
|
|
104
|
+
- content: Code content
|
|
105
|
+
- score: Hybrid relevance score
|
|
106
|
+
- chunk_id: Unique chunk identifier
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
import time
|
|
110
|
+
|
|
111
|
+
self._ensure_initialized()
|
|
112
|
+
|
|
113
|
+
# Use HybridRetriever to search
|
|
114
|
+
results = self._retriever.retrieve(query, top_k=limit)
|
|
115
|
+
|
|
116
|
+
# Record access for ACT-R activation tracking (CRITICAL FIX)
|
|
117
|
+
access_time = datetime.now(timezone.utc)
|
|
118
|
+
for result in results:
|
|
119
|
+
chunk_id = result.get("chunk_id")
|
|
120
|
+
if chunk_id:
|
|
121
|
+
try:
|
|
122
|
+
self._store.record_access(
|
|
123
|
+
chunk_id=chunk_id, access_time=access_time, context=query
|
|
124
|
+
)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning(f"Failed to record access for chunk {chunk_id}: {e}")
|
|
127
|
+
|
|
128
|
+
# Format results
|
|
129
|
+
# HybridRetriever returns list of dicts with keys:
|
|
130
|
+
# chunk_id, content, activation_score, semantic_score, hybrid_score, metadata
|
|
131
|
+
# metadata contains: type, name, file_path
|
|
132
|
+
formatted_results = []
|
|
133
|
+
for result in results:
|
|
134
|
+
metadata = result.get("metadata", {})
|
|
135
|
+
formatted_results.append(
|
|
136
|
+
{
|
|
137
|
+
"file_path": metadata.get("file_path", ""),
|
|
138
|
+
"function_name": metadata.get("name", ""),
|
|
139
|
+
"content": result.get("content", ""),
|
|
140
|
+
"score": float(result.get("hybrid_score", 0.0)),
|
|
141
|
+
"chunk_id": result.get("chunk_id", ""),
|
|
142
|
+
"line_range": metadata.get("line_range", [0, 0]),
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Store results in session cache for aurora_get (Task 7.3)
|
|
147
|
+
self._last_search_results = formatted_results
|
|
148
|
+
self._last_search_timestamp = time.time()
|
|
149
|
+
|
|
150
|
+
return json.dumps(formatted_results, indent=2)
|
|
151
|
+
|
|
152
|
+
except Exception as e:
|
|
153
|
+
logger.error(f"Error in aurora_search: {e}")
|
|
154
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
155
|
+
|
|
156
|
+
@log_performance("aurora_index")
|
|
157
|
+
def aurora_index(self, path: str, pattern: str = "*.py") -> str:
|
|
158
|
+
"""
|
|
159
|
+
Index a directory of code files.
|
|
160
|
+
|
|
161
|
+
No API key required. Local file parsing and storage only.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
path: Directory path to index
|
|
165
|
+
pattern: File pattern to match (default: *.py)
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
JSON string with indexing statistics:
|
|
169
|
+
- files_indexed: Number of files successfully indexed
|
|
170
|
+
- chunks_created: Number of code chunks created
|
|
171
|
+
- duration_seconds: Total indexing duration
|
|
172
|
+
- errors: Number of files that failed
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
self._ensure_initialized()
|
|
176
|
+
|
|
177
|
+
# Verify path exists
|
|
178
|
+
path_obj = Path(path).expanduser().resolve()
|
|
179
|
+
if not path_obj.exists():
|
|
180
|
+
return json.dumps({"error": f"Path does not exist: {path}"}, indent=2)
|
|
181
|
+
|
|
182
|
+
if not path_obj.is_dir():
|
|
183
|
+
return json.dumps({"error": f"Path is not a directory: {path}"}, indent=2)
|
|
184
|
+
|
|
185
|
+
# Index the path
|
|
186
|
+
stats = self._memory_manager.index_path(path_obj)
|
|
187
|
+
|
|
188
|
+
# Return statistics
|
|
189
|
+
return json.dumps(
|
|
190
|
+
{
|
|
191
|
+
"files_indexed": stats.files_indexed,
|
|
192
|
+
"chunks_created": stats.chunks_created,
|
|
193
|
+
"duration_seconds": round(stats.duration_seconds, 2),
|
|
194
|
+
"errors": stats.errors,
|
|
195
|
+
},
|
|
196
|
+
indent=2,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.error(f"Error in aurora_index: {e}")
|
|
201
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
202
|
+
|
|
203
|
+
@log_performance("aurora_context")
|
|
204
|
+
def aurora_context(self, file_path: str, function: str | None = None) -> str:
|
|
205
|
+
"""
|
|
206
|
+
Get code context from a specific file.
|
|
207
|
+
|
|
208
|
+
No API key required. Retrieves local file content only.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
file_path: Path to source file
|
|
212
|
+
function: Optional function name to extract
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
String with code content (or JSON error if file not found)
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
# Resolve path
|
|
219
|
+
path_obj = Path(file_path).expanduser().resolve()
|
|
220
|
+
|
|
221
|
+
if not path_obj.exists():
|
|
222
|
+
return json.dumps({"error": f"File not found: {file_path}"}, indent=2)
|
|
223
|
+
|
|
224
|
+
if not path_obj.is_file():
|
|
225
|
+
return json.dumps({"error": f"Path is not a file: {file_path}"}, indent=2)
|
|
226
|
+
|
|
227
|
+
# Read file content
|
|
228
|
+
try:
|
|
229
|
+
content = path_obj.read_text(encoding="utf-8")
|
|
230
|
+
except UnicodeDecodeError:
|
|
231
|
+
return json.dumps(
|
|
232
|
+
{"error": f"Unable to decode file (not UTF-8): {file_path}"}, indent=2
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# If function specified, extract it using AST parsing
|
|
236
|
+
if function:
|
|
237
|
+
if file_path.endswith(".py"):
|
|
238
|
+
parser = PythonParser()
|
|
239
|
+
chunks = parser.parse(path_obj)
|
|
240
|
+
|
|
241
|
+
# Find function in chunks
|
|
242
|
+
for chunk in chunks:
|
|
243
|
+
# CodeChunk has 'name' attribute directly
|
|
244
|
+
if hasattr(chunk, "name") and chunk.name == function:
|
|
245
|
+
# Extract function code using line numbers
|
|
246
|
+
lines = content.splitlines()
|
|
247
|
+
start_line = chunk.line_start - 1 # Convert to 0-indexed
|
|
248
|
+
end_line = (
|
|
249
|
+
chunk.line_end
|
|
250
|
+
) # end_line is inclusive, so we use it as-is for slicing
|
|
251
|
+
function_code = "\n".join(lines[start_line:end_line])
|
|
252
|
+
return function_code
|
|
253
|
+
|
|
254
|
+
return json.dumps(
|
|
255
|
+
{"error": f"Function '{function}' not found in {file_path}"}, indent=2
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
return json.dumps(
|
|
259
|
+
{"error": "Function extraction only supported for Python files (.py)"},
|
|
260
|
+
indent=2,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Return full file content
|
|
264
|
+
return content
|
|
265
|
+
|
|
266
|
+
except Exception as e:
|
|
267
|
+
logger.error(f"Error in aurora_context: {e}")
|
|
268
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
269
|
+
|
|
270
|
+
@log_performance("aurora_related")
|
|
271
|
+
def aurora_related(self, chunk_id: str, max_hops: int = 2) -> str:
|
|
272
|
+
"""
|
|
273
|
+
Find related code chunks using ACT-R spreading activation.
|
|
274
|
+
|
|
275
|
+
No API key required. Uses local ACT-R activation engine only.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
chunk_id: Source chunk ID
|
|
279
|
+
max_hops: Maximum relationship hops (default: 2)
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
JSON string with related chunks:
|
|
283
|
+
- chunk_id: Chunk identifier
|
|
284
|
+
- file_path: Path to source file
|
|
285
|
+
- function_name: Function/class name
|
|
286
|
+
- content: Code content
|
|
287
|
+
- activation_score: ACT-R activation score
|
|
288
|
+
- relationship_type: Type of relationship (import, call, etc.)
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
self._ensure_initialized()
|
|
292
|
+
|
|
293
|
+
# Get source chunk
|
|
294
|
+
source_chunk = self._store.get_chunk(chunk_id)
|
|
295
|
+
if source_chunk is None:
|
|
296
|
+
return json.dumps({"error": f"Chunk not found: {chunk_id}"}, indent=2)
|
|
297
|
+
|
|
298
|
+
# Use activation engine to find related chunks
|
|
299
|
+
# For now, we'll use a simple approach: find chunks from related files
|
|
300
|
+
# Future enhancement: implement proper spreading activation
|
|
301
|
+
|
|
302
|
+
related_chunks = []
|
|
303
|
+
|
|
304
|
+
# Get file path from source chunk
|
|
305
|
+
# source_chunk is a Chunk object with file_path attribute for CodeChunks
|
|
306
|
+
if hasattr(source_chunk, "file_path"):
|
|
307
|
+
source_file_path = source_chunk.file_path
|
|
308
|
+
else:
|
|
309
|
+
# Fallback: try to extract from JSON if available
|
|
310
|
+
chunk_json = source_chunk.to_json()
|
|
311
|
+
source_file_path = chunk_json.get("content", {}).get("file", "")
|
|
312
|
+
|
|
313
|
+
# Get chunks from the same file
|
|
314
|
+
with self._store._get_connection() as conn:
|
|
315
|
+
cursor = conn.cursor()
|
|
316
|
+
cursor.execute(
|
|
317
|
+
"""
|
|
318
|
+
SELECT id, type, content, metadata
|
|
319
|
+
FROM chunks
|
|
320
|
+
WHERE type = 'code' AND id != ?
|
|
321
|
+
LIMIT 50
|
|
322
|
+
""",
|
|
323
|
+
(chunk_id,),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
for row in cursor.fetchall():
|
|
327
|
+
chunk_id_rel, chunk_type, content_json, metadata_json = row
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
content_data = json.loads(content_json) if content_json else {}
|
|
331
|
+
json.loads(metadata_json) if metadata_json else {}
|
|
332
|
+
|
|
333
|
+
# Extract file path from content JSON
|
|
334
|
+
file_path = content_data.get("file", "")
|
|
335
|
+
|
|
336
|
+
# Only include chunks from same file or related files
|
|
337
|
+
if file_path == source_file_path or file_path.startswith(
|
|
338
|
+
str(Path(source_file_path).parent)
|
|
339
|
+
):
|
|
340
|
+
# Extract function name
|
|
341
|
+
function_name = content_data.get("function", "")
|
|
342
|
+
|
|
343
|
+
# Build content snippet from stored data
|
|
344
|
+
code_snippet = f"Function: {function_name}"
|
|
345
|
+
if "signature" in content_data:
|
|
346
|
+
code_snippet = content_data["signature"]
|
|
347
|
+
if "docstring" in content_data and content_data["docstring"]:
|
|
348
|
+
code_snippet += f"\n{content_data['docstring'][:200]}"
|
|
349
|
+
|
|
350
|
+
related_chunks.append(
|
|
351
|
+
{
|
|
352
|
+
"chunk_id": chunk_id_rel,
|
|
353
|
+
"file_path": file_path,
|
|
354
|
+
"function_name": function_name,
|
|
355
|
+
"content": code_snippet,
|
|
356
|
+
"activation_score": 0.5
|
|
357
|
+
if file_path == source_file_path
|
|
358
|
+
else 0.3,
|
|
359
|
+
"relationship_type": "same_file"
|
|
360
|
+
if file_path == source_file_path
|
|
361
|
+
else "related_file",
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
# Limit results
|
|
366
|
+
if len(related_chunks) >= 10:
|
|
367
|
+
break
|
|
368
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
369
|
+
logger.warning(f"Failed to parse chunk {chunk_id_rel}: {e}")
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
return json.dumps(related_chunks, indent=2)
|
|
373
|
+
|
|
374
|
+
except Exception as e:
|
|
375
|
+
logger.error(f"Error in aurora_related: {e}")
|
|
376
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
377
|
+
|
|
378
|
+
@log_performance("aurora_query")
|
|
379
|
+
def aurora_query(
|
|
380
|
+
self,
|
|
381
|
+
query: str,
|
|
382
|
+
limit: int = 10,
|
|
383
|
+
type_filter: str | None = None,
|
|
384
|
+
verbose: bool = False,
|
|
385
|
+
) -> str:
|
|
386
|
+
"""
|
|
387
|
+
Retrieve relevant context from AURORA memory without LLM inference.
|
|
388
|
+
|
|
389
|
+
No API key required. Returns structured context WITHOUT running any LLM.
|
|
390
|
+
Claude Code CLI's built-in LLM processes the returned context.
|
|
391
|
+
|
|
392
|
+
This simplified tool provides intelligent context retrieval with complexity
|
|
393
|
+
assessment and confidence scoring. It returns structured context that can
|
|
394
|
+
be used by the LLM client (Claude Code CLI) for further processing.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
query: Natural language query string
|
|
398
|
+
limit: Maximum number of chunks to retrieve (default: 10)
|
|
399
|
+
type_filter: Filter by memory type - "code", "reas", "know", or None (default: None)
|
|
400
|
+
verbose: Include detailed metadata in response (default: False)
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
JSON string with structured context containing:
|
|
404
|
+
- context: Retrieved chunks with metadata
|
|
405
|
+
- assessment: Complexity score, confidence, and suggested approach
|
|
406
|
+
- metadata: Query info, retrieval time, and index statistics
|
|
407
|
+
"""
|
|
408
|
+
try:
|
|
409
|
+
import time
|
|
410
|
+
|
|
411
|
+
start_time = time.time()
|
|
412
|
+
|
|
413
|
+
# Validate parameters
|
|
414
|
+
is_valid, error_msg = self._validate_parameters(query, type_filter)
|
|
415
|
+
if not is_valid:
|
|
416
|
+
# Build suggestion based on error type
|
|
417
|
+
suggestion = "Please check parameter values and try again.\n\nValid values:\n"
|
|
418
|
+
suggestion += "- query: Non-empty string\n"
|
|
419
|
+
suggestion += "- limit: Positive integer\n"
|
|
420
|
+
suggestion += "- type_filter: 'code', 'reas', 'know', or None"
|
|
421
|
+
|
|
422
|
+
return self._format_error(
|
|
423
|
+
error_type="InvalidParameter",
|
|
424
|
+
message=error_msg or "Invalid parameter",
|
|
425
|
+
suggestion=suggestion,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Retrieve chunks using hybrid retrieval
|
|
429
|
+
chunks = self._retrieve_chunks(query, limit=limit, type_filter=type_filter)
|
|
430
|
+
|
|
431
|
+
# Store results in session cache for aurora_get (Task 7.4)
|
|
432
|
+
self._last_search_results = chunks
|
|
433
|
+
self._last_search_timestamp = time.time()
|
|
434
|
+
|
|
435
|
+
# Assess complexity using heuristics
|
|
436
|
+
complexity_score = self._assess_complexity(query)
|
|
437
|
+
|
|
438
|
+
# Calculate retrieval time
|
|
439
|
+
retrieval_time_ms = (time.time() - start_time) * 1000
|
|
440
|
+
|
|
441
|
+
# Build structured response
|
|
442
|
+
response = self._build_context_response(
|
|
443
|
+
chunks=chunks,
|
|
444
|
+
query=query,
|
|
445
|
+
retrieval_time_ms=retrieval_time_ms,
|
|
446
|
+
complexity_score=complexity_score,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
return json.dumps(response, indent=2)
|
|
450
|
+
|
|
451
|
+
except Exception as e:
|
|
452
|
+
logger.error(f"Error in aurora_query: {e}", exc_info=True)
|
|
453
|
+
return self._format_error(
|
|
454
|
+
error_type="UnexpectedError",
|
|
455
|
+
message=f"An unexpected error occurred: {str(e)}",
|
|
456
|
+
suggestion="Please check the logs at ~/.aurora/logs/mcp.log for details.",
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
@log_performance("aurora_get")
|
|
460
|
+
def aurora_get(self, index: int) -> str:
|
|
461
|
+
"""
|
|
462
|
+
Retrieve a full chunk by index from the last search results.
|
|
463
|
+
|
|
464
|
+
No API key required. Retrieves from session cache only.
|
|
465
|
+
|
|
466
|
+
This tool allows you to get the complete content of a specific result
|
|
467
|
+
from your last aurora_search or aurora_query call. Results are numbered
|
|
468
|
+
starting from 1 (1-indexed).
|
|
469
|
+
|
|
470
|
+
Workflow:
|
|
471
|
+
1. Call aurora_search or aurora_query to get numbered results
|
|
472
|
+
2. Review the list and choose which result you want
|
|
473
|
+
3. Call aurora_get(N) to retrieve the full chunk for result N
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
index: 1-indexed position in last search results (must be >= 1)
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
JSON string with full chunk including:
|
|
480
|
+
- chunk: Complete chunk with all metadata
|
|
481
|
+
- metadata: Index position and total count
|
|
482
|
+
|
|
483
|
+
Note:
|
|
484
|
+
- Results are cached for 10 minutes after search
|
|
485
|
+
- Index must be >= 1 and <= total results count
|
|
486
|
+
- Returns error if no previous search or cache expired
|
|
487
|
+
"""
|
|
488
|
+
try:
|
|
489
|
+
import time
|
|
490
|
+
|
|
491
|
+
# Check if there's a previous search (Task 7.10)
|
|
492
|
+
if not self._last_search_results or self._last_search_timestamp is None:
|
|
493
|
+
return self._format_error(
|
|
494
|
+
error_type="NoSearchResults",
|
|
495
|
+
message="No previous search results found. Please run aurora_search or aurora_query first.",
|
|
496
|
+
suggestion="Use aurora_search or aurora_query to search for code, then use aurora_get to retrieve specific results by index.",
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
# Check cache expiry (10 minutes = 600 seconds) (Task 7.6)
|
|
500
|
+
cache_age_seconds = time.time() - self._last_search_timestamp
|
|
501
|
+
if cache_age_seconds > 600:
|
|
502
|
+
return self._format_error(
|
|
503
|
+
error_type="CacheExpired",
|
|
504
|
+
message="Search results cache has expired (older than 10 minutes). Please search again.",
|
|
505
|
+
suggestion="Run aurora_search or aurora_query again to refresh the results cache.",
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Validate index (Task 7.7)
|
|
509
|
+
# Must be >= 1 (1-indexed)
|
|
510
|
+
if index < 1:
|
|
511
|
+
return self._format_error(
|
|
512
|
+
error_type="InvalidParameter",
|
|
513
|
+
message=f"Index must be >= 1 (1-indexed system). Got: {index}",
|
|
514
|
+
suggestion="Use index starting from 1. For example: aurora_get(1) for the first result.",
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Must be <= length of results
|
|
518
|
+
total_results = len(self._last_search_results)
|
|
519
|
+
if index > total_results:
|
|
520
|
+
return self._format_error(
|
|
521
|
+
error_type="InvalidParameter",
|
|
522
|
+
message=f"Index {index} is out of range. Only {total_results} results available (valid range: 1-{total_results}).",
|
|
523
|
+
suggestion=f"Choose an index between 1 and {total_results}.",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Get the chunk (convert 1-indexed to 0-indexed)
|
|
527
|
+
chunk = self._last_search_results[index - 1]
|
|
528
|
+
|
|
529
|
+
# Build response per FR-11.4 (Task 7.8)
|
|
530
|
+
response = {
|
|
531
|
+
"chunk": chunk,
|
|
532
|
+
"metadata": {
|
|
533
|
+
"index": index,
|
|
534
|
+
"total_results": total_results,
|
|
535
|
+
"retrieved_from": "session_cache",
|
|
536
|
+
"cache_age_seconds": round(cache_age_seconds, 1),
|
|
537
|
+
},
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return json.dumps(response, indent=2)
|
|
541
|
+
|
|
542
|
+
except Exception as e:
|
|
543
|
+
logger.error(f"Error in aurora_get: {e}", exc_info=True)
|
|
544
|
+
return self._format_error(
|
|
545
|
+
error_type="UnexpectedError",
|
|
546
|
+
message=f"An unexpected error occurred: {str(e)}",
|
|
547
|
+
suggestion="Please check the logs at ~/.aurora/logs/mcp.log for details.",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# ========================================================================
|
|
551
|
+
# Helper Methods for aurora_query
|
|
552
|
+
# ========================================================================
|
|
553
|
+
|
|
554
|
+
def _validate_parameters(
|
|
555
|
+
self,
|
|
556
|
+
query: str,
|
|
557
|
+
type_filter: str | None,
|
|
558
|
+
) -> tuple[bool, str | None]:
|
|
559
|
+
"""
|
|
560
|
+
Validate aurora_query parameters.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
query: Query string to validate
|
|
564
|
+
type_filter: Type filter ("code", "reas", "know", or None)
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Tuple of (is_valid, error_message)
|
|
568
|
+
"""
|
|
569
|
+
# Check query is non-empty and not whitespace-only
|
|
570
|
+
if not query or not query.strip():
|
|
571
|
+
return False, "Query cannot be empty or whitespace-only"
|
|
572
|
+
|
|
573
|
+
# Check type_filter is valid if provided
|
|
574
|
+
if type_filter is not None:
|
|
575
|
+
valid_types = ["code", "reas", "know"]
|
|
576
|
+
if type_filter not in valid_types:
|
|
577
|
+
return False, f"type_filter must be one of {valid_types}, got '{type_filter}'"
|
|
578
|
+
|
|
579
|
+
return True, None
|
|
580
|
+
|
|
581
|
+
def _load_config(self) -> dict[str, Any]:
|
|
582
|
+
"""
|
|
583
|
+
Load AURORA configuration from ~/.aurora/config.json (Task 1.4).
|
|
584
|
+
|
|
585
|
+
Configuration priority:
|
|
586
|
+
1. Environment variables (highest)
|
|
587
|
+
2. Config file
|
|
588
|
+
3. Hard-coded defaults (lowest)
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Configuration dictionary with all required fields
|
|
592
|
+
"""
|
|
593
|
+
# Return cached config if already loaded
|
|
594
|
+
if hasattr(self, "_config_cache") and self._config_cache is not None:
|
|
595
|
+
return self._config_cache
|
|
596
|
+
|
|
597
|
+
# Default configuration
|
|
598
|
+
config: dict[str, Any] = {
|
|
599
|
+
"api": {
|
|
600
|
+
"default_model": "claude-sonnet-4-20250514",
|
|
601
|
+
"temperature": 0.7,
|
|
602
|
+
"max_tokens": 4000,
|
|
603
|
+
"anthropic_key": None,
|
|
604
|
+
},
|
|
605
|
+
"query": {
|
|
606
|
+
"auto_escalate": True,
|
|
607
|
+
"complexity_threshold": 0.6,
|
|
608
|
+
"verbosity": "normal",
|
|
609
|
+
},
|
|
610
|
+
"budget": {
|
|
611
|
+
"monthly_limit_usd": 50.0,
|
|
612
|
+
},
|
|
613
|
+
"memory": {
|
|
614
|
+
"default_limit": 10,
|
|
615
|
+
},
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
# Try to load from config file
|
|
619
|
+
config_path = Path.home() / ".aurora" / "config.json"
|
|
620
|
+
if config_path.exists():
|
|
621
|
+
try:
|
|
622
|
+
with open(str(config_path)) as f:
|
|
623
|
+
user_config = json.load(f)
|
|
624
|
+
|
|
625
|
+
# Merge user config with defaults (deep merge)
|
|
626
|
+
for section, values in user_config.items():
|
|
627
|
+
if section in config and isinstance(values, dict):
|
|
628
|
+
config[section].update(values)
|
|
629
|
+
else:
|
|
630
|
+
config[section] = values
|
|
631
|
+
|
|
632
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
633
|
+
logger.warning(f"Failed to load config from {config_path}: {e}. Using defaults.")
|
|
634
|
+
|
|
635
|
+
# Override with environment variables
|
|
636
|
+
if os.getenv("AURORA_MODEL"):
|
|
637
|
+
config["api"]["default_model"] = os.getenv("AURORA_MODEL")
|
|
638
|
+
if os.getenv("AURORA_VERBOSITY"):
|
|
639
|
+
config["query"]["verbosity"] = os.getenv("AURORA_VERBOSITY")
|
|
640
|
+
|
|
641
|
+
# Cache config
|
|
642
|
+
self._config_cache = config
|
|
643
|
+
return config
|
|
644
|
+
|
|
645
|
+
def _assess_complexity(self, query: str) -> float:
|
|
646
|
+
"""
|
|
647
|
+
Assess query complexity using keyword-based heuristics.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
query: Query string
|
|
651
|
+
|
|
652
|
+
Returns:
|
|
653
|
+
Complexity score from 0.0 to 1.0
|
|
654
|
+
"""
|
|
655
|
+
query_lower = query.lower()
|
|
656
|
+
|
|
657
|
+
# Simple query indicators (low complexity)
|
|
658
|
+
simple_keywords = ["what is", "define", "explain briefly", "who is", "when did"]
|
|
659
|
+
simple_score = sum(1 for keyword in simple_keywords if keyword in query_lower)
|
|
660
|
+
|
|
661
|
+
# Complex query indicators (high complexity)
|
|
662
|
+
complex_keywords = [
|
|
663
|
+
"complex", # Added for test compatibility
|
|
664
|
+
"compare",
|
|
665
|
+
"analyze",
|
|
666
|
+
"design",
|
|
667
|
+
"architecture",
|
|
668
|
+
"how does",
|
|
669
|
+
"why does",
|
|
670
|
+
"evaluate",
|
|
671
|
+
"implement",
|
|
672
|
+
"multiple",
|
|
673
|
+
"across",
|
|
674
|
+
"identify",
|
|
675
|
+
"suggest",
|
|
676
|
+
"improve",
|
|
677
|
+
]
|
|
678
|
+
complex_score = sum(1 for keyword in complex_keywords if keyword in query_lower)
|
|
679
|
+
|
|
680
|
+
# Calculate complexity (0.0 to 1.0)
|
|
681
|
+
if simple_score > 0 and complex_score == 0:
|
|
682
|
+
return 0.3 # Likely simple
|
|
683
|
+
elif complex_score >= 2:
|
|
684
|
+
return 0.7 # Likely complex (2+ complex keywords)
|
|
685
|
+
elif len(query.split()) > 20:
|
|
686
|
+
return 0.6 # Long query = moderate complexity
|
|
687
|
+
else:
|
|
688
|
+
return 0.5 # Default: medium complexity
|
|
689
|
+
|
|
690
|
+
def _retrieve_chunks(
|
|
691
|
+
self, query: str, limit: int = 10, type_filter: str | None = None
|
|
692
|
+
) -> list[dict[str, Any]]:
|
|
693
|
+
"""
|
|
694
|
+
Retrieve chunks using HybridRetriever with full metadata.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
query: Query string
|
|
698
|
+
limit: Maximum number of chunks to retrieve
|
|
699
|
+
type_filter: Filter by memory type (code, reas, know, or None)
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
List of chunk dictionaries with full metadata including:
|
|
703
|
+
- chunk_id: Unique identifier
|
|
704
|
+
- type: Memory type (code, reas, know)
|
|
705
|
+
- content: Chunk content
|
|
706
|
+
- file_path: Path to source file
|
|
707
|
+
- line_range: [start, end] line numbers
|
|
708
|
+
- relevance_score: Hybrid score (0.0-1.0)
|
|
709
|
+
- name: Function/class name (if applicable)
|
|
710
|
+
"""
|
|
711
|
+
try:
|
|
712
|
+
self._ensure_initialized()
|
|
713
|
+
|
|
714
|
+
# Use HybridRetriever to get chunks with relevance scores
|
|
715
|
+
results = self._retriever.retrieve(query, top_k=limit)
|
|
716
|
+
|
|
717
|
+
# Format results with full metadata
|
|
718
|
+
formatted_chunks = []
|
|
719
|
+
for result in results:
|
|
720
|
+
metadata = result.get("metadata", {})
|
|
721
|
+
chunk_type = metadata.get("type", "unknown")
|
|
722
|
+
|
|
723
|
+
# Apply type filter if specified
|
|
724
|
+
if type_filter and chunk_type != type_filter:
|
|
725
|
+
continue
|
|
726
|
+
|
|
727
|
+
# Extract line range from metadata or chunk
|
|
728
|
+
line_range = [0, 0]
|
|
729
|
+
if "line_range" in metadata:
|
|
730
|
+
line_range = metadata.get("line_range", [0, 0])
|
|
731
|
+
|
|
732
|
+
formatted_chunks.append(
|
|
733
|
+
{
|
|
734
|
+
"chunk_id": result.get("chunk_id", ""),
|
|
735
|
+
"type": chunk_type,
|
|
736
|
+
"content": result.get("content", ""),
|
|
737
|
+
"file_path": metadata.get("file_path", ""),
|
|
738
|
+
"line_range": line_range,
|
|
739
|
+
"relevance_score": float(result.get("hybrid_score", 0.0)),
|
|
740
|
+
"name": metadata.get("name", ""),
|
|
741
|
+
}
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
return formatted_chunks
|
|
745
|
+
|
|
746
|
+
except Exception as e:
|
|
747
|
+
logger.warning(f"Failed to retrieve chunks: {e}")
|
|
748
|
+
return []
|
|
749
|
+
|
|
750
|
+
def _calculate_retrieval_confidence(self, chunks: list[dict[str, Any]]) -> float:
|
|
751
|
+
"""
|
|
752
|
+
Calculate confidence score for retrieved chunks.
|
|
753
|
+
|
|
754
|
+
Confidence is based on:
|
|
755
|
+
- Top result score (main factor)
|
|
756
|
+
- Number of results found
|
|
757
|
+
- Score distribution
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
chunks: List of retrieved chunks with relevance_score field
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
Confidence score from 0.0 to 1.0
|
|
764
|
+
"""
|
|
765
|
+
if not chunks:
|
|
766
|
+
return 0.0
|
|
767
|
+
|
|
768
|
+
# Get relevance scores
|
|
769
|
+
scores = [chunk.get("relevance_score", 0.0) for chunk in chunks]
|
|
770
|
+
|
|
771
|
+
# Top score is the main factor (70% weight)
|
|
772
|
+
top_score = max(scores) if scores else 0.0
|
|
773
|
+
|
|
774
|
+
# Result count factor (20% weight)
|
|
775
|
+
# More results = higher confidence (up to 5 results)
|
|
776
|
+
count_factor = min(len(chunks) / 5.0, 1.0)
|
|
777
|
+
|
|
778
|
+
# Score distribution factor (10% weight)
|
|
779
|
+
# Consistent high scores = higher confidence
|
|
780
|
+
if len(scores) > 1:
|
|
781
|
+
avg_score = sum(scores) / len(scores)
|
|
782
|
+
distribution_factor = avg_score / top_score if top_score > 0 else 0.0
|
|
783
|
+
else:
|
|
784
|
+
distribution_factor = 1.0
|
|
785
|
+
|
|
786
|
+
# Calculate weighted confidence
|
|
787
|
+
confidence = 0.7 * top_score + 0.2 * count_factor + 0.1 * distribution_factor
|
|
788
|
+
|
|
789
|
+
# Clamp to [0.0, 1.0]
|
|
790
|
+
return max(0.0, min(1.0, confidence))
|
|
791
|
+
|
|
792
|
+
def _build_context_response(
|
|
793
|
+
self,
|
|
794
|
+
chunks: list[dict[str, Any]],
|
|
795
|
+
query: str,
|
|
796
|
+
retrieval_time_ms: float,
|
|
797
|
+
complexity_score: float,
|
|
798
|
+
) -> dict[str, Any]:
|
|
799
|
+
"""
|
|
800
|
+
Build structured context response per FR-2.2 schema.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
chunks: Retrieved chunks with metadata
|
|
804
|
+
query: Original query string
|
|
805
|
+
retrieval_time_ms: Time taken for retrieval in milliseconds
|
|
806
|
+
complexity_score: Heuristic complexity assessment (0.0-1.0)
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
Response dictionary with context, assessment, and metadata sections
|
|
810
|
+
"""
|
|
811
|
+
# Calculate retrieval confidence
|
|
812
|
+
confidence = self._calculate_retrieval_confidence(chunks)
|
|
813
|
+
|
|
814
|
+
# Determine suggested approach based on complexity
|
|
815
|
+
if complexity_score < 0.5:
|
|
816
|
+
suggested_approach = "simple"
|
|
817
|
+
elif complexity_score < 0.65:
|
|
818
|
+
suggested_approach = "direct"
|
|
819
|
+
else:
|
|
820
|
+
suggested_approach = "complex"
|
|
821
|
+
|
|
822
|
+
# Build numbered chunks list
|
|
823
|
+
numbered_chunks = []
|
|
824
|
+
for idx, chunk in enumerate(chunks, start=1):
|
|
825
|
+
numbered_chunks.append(
|
|
826
|
+
{
|
|
827
|
+
"id": chunk.get("chunk_id", ""),
|
|
828
|
+
"number": idx,
|
|
829
|
+
"type": chunk.get("type", "unknown"),
|
|
830
|
+
"content": chunk.get("content", ""),
|
|
831
|
+
"file_path": chunk.get("file_path", ""),
|
|
832
|
+
"line_range": chunk.get("line_range", [0, 0]),
|
|
833
|
+
"relevance_score": round(chunk.get("relevance_score", 0.0), 3),
|
|
834
|
+
}
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
# Get index stats
|
|
838
|
+
try:
|
|
839
|
+
self._ensure_initialized()
|
|
840
|
+
with self._store._get_connection() as conn:
|
|
841
|
+
cursor = conn.cursor()
|
|
842
|
+
cursor.execute("SELECT COUNT(*) FROM chunks")
|
|
843
|
+
total_chunks = cursor.fetchone()[0]
|
|
844
|
+
|
|
845
|
+
# Count by type
|
|
846
|
+
cursor.execute("SELECT type, COUNT(*) FROM chunks GROUP BY type")
|
|
847
|
+
types_breakdown = {row[0]: row[1] for row in cursor.fetchall()}
|
|
848
|
+
except Exception as e:
|
|
849
|
+
logger.warning(f"Failed to get index stats: {e}")
|
|
850
|
+
total_chunks = 0
|
|
851
|
+
types_breakdown = {}
|
|
852
|
+
|
|
853
|
+
# Build response structure
|
|
854
|
+
response: dict[str, Any] = {
|
|
855
|
+
"context": {
|
|
856
|
+
"chunks": numbered_chunks,
|
|
857
|
+
"total_found": len(chunks),
|
|
858
|
+
"returned": len(chunks),
|
|
859
|
+
},
|
|
860
|
+
"assessment": {
|
|
861
|
+
"complexity_score": round(complexity_score, 2),
|
|
862
|
+
"suggested_approach": suggested_approach,
|
|
863
|
+
"retrieval_confidence": round(confidence, 2),
|
|
864
|
+
},
|
|
865
|
+
"metadata": {
|
|
866
|
+
"query": query,
|
|
867
|
+
"retrieval_time_ms": round(retrieval_time_ms, 1),
|
|
868
|
+
"index_stats": {
|
|
869
|
+
"total_chunks": total_chunks,
|
|
870
|
+
"types": types_breakdown,
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
# Add suggestion if confidence is low
|
|
876
|
+
if confidence < 0.5:
|
|
877
|
+
response["assessment"]["suggestion"] = (
|
|
878
|
+
"Low confidence results. Consider refining your query or indexing more code."
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
return response
|
|
882
|
+
|
|
883
|
+
def _get_memory_context(self, query: str, limit: int = 3) -> str:
|
|
884
|
+
"""
|
|
885
|
+
Get memory context for query (graceful degradation).
|
|
886
|
+
|
|
887
|
+
Args:
|
|
888
|
+
query: Query string
|
|
889
|
+
limit: Maximum number of chunks to retrieve
|
|
890
|
+
|
|
891
|
+
Returns:
|
|
892
|
+
Memory context string (empty if unavailable)
|
|
893
|
+
"""
|
|
894
|
+
try:
|
|
895
|
+
# Try to retrieve from memory
|
|
896
|
+
# For now, return empty (will implement when memory integration is ready)
|
|
897
|
+
return ""
|
|
898
|
+
except Exception as e:
|
|
899
|
+
logger.warning(f"Memory store not available. Answering from base knowledge: {e}")
|
|
900
|
+
return ""
|
|
901
|
+
|
|
902
|
+
def _format_response(self, result: dict[str, Any], verbose: bool) -> str:
|
|
903
|
+
"""
|
|
904
|
+
Format query result as JSON response.
|
|
905
|
+
|
|
906
|
+
Args:
|
|
907
|
+
result: Result dictionary from execution
|
|
908
|
+
verbose: Whether to include verbose details
|
|
909
|
+
|
|
910
|
+
Returns:
|
|
911
|
+
JSON string
|
|
912
|
+
"""
|
|
913
|
+
response: dict[str, Any] = {
|
|
914
|
+
"answer": result.get("answer", ""),
|
|
915
|
+
"execution_path": result.get("execution_path", "unknown"),
|
|
916
|
+
"metadata": self._extract_metadata(result),
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
# Add phases if verbose and SOAR was used
|
|
920
|
+
if verbose and result.get("execution_path") == "soar_pipeline":
|
|
921
|
+
if "phase_trace" in result:
|
|
922
|
+
response["phases"] = result["phase_trace"]["phases"]
|
|
923
|
+
|
|
924
|
+
# Add sources if present
|
|
925
|
+
if "sources" in result:
|
|
926
|
+
response["sources"] = result["sources"]
|
|
927
|
+
|
|
928
|
+
return json.dumps(response, indent=2)
|
|
929
|
+
|
|
930
|
+
def _extract_metadata(self, result: dict[str, Any]) -> dict[str, Any]:
|
|
931
|
+
"""
|
|
932
|
+
Extract metadata from result dictionary.
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
result: Result dictionary
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
Metadata dictionary
|
|
939
|
+
"""
|
|
940
|
+
return {
|
|
941
|
+
"duration_seconds": round(result.get("duration", 0.0), 2),
|
|
942
|
+
"cost_usd": round(result.get("cost", 0.0), 2),
|
|
943
|
+
"input_tokens": result.get("input_tokens", 0),
|
|
944
|
+
"output_tokens": result.get("output_tokens", 0),
|
|
945
|
+
"model": result.get("model", "unknown"),
|
|
946
|
+
"temperature": result.get("temperature", 0.7),
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
def _format_error(
|
|
950
|
+
self,
|
|
951
|
+
error_type: str,
|
|
952
|
+
message: str,
|
|
953
|
+
suggestion: str,
|
|
954
|
+
details: dict[str, Any] | None = None,
|
|
955
|
+
) -> str:
|
|
956
|
+
"""
|
|
957
|
+
Format error message as JSON.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
error_type: Error type identifier
|
|
961
|
+
message: Error message
|
|
962
|
+
suggestion: Suggestion for fixing the error
|
|
963
|
+
details: Optional additional details
|
|
964
|
+
|
|
965
|
+
Returns:
|
|
966
|
+
JSON string with error structure
|
|
967
|
+
"""
|
|
968
|
+
# Log error before returning
|
|
969
|
+
logger.error(f"{error_type}: {message}")
|
|
970
|
+
|
|
971
|
+
error_dict: dict[str, Any] = {
|
|
972
|
+
"error": {
|
|
973
|
+
"type": error_type,
|
|
974
|
+
"message": message,
|
|
975
|
+
"suggestion": suggestion,
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if details:
|
|
980
|
+
error_dict["error"]["details"] = details
|
|
981
|
+
|
|
982
|
+
return json.dumps(error_dict, indent=2)
|
|
983
|
+
|
|
984
|
+
# ========================================================================
|
|
985
|
+
# Agent Discovery Tools
|
|
986
|
+
# ========================================================================
|
|
987
|
+
|
|
988
|
+
@log_performance("aurora_list_agents")
|
|
989
|
+
def aurora_list_agents(self) -> str:
|
|
990
|
+
"""
|
|
991
|
+
List all discovered agents from configured sources.
|
|
992
|
+
|
|
993
|
+
No API key required. Returns local agent directory listing only.
|
|
994
|
+
|
|
995
|
+
Returns:
|
|
996
|
+
JSON string with array of agents containing:
|
|
997
|
+
- id: Agent identifier (kebab-case)
|
|
998
|
+
- title: Agent role/title
|
|
999
|
+
- source_path: Path to agent markdown file
|
|
1000
|
+
- when_to_use: Guidance on when to invoke this agent
|
|
1001
|
+
"""
|
|
1002
|
+
try:
|
|
1003
|
+
# Get manifest cache path
|
|
1004
|
+
manifest_path = Path.home() / ".aurora" / "agent_manifest.json"
|
|
1005
|
+
|
|
1006
|
+
# Initialize ManifestManager and get/refresh manifest
|
|
1007
|
+
manifest_manager = ManifestManager()
|
|
1008
|
+
manifest = manifest_manager.get_or_refresh(
|
|
1009
|
+
path=manifest_path,
|
|
1010
|
+
auto_refresh=True,
|
|
1011
|
+
refresh_interval_hours=24 * 365 / 12 / 60, # 5 minutes as per PRD
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
# Format agents for MCP response
|
|
1015
|
+
agents_list = []
|
|
1016
|
+
for agent in manifest.agents:
|
|
1017
|
+
agents_list.append(
|
|
1018
|
+
{
|
|
1019
|
+
"id": agent.id,
|
|
1020
|
+
"title": agent.role,
|
|
1021
|
+
"source_path": agent.source_file or "",
|
|
1022
|
+
"when_to_use": agent.when_to_use or "",
|
|
1023
|
+
}
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
return json.dumps(agents_list, indent=2)
|
|
1027
|
+
|
|
1028
|
+
except Exception as e:
|
|
1029
|
+
logger.error(f"Error in aurora_list_agents: {e}")
|
|
1030
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
1031
|
+
|
|
1032
|
+
@log_performance("aurora_search_agents")
|
|
1033
|
+
def aurora_search_agents(self, query: str) -> str:
|
|
1034
|
+
"""
|
|
1035
|
+
Search agents by keyword with relevance scoring.
|
|
1036
|
+
|
|
1037
|
+
No API key required. Local substring-based search only.
|
|
1038
|
+
|
|
1039
|
+
Uses substring matching to search agent id, title, and when_to_use
|
|
1040
|
+
fields, returning results sorted by relevance score (0.0-1.0).
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
query: Search query string (required, non-empty)
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
JSON string with array of matching agents containing:
|
|
1047
|
+
- id: Agent identifier
|
|
1048
|
+
- title: Agent role/title
|
|
1049
|
+
- source_path: Path to agent markdown file
|
|
1050
|
+
- when_to_use: When to use guidance
|
|
1051
|
+
- relevance_score: Match score from 0.0 to 1.0
|
|
1052
|
+
"""
|
|
1053
|
+
try:
|
|
1054
|
+
# Validate query
|
|
1055
|
+
if not query or not query.strip():
|
|
1056
|
+
return json.dumps({"error": "Query cannot be empty or whitespace-only"}, indent=2)
|
|
1057
|
+
|
|
1058
|
+
query_lower = query.strip().lower()
|
|
1059
|
+
|
|
1060
|
+
# Get manifest
|
|
1061
|
+
manifest_path = Path.home() / ".aurora" / "agent_manifest.json"
|
|
1062
|
+
manifest_manager = ManifestManager()
|
|
1063
|
+
manifest = manifest_manager.get_or_refresh(
|
|
1064
|
+
path=manifest_path,
|
|
1065
|
+
auto_refresh=True,
|
|
1066
|
+
refresh_interval_hours=24 * 365 / 12 / 60, # 5 minutes
|
|
1067
|
+
)
|
|
1068
|
+
|
|
1069
|
+
# Search agents with substring-based relevance scoring
|
|
1070
|
+
results = []
|
|
1071
|
+
for agent in manifest.agents:
|
|
1072
|
+
# Calculate relevance score based on substring matches
|
|
1073
|
+
score = 0.0
|
|
1074
|
+
|
|
1075
|
+
# Check id (highest weight: 0.5)
|
|
1076
|
+
if query_lower in agent.id.lower():
|
|
1077
|
+
score += 0.5
|
|
1078
|
+
|
|
1079
|
+
# Check title/role (medium weight: 0.3)
|
|
1080
|
+
if query_lower in agent.role.lower():
|
|
1081
|
+
score += 0.3
|
|
1082
|
+
|
|
1083
|
+
# Check when_to_use (lower weight: 0.2)
|
|
1084
|
+
if agent.when_to_use and query_lower in agent.when_to_use.lower():
|
|
1085
|
+
score += 0.2
|
|
1086
|
+
|
|
1087
|
+
# Only include if there's a match
|
|
1088
|
+
if score > 0.0:
|
|
1089
|
+
results.append(
|
|
1090
|
+
{
|
|
1091
|
+
"id": agent.id,
|
|
1092
|
+
"title": agent.role,
|
|
1093
|
+
"source_path": agent.source_file or "",
|
|
1094
|
+
"when_to_use": agent.when_to_use or "",
|
|
1095
|
+
"relevance_score": min(score, 1.0), # Cap at 1.0
|
|
1096
|
+
}
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
# Sort by relevance score descending
|
|
1100
|
+
results.sort(key=lambda x: x["relevance_score"], reverse=True)
|
|
1101
|
+
|
|
1102
|
+
return json.dumps(results, indent=2)
|
|
1103
|
+
|
|
1104
|
+
except Exception as e:
|
|
1105
|
+
logger.error(f"Error in aurora_search_agents: {e}")
|
|
1106
|
+
return json.dumps({"error": str(e)}, indent=2)
|
|
1107
|
+
|
|
1108
|
+
@log_performance("aurora_show_agent")
|
|
1109
|
+
def aurora_show_agent(self, agent_id: str) -> str:
|
|
1110
|
+
"""
|
|
1111
|
+
Show full agent details including complete markdown content.
|
|
1112
|
+
|
|
1113
|
+
No API key required. Reads local agent markdown file only.
|
|
1114
|
+
|
|
1115
|
+
Args:
|
|
1116
|
+
agent_id: Agent identifier (required, non-empty)
|
|
1117
|
+
|
|
1118
|
+
Returns:
|
|
1119
|
+
JSON string with full agent details:
|
|
1120
|
+
- id: Agent identifier
|
|
1121
|
+
- title: Agent role/title
|
|
1122
|
+
- source_path: Path to agent markdown file
|
|
1123
|
+
- when_to_use: When to use guidance
|
|
1124
|
+
- content: Complete markdown file content
|
|
1125
|
+
|
|
1126
|
+
Or error JSON if agent not found:
|
|
1127
|
+
- error: "Agent not found"
|
|
1128
|
+
- agent_id: The requested agent ID
|
|
1129
|
+
"""
|
|
1130
|
+
try:
|
|
1131
|
+
# Validate agent_id
|
|
1132
|
+
if not agent_id or not agent_id.strip():
|
|
1133
|
+
return json.dumps({"error": "agent_id cannot be empty or whitespace-only"}, indent=2)
|
|
1134
|
+
|
|
1135
|
+
agent_id_clean = agent_id.strip()
|
|
1136
|
+
|
|
1137
|
+
# Get manifest
|
|
1138
|
+
manifest_path = Path.home() / ".aurora" / "agent_manifest.json"
|
|
1139
|
+
manifest_manager = ManifestManager()
|
|
1140
|
+
manifest = manifest_manager.get_or_refresh(
|
|
1141
|
+
path=manifest_path,
|
|
1142
|
+
auto_refresh=True,
|
|
1143
|
+
refresh_interval_hours=24 * 365 / 12 / 60, # 5 minutes
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
# Find agent by ID
|
|
1147
|
+
agent = manifest.get_agent(agent_id_clean)
|
|
1148
|
+
if agent is None:
|
|
1149
|
+
return json.dumps(
|
|
1150
|
+
{"error": "Agent not found", "agent_id": agent_id_clean},
|
|
1151
|
+
indent=2,
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
# Read full markdown content from source file
|
|
1155
|
+
if not agent.source_file:
|
|
1156
|
+
return json.dumps(
|
|
1157
|
+
{"error": "Agent has no source file", "agent_id": agent_id_clean},
|
|
1158
|
+
indent=2,
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
source_path = Path(agent.source_file)
|
|
1162
|
+
if not source_path.exists():
|
|
1163
|
+
return json.dumps(
|
|
1164
|
+
{
|
|
1165
|
+
"error": "Agent source file not found",
|
|
1166
|
+
"agent_id": agent_id_clean,
|
|
1167
|
+
"source_path": str(source_path),
|
|
1168
|
+
},
|
|
1169
|
+
indent=2,
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
# Read complete markdown content
|
|
1173
|
+
try:
|
|
1174
|
+
content = source_path.read_text(encoding="utf-8")
|
|
1175
|
+
except Exception as read_error:
|
|
1176
|
+
return json.dumps(
|
|
1177
|
+
{
|
|
1178
|
+
"error": f"Failed to read agent file: {read_error}",
|
|
1179
|
+
"agent_id": agent_id_clean,
|
|
1180
|
+
},
|
|
1181
|
+
indent=2,
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
# Return full agent details with content
|
|
1185
|
+
return json.dumps(
|
|
1186
|
+
{
|
|
1187
|
+
"id": agent.id,
|
|
1188
|
+
"title": agent.role,
|
|
1189
|
+
"source_path": agent.source_file,
|
|
1190
|
+
"when_to_use": agent.when_to_use or "",
|
|
1191
|
+
"content": content,
|
|
1192
|
+
},
|
|
1193
|
+
indent=2,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
except Exception as e:
|
|
1197
|
+
logger.error(f"Error in aurora_show_agent: {e}")
|
|
1198
|
+
return json.dumps({"error": str(e)}, indent=2)
|