remdb 0.3.242__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.
Potentially problematic release.
This version of remdb might be problematic. Click here for more details.
- rem/__init__.py +129 -0
- rem/agentic/README.md +760 -0
- rem/agentic/__init__.py +54 -0
- rem/agentic/agents/README.md +155 -0
- rem/agentic/agents/__init__.py +38 -0
- rem/agentic/agents/agent_manager.py +311 -0
- rem/agentic/agents/sse_simulator.py +502 -0
- rem/agentic/context.py +425 -0
- rem/agentic/context_builder.py +360 -0
- rem/agentic/llm_provider_models.py +301 -0
- rem/agentic/mcp/__init__.py +0 -0
- rem/agentic/mcp/tool_wrapper.py +273 -0
- rem/agentic/otel/__init__.py +5 -0
- rem/agentic/otel/setup.py +240 -0
- rem/agentic/providers/phoenix.py +926 -0
- rem/agentic/providers/pydantic_ai.py +854 -0
- rem/agentic/query.py +117 -0
- rem/agentic/query_helper.py +89 -0
- rem/agentic/schema.py +737 -0
- rem/agentic/serialization.py +245 -0
- rem/agentic/tools/__init__.py +5 -0
- rem/agentic/tools/rem_tools.py +242 -0
- rem/api/README.md +657 -0
- rem/api/deps.py +253 -0
- rem/api/main.py +460 -0
- rem/api/mcp_router/prompts.py +182 -0
- rem/api/mcp_router/resources.py +820 -0
- rem/api/mcp_router/server.py +243 -0
- rem/api/mcp_router/tools.py +1605 -0
- rem/api/middleware/tracking.py +172 -0
- rem/api/routers/admin.py +520 -0
- rem/api/routers/auth.py +898 -0
- rem/api/routers/chat/__init__.py +5 -0
- rem/api/routers/chat/child_streaming.py +394 -0
- rem/api/routers/chat/completions.py +702 -0
- rem/api/routers/chat/json_utils.py +76 -0
- rem/api/routers/chat/models.py +202 -0
- rem/api/routers/chat/otel_utils.py +33 -0
- rem/api/routers/chat/sse_events.py +546 -0
- rem/api/routers/chat/streaming.py +950 -0
- rem/api/routers/chat/streaming_utils.py +327 -0
- rem/api/routers/common.py +18 -0
- rem/api/routers/dev.py +87 -0
- rem/api/routers/feedback.py +276 -0
- rem/api/routers/messages.py +620 -0
- rem/api/routers/models.py +86 -0
- rem/api/routers/query.py +362 -0
- rem/api/routers/shared_sessions.py +422 -0
- rem/auth/README.md +258 -0
- rem/auth/__init__.py +36 -0
- rem/auth/jwt.py +367 -0
- rem/auth/middleware.py +318 -0
- rem/auth/providers/__init__.py +16 -0
- rem/auth/providers/base.py +376 -0
- rem/auth/providers/email.py +215 -0
- rem/auth/providers/google.py +163 -0
- rem/auth/providers/microsoft.py +237 -0
- rem/cli/README.md +517 -0
- rem/cli/__init__.py +8 -0
- rem/cli/commands/README.md +299 -0
- rem/cli/commands/__init__.py +3 -0
- rem/cli/commands/ask.py +549 -0
- rem/cli/commands/cluster.py +1808 -0
- rem/cli/commands/configure.py +495 -0
- rem/cli/commands/db.py +828 -0
- rem/cli/commands/dreaming.py +324 -0
- rem/cli/commands/experiments.py +1698 -0
- rem/cli/commands/mcp.py +66 -0
- rem/cli/commands/process.py +388 -0
- rem/cli/commands/query.py +109 -0
- rem/cli/commands/scaffold.py +47 -0
- rem/cli/commands/schema.py +230 -0
- rem/cli/commands/serve.py +106 -0
- rem/cli/commands/session.py +453 -0
- rem/cli/dreaming.py +363 -0
- rem/cli/main.py +123 -0
- rem/config.py +244 -0
- rem/mcp_server.py +41 -0
- rem/models/core/__init__.py +49 -0
- rem/models/core/core_model.py +70 -0
- rem/models/core/engram.py +333 -0
- rem/models/core/experiment.py +672 -0
- rem/models/core/inline_edge.py +132 -0
- rem/models/core/rem_query.py +246 -0
- rem/models/entities/__init__.py +68 -0
- rem/models/entities/domain_resource.py +38 -0
- rem/models/entities/feedback.py +123 -0
- rem/models/entities/file.py +57 -0
- rem/models/entities/image_resource.py +88 -0
- rem/models/entities/message.py +64 -0
- rem/models/entities/moment.py +123 -0
- rem/models/entities/ontology.py +181 -0
- rem/models/entities/ontology_config.py +131 -0
- rem/models/entities/resource.py +95 -0
- rem/models/entities/schema.py +87 -0
- rem/models/entities/session.py +84 -0
- rem/models/entities/shared_session.py +180 -0
- rem/models/entities/subscriber.py +175 -0
- rem/models/entities/user.py +93 -0
- rem/py.typed +0 -0
- rem/registry.py +373 -0
- rem/schemas/README.md +507 -0
- rem/schemas/__init__.py +6 -0
- rem/schemas/agents/README.md +92 -0
- rem/schemas/agents/core/agent-builder.yaml +235 -0
- rem/schemas/agents/core/moment-builder.yaml +178 -0
- rem/schemas/agents/core/rem-query-agent.yaml +226 -0
- rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
- rem/schemas/agents/core/simple-assistant.yaml +19 -0
- rem/schemas/agents/core/user-profile-builder.yaml +163 -0
- rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
- rem/schemas/agents/examples/contract-extractor.yaml +134 -0
- rem/schemas/agents/examples/cv-parser.yaml +263 -0
- rem/schemas/agents/examples/hello-world.yaml +37 -0
- rem/schemas/agents/examples/query.yaml +54 -0
- rem/schemas/agents/examples/simple.yaml +21 -0
- rem/schemas/agents/examples/test.yaml +29 -0
- rem/schemas/agents/rem.yaml +132 -0
- rem/schemas/evaluators/hello-world/default.yaml +77 -0
- rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
- rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
- rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
- rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
- rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
- rem/services/__init__.py +18 -0
- rem/services/audio/INTEGRATION.md +308 -0
- rem/services/audio/README.md +376 -0
- rem/services/audio/__init__.py +15 -0
- rem/services/audio/chunker.py +354 -0
- rem/services/audio/transcriber.py +259 -0
- rem/services/content/README.md +1269 -0
- rem/services/content/__init__.py +5 -0
- rem/services/content/providers.py +760 -0
- rem/services/content/service.py +762 -0
- rem/services/dreaming/README.md +230 -0
- rem/services/dreaming/__init__.py +53 -0
- rem/services/dreaming/affinity_service.py +322 -0
- rem/services/dreaming/moment_service.py +251 -0
- rem/services/dreaming/ontology_service.py +54 -0
- rem/services/dreaming/user_model_service.py +297 -0
- rem/services/dreaming/utils.py +39 -0
- rem/services/email/__init__.py +10 -0
- rem/services/email/service.py +522 -0
- rem/services/email/templates.py +360 -0
- rem/services/embeddings/__init__.py +11 -0
- rem/services/embeddings/api.py +127 -0
- rem/services/embeddings/worker.py +435 -0
- rem/services/fs/README.md +662 -0
- rem/services/fs/__init__.py +62 -0
- rem/services/fs/examples.py +206 -0
- rem/services/fs/examples_paths.py +204 -0
- rem/services/fs/git_provider.py +935 -0
- rem/services/fs/local_provider.py +760 -0
- rem/services/fs/parsing-hooks-examples.md +172 -0
- rem/services/fs/paths.py +276 -0
- rem/services/fs/provider.py +460 -0
- rem/services/fs/s3_provider.py +1042 -0
- rem/services/fs/service.py +186 -0
- rem/services/git/README.md +1075 -0
- rem/services/git/__init__.py +17 -0
- rem/services/git/service.py +469 -0
- rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
- rem/services/phoenix/README.md +453 -0
- rem/services/phoenix/__init__.py +46 -0
- rem/services/phoenix/client.py +960 -0
- rem/services/phoenix/config.py +88 -0
- rem/services/phoenix/prompt_labels.py +477 -0
- rem/services/postgres/README.md +757 -0
- rem/services/postgres/__init__.py +49 -0
- rem/services/postgres/diff_service.py +599 -0
- rem/services/postgres/migration_service.py +427 -0
- rem/services/postgres/programmable_diff_service.py +635 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
- rem/services/postgres/register_type.py +353 -0
- rem/services/postgres/repository.py +481 -0
- rem/services/postgres/schema_generator.py +661 -0
- rem/services/postgres/service.py +802 -0
- rem/services/postgres/sql_builder.py +355 -0
- rem/services/rate_limit.py +113 -0
- rem/services/rem/README.md +318 -0
- rem/services/rem/__init__.py +23 -0
- rem/services/rem/exceptions.py +71 -0
- rem/services/rem/executor.py +293 -0
- rem/services/rem/parser.py +180 -0
- rem/services/rem/queries.py +196 -0
- rem/services/rem/query.py +371 -0
- rem/services/rem/service.py +608 -0
- rem/services/session/README.md +374 -0
- rem/services/session/__init__.py +13 -0
- rem/services/session/compression.py +488 -0
- rem/services/session/pydantic_messages.py +310 -0
- rem/services/session/reload.py +85 -0
- rem/services/user_service.py +130 -0
- rem/settings.py +1877 -0
- rem/sql/background_indexes.sql +52 -0
- rem/sql/migrations/001_install.sql +983 -0
- rem/sql/migrations/002_install_models.sql +3157 -0
- rem/sql/migrations/003_optional_extensions.sql +326 -0
- rem/sql/migrations/004_cache_system.sql +282 -0
- rem/sql/migrations/005_schema_update.sql +145 -0
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/AGENTIC_CHUNKING.md +597 -0
- rem/utils/README.md +628 -0
- rem/utils/__init__.py +61 -0
- rem/utils/agentic_chunking.py +622 -0
- rem/utils/batch_ops.py +343 -0
- rem/utils/chunking.py +108 -0
- rem/utils/clip_embeddings.py +276 -0
- rem/utils/constants.py +97 -0
- rem/utils/date_utils.py +228 -0
- rem/utils/dict_utils.py +98 -0
- rem/utils/embeddings.py +436 -0
- rem/utils/examples/embeddings_example.py +305 -0
- rem/utils/examples/sql_types_example.py +202 -0
- rem/utils/files.py +323 -0
- rem/utils/markdown.py +16 -0
- rem/utils/mime_types.py +158 -0
- rem/utils/model_helpers.py +492 -0
- rem/utils/schema_loader.py +649 -0
- rem/utils/sql_paths.py +146 -0
- rem/utils/sql_types.py +350 -0
- rem/utils/user_id.py +81 -0
- rem/utils/vision.py +325 -0
- rem/workers/README.md +506 -0
- rem/workers/__init__.py +7 -0
- rem/workers/db_listener.py +579 -0
- rem/workers/db_maintainer.py +74 -0
- rem/workers/dreaming.py +502 -0
- rem/workers/engram_processor.py +312 -0
- rem/workers/sqs_file_processor.py +193 -0
- rem/workers/unlogged_maintainer.py +463 -0
- remdb-0.3.242.dist-info/METADATA +1632 -0
- remdb-0.3.242.dist-info/RECORD +235 -0
- remdb-0.3.242.dist-info/WHEEL +4 -0
- remdb-0.3.242.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1605 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Tools for REM operations.
|
|
3
|
+
|
|
4
|
+
Tools are functions that LLMs can call to interact with the REM system.
|
|
5
|
+
Each tool is decorated with @mcp.tool() and registered with the FastMCP server.
|
|
6
|
+
|
|
7
|
+
Design Pattern:
|
|
8
|
+
- Tools receive parameters from LLM
|
|
9
|
+
- Tools delegate to RemService or ContentService
|
|
10
|
+
- Tools return structured results
|
|
11
|
+
- Tools handle errors gracefully with informative messages
|
|
12
|
+
|
|
13
|
+
Available Tools:
|
|
14
|
+
- search_rem: Execute REM queries (LOOKUP, FUZZY, SEARCH, SQL, TRAVERSE)
|
|
15
|
+
- ask_rem_agent: Natural language to REM query conversion via agent
|
|
16
|
+
- ingest_into_rem: Full file ingestion pipeline (read + store + parse + chunk)
|
|
17
|
+
- read_resource: Access MCP resources (for Claude Desktop compatibility)
|
|
18
|
+
- register_metadata: Register response metadata for SSE MetadataEvent
|
|
19
|
+
- list_schema: List all schemas (tables, agents) in the database with row counts
|
|
20
|
+
- get_schema: Get detailed schema for a table (columns, types, indexes)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from functools import wraps
|
|
25
|
+
from typing import Any, Callable, Literal, cast
|
|
26
|
+
|
|
27
|
+
from loguru import logger
|
|
28
|
+
|
|
29
|
+
from ...agentic.context import AgentContext
|
|
30
|
+
from ...models.core import (
|
|
31
|
+
FuzzyParameters,
|
|
32
|
+
LookupParameters,
|
|
33
|
+
QueryType,
|
|
34
|
+
RemQuery,
|
|
35
|
+
SearchParameters,
|
|
36
|
+
SQLParameters,
|
|
37
|
+
TraverseParameters,
|
|
38
|
+
)
|
|
39
|
+
from ...services.postgres import PostgresService
|
|
40
|
+
from ...services.rem import RemService
|
|
41
|
+
from ...settings import settings
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Service cache for FastAPI lifespan initialization
|
|
45
|
+
_service_cache: dict[str, Any] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def init_services(postgres_service: PostgresService, rem_service: RemService):
|
|
49
|
+
"""
|
|
50
|
+
Initialize service instances for MCP tools.
|
|
51
|
+
|
|
52
|
+
Called during FastAPI lifespan startup.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
postgres_service: PostgresService instance
|
|
56
|
+
rem_service: RemService instance
|
|
57
|
+
"""
|
|
58
|
+
_service_cache["postgres"] = postgres_service
|
|
59
|
+
_service_cache["rem"] = rem_service
|
|
60
|
+
logger.debug("MCP tools initialized with service instances")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def get_rem_service() -> RemService:
|
|
64
|
+
"""
|
|
65
|
+
Get or create RemService instance (lazy initialization).
|
|
66
|
+
|
|
67
|
+
Returns cached instance if available, otherwise creates new one.
|
|
68
|
+
Thread-safe for async usage.
|
|
69
|
+
"""
|
|
70
|
+
if "rem" in _service_cache:
|
|
71
|
+
return cast(RemService, _service_cache["rem"])
|
|
72
|
+
|
|
73
|
+
# Lazy initialization for in-process/CLI usage
|
|
74
|
+
from ...services.postgres import get_postgres_service
|
|
75
|
+
|
|
76
|
+
postgres_service = get_postgres_service()
|
|
77
|
+
if not postgres_service:
|
|
78
|
+
raise RuntimeError("PostgreSQL is disabled. Cannot use REM service.")
|
|
79
|
+
|
|
80
|
+
await postgres_service.connect()
|
|
81
|
+
rem_service = RemService(postgres_service=postgres_service)
|
|
82
|
+
|
|
83
|
+
_service_cache["postgres"] = postgres_service
|
|
84
|
+
_service_cache["rem"] = rem_service
|
|
85
|
+
|
|
86
|
+
logger.debug("MCP tools: lazy initialized services")
|
|
87
|
+
return rem_service
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def mcp_tool_error_handler(func: Callable) -> Callable:
|
|
91
|
+
"""
|
|
92
|
+
Decorator for consistent MCP tool error handling.
|
|
93
|
+
|
|
94
|
+
Wraps tool functions to:
|
|
95
|
+
- Log errors with full context
|
|
96
|
+
- Return standardized error responses
|
|
97
|
+
- Prevent exceptions from bubbling to LLM
|
|
98
|
+
|
|
99
|
+
Usage:
|
|
100
|
+
@mcp_tool_error_handler
|
|
101
|
+
async def my_tool(...) -> dict[str, Any]:
|
|
102
|
+
# Pure business logic - no try/except needed
|
|
103
|
+
result = await service.do_work()
|
|
104
|
+
return {"data": result}
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
{"status": "success", **result} on success
|
|
108
|
+
{"status": "error", "error": str(e)} on failure
|
|
109
|
+
"""
|
|
110
|
+
@wraps(func)
|
|
111
|
+
async def wrapper(*args, **kwargs) -> dict[str, Any]:
|
|
112
|
+
try:
|
|
113
|
+
result = await func(*args, **kwargs)
|
|
114
|
+
# If result already has status, return as-is
|
|
115
|
+
if isinstance(result, dict) and "status" in result:
|
|
116
|
+
return result
|
|
117
|
+
# Otherwise wrap in success response
|
|
118
|
+
return {"status": "success", **result}
|
|
119
|
+
except Exception as e:
|
|
120
|
+
# Use %s format to avoid issues with curly braces in error messages
|
|
121
|
+
logger.opt(exception=True).error("{} failed: {}", func.__name__, str(e))
|
|
122
|
+
return {
|
|
123
|
+
"status": "error",
|
|
124
|
+
"error": str(e),
|
|
125
|
+
"tool": func.__name__,
|
|
126
|
+
}
|
|
127
|
+
return wrapper
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@mcp_tool_error_handler
|
|
131
|
+
async def search_rem(
|
|
132
|
+
query: str,
|
|
133
|
+
limit: int = 20,
|
|
134
|
+
) -> dict[str, Any]:
|
|
135
|
+
"""
|
|
136
|
+
Execute a REM query using the REM query dialect.
|
|
137
|
+
|
|
138
|
+
**REM Query Syntax:**
|
|
139
|
+
|
|
140
|
+
LOOKUP <entity_key>
|
|
141
|
+
Find entity by exact name/key. Searches across all tables.
|
|
142
|
+
Example: LOOKUP phq-9-procedure
|
|
143
|
+
Example: LOOKUP sertraline
|
|
144
|
+
|
|
145
|
+
SEARCH <text> IN <table>
|
|
146
|
+
Semantic vector search within a specific table.
|
|
147
|
+
Tables: 'ontologies' (clinical knowledge, procedures, drugs, DSM criteria)
|
|
148
|
+
'resources' (documents, files, user content)
|
|
149
|
+
Example: SEARCH depression IN ontologies
|
|
150
|
+
Example: SEARCH Module F IN ontologies
|
|
151
|
+
|
|
152
|
+
FUZZY <text>
|
|
153
|
+
Fuzzy text matching for partial matches and typos.
|
|
154
|
+
Example: FUZZY setraline
|
|
155
|
+
|
|
156
|
+
TRAVERSE <start_entity>
|
|
157
|
+
Graph traversal from a starting entity.
|
|
158
|
+
Example: TRAVERSE sarah-chen
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
query: REM query string (e.g., "LOOKUP phq-9-procedure", "SEARCH depression IN ontologies")
|
|
162
|
+
limit: Maximum results to return (default: 20)
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Dict with query results and metadata. If no results found, includes
|
|
166
|
+
'suggestions' with alternative search strategies.
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
search_rem("LOOKUP phq-9-procedure")
|
|
170
|
+
search_rem("SEARCH depression IN ontologies")
|
|
171
|
+
search_rem("SEARCH anxiety treatment IN ontologies", limit=10)
|
|
172
|
+
search_rem("FUZZY setraline")
|
|
173
|
+
"""
|
|
174
|
+
# Get RemService instance (lazy initialization)
|
|
175
|
+
rem_service = await get_rem_service()
|
|
176
|
+
|
|
177
|
+
# Get user_id from context
|
|
178
|
+
user_id = AgentContext.get_user_id_or_default(None, source="search_rem")
|
|
179
|
+
|
|
180
|
+
# Parse the REM query string
|
|
181
|
+
if not query or not query.strip():
|
|
182
|
+
return {
|
|
183
|
+
"status": "error",
|
|
184
|
+
"error": "Empty query. Use REM syntax: LOOKUP <key>, SEARCH <text> IN <table>, FUZZY <text>, or TRAVERSE <entity>",
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
query = query.strip()
|
|
188
|
+
parts = query.split(None, 1) # Split on first whitespace
|
|
189
|
+
|
|
190
|
+
if len(parts) < 2:
|
|
191
|
+
return {
|
|
192
|
+
"status": "error",
|
|
193
|
+
"error": f"Invalid query format: '{query}'. Expected: LOOKUP <key>, SEARCH <text> IN <table>, FUZZY <text>, or TRAVERSE <entity>",
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
query_type = parts[0].upper()
|
|
197
|
+
remainder = parts[1].strip()
|
|
198
|
+
|
|
199
|
+
# Build RemQuery based on query_type
|
|
200
|
+
if query_type == "LOOKUP":
|
|
201
|
+
if not remainder:
|
|
202
|
+
return {
|
|
203
|
+
"status": "error",
|
|
204
|
+
"error": "LOOKUP requires an entity key. Example: LOOKUP phq-9-procedure",
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
rem_query = RemQuery(
|
|
208
|
+
query_type=QueryType.LOOKUP,
|
|
209
|
+
parameters=LookupParameters(
|
|
210
|
+
key=remainder,
|
|
211
|
+
user_id=user_id,
|
|
212
|
+
),
|
|
213
|
+
user_id=user_id,
|
|
214
|
+
)
|
|
215
|
+
table = None # LOOKUP searches all tables
|
|
216
|
+
|
|
217
|
+
elif query_type == "SEARCH":
|
|
218
|
+
# Parse "text IN table" format
|
|
219
|
+
if " IN " in remainder.upper():
|
|
220
|
+
# Find the last " IN " to handle cases like "SEARCH pain IN back IN ontologies"
|
|
221
|
+
in_pos = remainder.upper().rfind(" IN ")
|
|
222
|
+
search_text = remainder[:in_pos].strip()
|
|
223
|
+
table = remainder[in_pos + 4:].strip().lower()
|
|
224
|
+
else:
|
|
225
|
+
return {
|
|
226
|
+
"status": "error",
|
|
227
|
+
"error": f"SEARCH requires table: SEARCH <text> IN <table>. "
|
|
228
|
+
"Use 'ontologies' for clinical knowledge or 'resources' for documents. "
|
|
229
|
+
f"Example: SEARCH {remainder} IN ontologies",
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if not search_text:
|
|
233
|
+
return {
|
|
234
|
+
"status": "error",
|
|
235
|
+
"error": "SEARCH requires search text. Example: SEARCH depression IN ontologies",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
rem_query = RemQuery(
|
|
239
|
+
query_type=QueryType.SEARCH,
|
|
240
|
+
parameters=SearchParameters(
|
|
241
|
+
query_text=search_text,
|
|
242
|
+
table_name=table,
|
|
243
|
+
limit=limit,
|
|
244
|
+
),
|
|
245
|
+
user_id=user_id,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
elif query_type == "FUZZY":
|
|
249
|
+
if not remainder:
|
|
250
|
+
return {
|
|
251
|
+
"status": "error",
|
|
252
|
+
"error": "FUZZY requires search text. Example: FUZZY setraline",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
rem_query = RemQuery(
|
|
256
|
+
query_type=QueryType.FUZZY,
|
|
257
|
+
parameters=FuzzyParameters(
|
|
258
|
+
query_text=remainder,
|
|
259
|
+
threshold=0.3, # pg_trgm similarity - 0.3 is reasonable for typo correction
|
|
260
|
+
limit=limit,
|
|
261
|
+
),
|
|
262
|
+
user_id=user_id,
|
|
263
|
+
)
|
|
264
|
+
table = None
|
|
265
|
+
|
|
266
|
+
elif query_type == "TRAVERSE":
|
|
267
|
+
if not remainder:
|
|
268
|
+
return {
|
|
269
|
+
"status": "error",
|
|
270
|
+
"error": "TRAVERSE requires a starting entity. Example: TRAVERSE sarah-chen",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
rem_query = RemQuery(
|
|
274
|
+
query_type=QueryType.TRAVERSE,
|
|
275
|
+
parameters=TraverseParameters(
|
|
276
|
+
initial_query=remainder,
|
|
277
|
+
edge_types=[],
|
|
278
|
+
max_depth=1,
|
|
279
|
+
),
|
|
280
|
+
user_id=user_id,
|
|
281
|
+
)
|
|
282
|
+
table = None
|
|
283
|
+
|
|
284
|
+
else:
|
|
285
|
+
return {
|
|
286
|
+
"status": "error",
|
|
287
|
+
"error": f"Unknown query type: '{query_type}'. Valid types: LOOKUP, SEARCH, FUZZY, TRAVERSE. "
|
|
288
|
+
"Examples: LOOKUP phq-9-procedure, SEARCH depression IN ontologies",
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Execute query (errors handled by decorator)
|
|
292
|
+
logger.info(f"Executing REM query: {query_type} for user {user_id}")
|
|
293
|
+
result = await rem_service.execute_query(rem_query)
|
|
294
|
+
|
|
295
|
+
logger.info(f"Query completed successfully: {query_type}")
|
|
296
|
+
|
|
297
|
+
# Provide helpful guidance when no results found
|
|
298
|
+
response: dict[str, Any] = {
|
|
299
|
+
"query_type": query_type,
|
|
300
|
+
"results": result,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Check if results are empty - handle both list and dict result formats
|
|
304
|
+
is_empty = False
|
|
305
|
+
if not result:
|
|
306
|
+
is_empty = True
|
|
307
|
+
elif isinstance(result, list) and len(result) == 0:
|
|
308
|
+
is_empty = True
|
|
309
|
+
elif isinstance(result, dict):
|
|
310
|
+
# RemService returns dict with 'results' key containing actual matches
|
|
311
|
+
inner_results = result.get("results", [])
|
|
312
|
+
count = result.get("count", len(inner_results) if isinstance(inner_results, list) else 0)
|
|
313
|
+
is_empty = count == 0 or (isinstance(inner_results, list) and len(inner_results) == 0)
|
|
314
|
+
|
|
315
|
+
if is_empty:
|
|
316
|
+
# Build helpful suggestions based on query type
|
|
317
|
+
suggestions = []
|
|
318
|
+
|
|
319
|
+
if query_type in ("LOOKUP", "FUZZY"):
|
|
320
|
+
suggestions.append(
|
|
321
|
+
"LOOKUP/FUZZY searches across ALL tables. If you expected results, "
|
|
322
|
+
"verify the entity name is spelled correctly."
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if query_type == "SEARCH":
|
|
326
|
+
if table == "resources":
|
|
327
|
+
suggestions.append(
|
|
328
|
+
"No results in 'resources' table. Try: SEARCH <text> IN ontologies - "
|
|
329
|
+
"clinical procedures, drug info, and diagnostic criteria are stored there."
|
|
330
|
+
)
|
|
331
|
+
elif table == "ontologies":
|
|
332
|
+
suggestions.append(
|
|
333
|
+
"No results in 'ontologies' table. Try: SEARCH <text> IN resources - "
|
|
334
|
+
"for user-uploaded documents and general content."
|
|
335
|
+
)
|
|
336
|
+
else:
|
|
337
|
+
suggestions.append(
|
|
338
|
+
"Try: SEARCH <text> IN ontologies (clinical knowledge, procedures, drugs) "
|
|
339
|
+
"or SEARCH <text> IN resources (documents, files)."
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Always suggest both tables if no specific table guidance given
|
|
343
|
+
if not suggestions:
|
|
344
|
+
suggestions.append(
|
|
345
|
+
"No results found. Try: SEARCH <text> IN ontologies (clinical procedures, drugs) "
|
|
346
|
+
"or SEARCH <text> IN resources (documents, files)."
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
response["suggestions"] = suggestions
|
|
350
|
+
response["hint"] = "0 results returned. See 'suggestions' for alternative search strategies."
|
|
351
|
+
|
|
352
|
+
return response
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@mcp_tool_error_handler
|
|
356
|
+
async def ask_rem_agent(
|
|
357
|
+
query: str,
|
|
358
|
+
agent_schema: str = "ask_rem",
|
|
359
|
+
agent_version: str | None = None,
|
|
360
|
+
user_id: str | None = None,
|
|
361
|
+
) -> dict[str, Any]:
|
|
362
|
+
"""
|
|
363
|
+
Ask REM using natural language via agent-driven query conversion.
|
|
364
|
+
|
|
365
|
+
This tool converts natural language questions into optimized REM queries
|
|
366
|
+
using an agent that understands the REM query language and schema.
|
|
367
|
+
|
|
368
|
+
The agent can perform multi-turn reasoning and iterated retrieval:
|
|
369
|
+
1. Initial exploration (LOOKUP/FUZZY to find entities)
|
|
370
|
+
2. Semantic search (SEARCH for related content)
|
|
371
|
+
3. Graph traversal (TRAVERSE to explore relationships)
|
|
372
|
+
4. Synthesis (combine results into final answer)
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
query: Natural language question or task
|
|
376
|
+
agent_schema: Agent schema name (default: "ask_rem")
|
|
377
|
+
agent_version: Optional agent version (default: latest)
|
|
378
|
+
user_id: Optional user identifier (defaults to authenticated user or "default")
|
|
379
|
+
|
|
380
|
+
Returns:
|
|
381
|
+
Dict with:
|
|
382
|
+
- status: "success" or "error"
|
|
383
|
+
- response: Agent's natural language response
|
|
384
|
+
- query_output: Structured query results (if available)
|
|
385
|
+
- queries_executed: List of REM queries executed
|
|
386
|
+
- metadata: Agent execution metadata
|
|
387
|
+
|
|
388
|
+
Examples:
|
|
389
|
+
# Simple question (uses authenticated user context)
|
|
390
|
+
ask_rem_agent(
|
|
391
|
+
query="Who is Sarah Chen?"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Complex multi-step question
|
|
395
|
+
ask_rem_agent(
|
|
396
|
+
query="What are the key findings from last week's sprint retrospective?"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Graph exploration
|
|
400
|
+
ask_rem_agent(
|
|
401
|
+
query="Show me Sarah's reporting chain and their recent projects"
|
|
402
|
+
)
|
|
403
|
+
"""
|
|
404
|
+
from ...agentic import create_agent
|
|
405
|
+
from ...agentic.context import get_current_context
|
|
406
|
+
from ...utils.schema_loader import load_agent_schema
|
|
407
|
+
|
|
408
|
+
# Get parent context for multi-agent support
|
|
409
|
+
# This enables context propagation from parent agent to child agent
|
|
410
|
+
parent_context = get_current_context()
|
|
411
|
+
|
|
412
|
+
# Build child context: inherit from parent if available, otherwise use defaults
|
|
413
|
+
if parent_context is not None:
|
|
414
|
+
# Inherit user_id, tenant_id, session_id, is_eval from parent
|
|
415
|
+
# Allow explicit user_id override if provided
|
|
416
|
+
effective_user_id = user_id or parent_context.user_id
|
|
417
|
+
context = parent_context.child_context(agent_schema_uri=agent_schema)
|
|
418
|
+
if user_id is not None:
|
|
419
|
+
# Override user_id if explicitly provided
|
|
420
|
+
context = AgentContext(
|
|
421
|
+
user_id=user_id,
|
|
422
|
+
tenant_id=parent_context.tenant_id,
|
|
423
|
+
session_id=parent_context.session_id,
|
|
424
|
+
default_model=parent_context.default_model,
|
|
425
|
+
agent_schema_uri=agent_schema,
|
|
426
|
+
is_eval=parent_context.is_eval,
|
|
427
|
+
)
|
|
428
|
+
logger.debug(
|
|
429
|
+
f"ask_rem_agent inheriting context from parent: "
|
|
430
|
+
f"user_id={context.user_id}, session_id={context.session_id}"
|
|
431
|
+
)
|
|
432
|
+
else:
|
|
433
|
+
# No parent context - create fresh context (backwards compatible)
|
|
434
|
+
effective_user_id = AgentContext.get_user_id_or_default(
|
|
435
|
+
user_id, source="ask_rem_agent"
|
|
436
|
+
)
|
|
437
|
+
context = AgentContext(
|
|
438
|
+
user_id=effective_user_id,
|
|
439
|
+
tenant_id=effective_user_id or "default",
|
|
440
|
+
default_model=settings.llm.default_model,
|
|
441
|
+
agent_schema_uri=agent_schema,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Load agent schema
|
|
445
|
+
try:
|
|
446
|
+
schema = load_agent_schema(agent_schema)
|
|
447
|
+
except FileNotFoundError:
|
|
448
|
+
return {
|
|
449
|
+
"status": "error",
|
|
450
|
+
"error": f"Agent schema not found: {agent_schema}",
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
# Create agent
|
|
454
|
+
agent_runtime = await create_agent(
|
|
455
|
+
context=context,
|
|
456
|
+
agent_schema_override=schema,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Run agent (errors handled by decorator)
|
|
460
|
+
logger.debug(f"Running ask_rem agent for query: {query[:100]}...")
|
|
461
|
+
result = await agent_runtime.run(query)
|
|
462
|
+
|
|
463
|
+
# Extract output
|
|
464
|
+
from rem.agentic.serialization import serialize_agent_result
|
|
465
|
+
query_output = serialize_agent_result(result.output)
|
|
466
|
+
|
|
467
|
+
logger.debug("Agent execution completed successfully")
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
"response": str(result.output),
|
|
471
|
+
"query_output": query_output,
|
|
472
|
+
"natural_query": query,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@mcp_tool_error_handler
|
|
477
|
+
async def ingest_into_rem(
|
|
478
|
+
file_uri: str,
|
|
479
|
+
category: str | None = None,
|
|
480
|
+
tags: list[str] | None = None,
|
|
481
|
+
is_local_server: bool = False,
|
|
482
|
+
resource_type: str | None = None,
|
|
483
|
+
) -> dict[str, Any]:
|
|
484
|
+
"""
|
|
485
|
+
Ingest file into REM, creating searchable PUBLIC resources and embeddings.
|
|
486
|
+
|
|
487
|
+
**IMPORTANT: All ingested data is PUBLIC by default.** This is correct for
|
|
488
|
+
shared knowledge bases (ontologies, procedures, reference data). Private
|
|
489
|
+
user-scoped data requires different handling via the CLI with --make-private.
|
|
490
|
+
|
|
491
|
+
This tool provides the complete file ingestion pipeline:
|
|
492
|
+
1. **Read**: File from local/S3/HTTP
|
|
493
|
+
2. **Store**: To internal storage (public namespace)
|
|
494
|
+
3. **Parse**: Extract content, metadata, tables, images
|
|
495
|
+
4. **Chunk**: Semantic chunking for embeddings
|
|
496
|
+
5. **Embed**: Create Resource chunks with vector embeddings
|
|
497
|
+
|
|
498
|
+
Supported file types:
|
|
499
|
+
- Documents: PDF, DOCX, TXT, Markdown
|
|
500
|
+
- Code: Python, JavaScript, TypeScript, etc.
|
|
501
|
+
- Data: CSV, JSON, YAML
|
|
502
|
+
- Audio: WAV, MP3 (transcription)
|
|
503
|
+
|
|
504
|
+
**Security**: Remote MCP servers cannot read local files. Only local/stdio
|
|
505
|
+
MCP servers can access local filesystem paths.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
file_uri: File location (local path, s3:// URI, or http(s):// URL)
|
|
509
|
+
category: Optional category (document, code, audio, etc.)
|
|
510
|
+
tags: Optional tags for file
|
|
511
|
+
is_local_server: True if running as local/stdio MCP server
|
|
512
|
+
resource_type: Optional resource type for storing chunks (case-insensitive).
|
|
513
|
+
Supports flexible naming:
|
|
514
|
+
- "resource", "resources", "Resource" → Resource (default)
|
|
515
|
+
- "domain-resource", "domain_resource", "DomainResource",
|
|
516
|
+
"domain-resources" → DomainResource (curated internal knowledge)
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Dict with:
|
|
520
|
+
- status: "success" or "error"
|
|
521
|
+
- file_id: Created file UUID
|
|
522
|
+
- file_name: Original filename
|
|
523
|
+
- storage_uri: Internal storage URI
|
|
524
|
+
- processing_status: "completed" or "failed"
|
|
525
|
+
- resources_created: Number of Resource chunks created
|
|
526
|
+
- content: Parsed file content (markdown format) if completed
|
|
527
|
+
- message: Human-readable status message
|
|
528
|
+
|
|
529
|
+
Examples:
|
|
530
|
+
# Ingest local file (local server only)
|
|
531
|
+
ingest_into_rem(
|
|
532
|
+
file_uri="/Users/me/procedure.pdf",
|
|
533
|
+
category="medical",
|
|
534
|
+
is_local_server=True
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Ingest from S3
|
|
538
|
+
ingest_into_rem(
|
|
539
|
+
file_uri="s3://bucket/docs/report.pdf"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Ingest from HTTP
|
|
543
|
+
ingest_into_rem(
|
|
544
|
+
file_uri="https://example.com/whitepaper.pdf",
|
|
545
|
+
tags=["research", "whitepaper"]
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# Ingest as curated domain knowledge
|
|
549
|
+
ingest_into_rem(
|
|
550
|
+
file_uri="s3://bucket/internal/procedures.pdf",
|
|
551
|
+
resource_type="domain-resource",
|
|
552
|
+
category="procedures"
|
|
553
|
+
)
|
|
554
|
+
"""
|
|
555
|
+
from ...services.content import ContentService
|
|
556
|
+
|
|
557
|
+
# Data is PUBLIC by default (user_id=None)
|
|
558
|
+
# Private user-scoped data requires CLI with --make-private flag
|
|
559
|
+
|
|
560
|
+
# Delegate to ContentService for centralized ingestion (errors handled by decorator)
|
|
561
|
+
content_service = ContentService()
|
|
562
|
+
result = await content_service.ingest_file(
|
|
563
|
+
file_uri=file_uri,
|
|
564
|
+
user_id=None, # PUBLIC - all ingested data is shared/public
|
|
565
|
+
category=category,
|
|
566
|
+
tags=tags,
|
|
567
|
+
is_local_server=is_local_server,
|
|
568
|
+
resource_type=resource_type,
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
logger.debug(
|
|
572
|
+
f"MCP ingestion complete: {result['file_name']} "
|
|
573
|
+
f"(status: {result['processing_status']}, "
|
|
574
|
+
f"resources: {result['resources_created']})"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
return result
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@mcp_tool_error_handler
|
|
581
|
+
async def read_resource(uri: str) -> dict[str, Any]:
|
|
582
|
+
"""
|
|
583
|
+
Read an MCP resource by URI.
|
|
584
|
+
|
|
585
|
+
This tool provides automatic access to MCP resources in Claude Desktop.
|
|
586
|
+
Resources contain authoritative, up-to-date reference data.
|
|
587
|
+
|
|
588
|
+
**IMPORTANT**: This tool enables Claude Desktop to automatically access
|
|
589
|
+
resources based on query relevance. While FastMCP correctly exposes resources
|
|
590
|
+
via standard MCP resource endpoints, Claude Desktop currently requires manual
|
|
591
|
+
resource attachment. This tool bridges that gap by exposing resource access
|
|
592
|
+
as a tool, which Claude Desktop WILL automatically invoke.
|
|
593
|
+
|
|
594
|
+
**Available Resources:**
|
|
595
|
+
|
|
596
|
+
Agent Schemas:
|
|
597
|
+
• rem://agents - List all available agent schemas
|
|
598
|
+
• rem://agents/{agent_name} - Get specific agent schema
|
|
599
|
+
|
|
600
|
+
Documentation:
|
|
601
|
+
• rem://schema/entities - Entity schemas (Resource, Message, User, File, Moment)
|
|
602
|
+
• rem://schema/query-types - REM query type documentation
|
|
603
|
+
|
|
604
|
+
System Status:
|
|
605
|
+
• rem://status - System health and statistics
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
uri: Resource URI (e.g., "rem://agents", "rem://agents/ask_rem")
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
Dict with:
|
|
612
|
+
- status: "success" or "error"
|
|
613
|
+
- uri: Original URI
|
|
614
|
+
- data: Resource data (format depends on resource type)
|
|
615
|
+
|
|
616
|
+
Examples:
|
|
617
|
+
# List all agents
|
|
618
|
+
read_resource(uri="rem://agents")
|
|
619
|
+
|
|
620
|
+
# Get specific agent
|
|
621
|
+
read_resource(uri="rem://agents/ask_rem")
|
|
622
|
+
|
|
623
|
+
# Check system status
|
|
624
|
+
read_resource(uri="rem://status")
|
|
625
|
+
"""
|
|
626
|
+
logger.debug(f"Reading resource: {uri}")
|
|
627
|
+
|
|
628
|
+
# Import here to avoid circular dependency
|
|
629
|
+
from .resources import load_resource
|
|
630
|
+
|
|
631
|
+
# Load resource using the existing resource handler (errors handled by decorator)
|
|
632
|
+
result = await load_resource(uri)
|
|
633
|
+
|
|
634
|
+
logger.debug(f"Resource loaded successfully: {uri}")
|
|
635
|
+
|
|
636
|
+
# If result is already a dict, return it
|
|
637
|
+
if isinstance(result, dict):
|
|
638
|
+
return {
|
|
639
|
+
"uri": uri,
|
|
640
|
+
"data": result,
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
# If result is a string (JSON), parse it
|
|
644
|
+
import json
|
|
645
|
+
|
|
646
|
+
try:
|
|
647
|
+
data = json.loads(result)
|
|
648
|
+
return {
|
|
649
|
+
"uri": uri,
|
|
650
|
+
"data": data,
|
|
651
|
+
}
|
|
652
|
+
except json.JSONDecodeError:
|
|
653
|
+
# Return as plain text if not JSON
|
|
654
|
+
return {
|
|
655
|
+
"uri": uri,
|
|
656
|
+
"data": {"content": result},
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
async def register_metadata(
|
|
661
|
+
confidence: float | None = None,
|
|
662
|
+
references: list[str] | None = None,
|
|
663
|
+
sources: list[str] | None = None,
|
|
664
|
+
flags: list[str] | None = None,
|
|
665
|
+
# Session naming
|
|
666
|
+
session_name: str | None = None,
|
|
667
|
+
# Risk assessment fields (used by specialized agents)
|
|
668
|
+
risk_level: str | None = None,
|
|
669
|
+
risk_score: int | None = None,
|
|
670
|
+
risk_reasoning: str | None = None,
|
|
671
|
+
recommended_action: str | None = None,
|
|
672
|
+
# Generic extension - any additional key-value pairs
|
|
673
|
+
extra: dict[str, Any] | None = None,
|
|
674
|
+
# Agent schema (auto-populated from context if not provided)
|
|
675
|
+
agent_schema: str | None = None,
|
|
676
|
+
) -> dict[str, Any]:
|
|
677
|
+
"""
|
|
678
|
+
Register response metadata to be emitted as an SSE MetadataEvent.
|
|
679
|
+
|
|
680
|
+
Call this tool BEFORE generating your final response to provide structured
|
|
681
|
+
metadata that will be sent to the client alongside your natural language output.
|
|
682
|
+
This allows you to stream conversational responses while still providing
|
|
683
|
+
machine-readable confidence scores, references, and other metadata.
|
|
684
|
+
|
|
685
|
+
**Design Pattern**: Agents can call this once before their final response to
|
|
686
|
+
register metadata that the streaming layer will emit as a MetadataEvent.
|
|
687
|
+
This decouples structured metadata from the response format.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
confidence: Confidence score (0.0-1.0) for the response quality.
|
|
691
|
+
- 0.9-1.0: High confidence, answer is well-supported
|
|
692
|
+
- 0.7-0.9: Medium confidence, some uncertainty
|
|
693
|
+
- 0.5-0.7: Low confidence, significant gaps
|
|
694
|
+
- <0.5: Very uncertain, may need clarification
|
|
695
|
+
references: List of reference identifiers (file paths, document IDs,
|
|
696
|
+
entity labels) that support the response.
|
|
697
|
+
sources: List of source descriptions (e.g., "REM database",
|
|
698
|
+
"search results", "user context").
|
|
699
|
+
flags: Optional flags for the response (e.g., "needs_review",
|
|
700
|
+
"uncertain", "incomplete", "crisis_alert").
|
|
701
|
+
|
|
702
|
+
session_name: Short 1-3 phrase name describing the session topic.
|
|
703
|
+
Used by the UI to label conversations in the sidebar.
|
|
704
|
+
Examples: "Prescription Drug Questions", "AWS Setup Help",
|
|
705
|
+
"Python Code Review", "Travel Planning".
|
|
706
|
+
|
|
707
|
+
risk_level: Risk level indicator (e.g., "green", "orange", "red").
|
|
708
|
+
Used by mental health agents for C-SSRS style assessment.
|
|
709
|
+
risk_score: Numeric risk score (e.g., 0-6 for C-SSRS).
|
|
710
|
+
risk_reasoning: Brief explanation of risk assessment.
|
|
711
|
+
recommended_action: Suggested next steps based on assessment.
|
|
712
|
+
|
|
713
|
+
extra: Dict of arbitrary additional metadata. Use this for any
|
|
714
|
+
domain-specific fields not covered by the standard parameters.
|
|
715
|
+
Example: {"topics_detected": ["anxiety", "sleep"], "session_count": 5}
|
|
716
|
+
agent_schema: Optional agent schema name. If not provided, automatically
|
|
717
|
+
populated from the current agent context (for multi-agent tracing).
|
|
718
|
+
|
|
719
|
+
Returns:
|
|
720
|
+
Dict with:
|
|
721
|
+
- status: "success"
|
|
722
|
+
- _metadata_event: True (marker for streaming layer)
|
|
723
|
+
- All provided fields merged into response
|
|
724
|
+
|
|
725
|
+
Examples:
|
|
726
|
+
# High confidence answer with references
|
|
727
|
+
register_metadata(
|
|
728
|
+
confidence=0.95,
|
|
729
|
+
references=["sarah-chen", "q3-report-2024"],
|
|
730
|
+
sources=["REM database lookup"]
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
# Risk assessment example
|
|
734
|
+
register_metadata(
|
|
735
|
+
confidence=0.9,
|
|
736
|
+
risk_level="green",
|
|
737
|
+
risk_score=0,
|
|
738
|
+
risk_reasoning="No risk indicators detected in message",
|
|
739
|
+
sources=["mental_health_resources"]
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
# Orange risk with recommended action
|
|
743
|
+
register_metadata(
|
|
744
|
+
risk_level="orange",
|
|
745
|
+
risk_score=2,
|
|
746
|
+
risk_reasoning="Passive ideation detected - 'feeling hopeless'",
|
|
747
|
+
recommended_action="Schedule care team check-in within 24-48 hours",
|
|
748
|
+
flags=["care_team_alert"]
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
# Custom domain-specific metadata
|
|
752
|
+
register_metadata(
|
|
753
|
+
confidence=0.8,
|
|
754
|
+
extra={
|
|
755
|
+
"topics_detected": ["medication", "side_effects"],
|
|
756
|
+
"drug_mentioned": "sertraline",
|
|
757
|
+
"sentiment": "concerned"
|
|
758
|
+
}
|
|
759
|
+
)
|
|
760
|
+
"""
|
|
761
|
+
# Auto-populate agent_schema from context if not provided
|
|
762
|
+
if agent_schema is None:
|
|
763
|
+
from ...agentic.context import get_current_context
|
|
764
|
+
current_context = get_current_context()
|
|
765
|
+
if current_context and current_context.agent_schema_uri:
|
|
766
|
+
agent_schema = current_context.agent_schema_uri
|
|
767
|
+
|
|
768
|
+
logger.debug(
|
|
769
|
+
f"Registering metadata: confidence={confidence}, "
|
|
770
|
+
f"risk_level={risk_level}, refs={len(references or [])}, "
|
|
771
|
+
f"sources={len(sources or [])}, agent_schema={agent_schema}"
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
result = {
|
|
775
|
+
"status": "success",
|
|
776
|
+
"_metadata_event": True, # Marker for streaming layer
|
|
777
|
+
"confidence": confidence,
|
|
778
|
+
"references": references,
|
|
779
|
+
"sources": sources,
|
|
780
|
+
"flags": flags,
|
|
781
|
+
"agent_schema": agent_schema, # Include agent schema for tracing
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
# Add session name if provided
|
|
785
|
+
if session_name is not None:
|
|
786
|
+
result["session_name"] = session_name
|
|
787
|
+
|
|
788
|
+
# Add risk assessment fields if provided
|
|
789
|
+
if risk_level is not None:
|
|
790
|
+
result["risk_level"] = risk_level
|
|
791
|
+
if risk_score is not None:
|
|
792
|
+
result["risk_score"] = risk_score
|
|
793
|
+
if risk_reasoning is not None:
|
|
794
|
+
result["risk_reasoning"] = risk_reasoning
|
|
795
|
+
if recommended_action is not None:
|
|
796
|
+
result["recommended_action"] = recommended_action
|
|
797
|
+
|
|
798
|
+
# Merge any extra fields
|
|
799
|
+
if extra:
|
|
800
|
+
result["extra"] = extra
|
|
801
|
+
|
|
802
|
+
return result
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@mcp_tool_error_handler
|
|
806
|
+
async def list_schema(
|
|
807
|
+
include_system: bool = False,
|
|
808
|
+
user_id: str | None = None,
|
|
809
|
+
) -> dict[str, Any]:
|
|
810
|
+
"""
|
|
811
|
+
List all schemas (tables) in the REM database.
|
|
812
|
+
|
|
813
|
+
Returns metadata about all available tables including their names,
|
|
814
|
+
row counts, and descriptions. Use this to discover what data is
|
|
815
|
+
available before constructing queries.
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
include_system: If True, include PostgreSQL system tables (pg_*, information_schema).
|
|
819
|
+
Default False shows only REM application tables.
|
|
820
|
+
user_id: Optional user identifier (defaults to authenticated user or "default")
|
|
821
|
+
|
|
822
|
+
Returns:
|
|
823
|
+
Dict with:
|
|
824
|
+
- status: "success" or "error"
|
|
825
|
+
- tables: List of table metadata dicts with:
|
|
826
|
+
- name: Table name
|
|
827
|
+
- schema: Schema name (usually "public")
|
|
828
|
+
- estimated_rows: Approximate row count
|
|
829
|
+
- description: Table comment if available
|
|
830
|
+
|
|
831
|
+
Examples:
|
|
832
|
+
# List all REM schemas
|
|
833
|
+
list_schema()
|
|
834
|
+
|
|
835
|
+
# Include system tables
|
|
836
|
+
list_schema(include_system=True)
|
|
837
|
+
"""
|
|
838
|
+
rem_service = await get_rem_service()
|
|
839
|
+
user_id = AgentContext.get_user_id_or_default(user_id, source="list_schema")
|
|
840
|
+
|
|
841
|
+
# Query information_schema for tables
|
|
842
|
+
schema_filter = ""
|
|
843
|
+
if not include_system:
|
|
844
|
+
schema_filter = """
|
|
845
|
+
AND table_schema = 'public'
|
|
846
|
+
AND table_name NOT LIKE 'pg_%'
|
|
847
|
+
AND table_name NOT LIKE '_pg_%'
|
|
848
|
+
"""
|
|
849
|
+
|
|
850
|
+
query = f"""
|
|
851
|
+
SELECT
|
|
852
|
+
t.table_schema,
|
|
853
|
+
t.table_name,
|
|
854
|
+
pg_catalog.obj_description(
|
|
855
|
+
(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::regclass,
|
|
856
|
+
'pg_class'
|
|
857
|
+
) as description,
|
|
858
|
+
(
|
|
859
|
+
SELECT reltuples::bigint
|
|
860
|
+
FROM pg_class c
|
|
861
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
862
|
+
WHERE c.relname = t.table_name
|
|
863
|
+
AND n.nspname = t.table_schema
|
|
864
|
+
) as estimated_rows
|
|
865
|
+
FROM information_schema.tables t
|
|
866
|
+
WHERE t.table_type = 'BASE TABLE'
|
|
867
|
+
{schema_filter}
|
|
868
|
+
ORDER BY t.table_schema, t.table_name
|
|
869
|
+
"""
|
|
870
|
+
|
|
871
|
+
# Access postgres service directly from cache
|
|
872
|
+
postgres_service = _service_cache.get("postgres")
|
|
873
|
+
if not postgres_service:
|
|
874
|
+
postgres_service = rem_service._postgres
|
|
875
|
+
|
|
876
|
+
rows = await postgres_service.fetch(query)
|
|
877
|
+
|
|
878
|
+
tables = []
|
|
879
|
+
for row in rows:
|
|
880
|
+
tables.append({
|
|
881
|
+
"name": row["table_name"],
|
|
882
|
+
"schema": row["table_schema"],
|
|
883
|
+
"estimated_rows": int(row["estimated_rows"]) if row["estimated_rows"] else 0,
|
|
884
|
+
"description": row["description"],
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
logger.info(f"Listed {len(tables)} schemas for user {user_id}")
|
|
888
|
+
|
|
889
|
+
return {
|
|
890
|
+
"tables": tables,
|
|
891
|
+
"count": len(tables),
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
@mcp_tool_error_handler
|
|
896
|
+
async def get_schema(
|
|
897
|
+
table_name: str,
|
|
898
|
+
include_indexes: bool = True,
|
|
899
|
+
include_constraints: bool = True,
|
|
900
|
+
columns: list[str] | None = None,
|
|
901
|
+
user_id: str | None = None,
|
|
902
|
+
) -> dict[str, Any]:
|
|
903
|
+
"""
|
|
904
|
+
Get detailed schema information for a specific table.
|
|
905
|
+
|
|
906
|
+
Returns column definitions, data types, constraints, and indexes.
|
|
907
|
+
Use this to understand table structure before writing SQL queries.
|
|
908
|
+
|
|
909
|
+
Args:
|
|
910
|
+
table_name: Name of the table to inspect (e.g., "resources", "moments")
|
|
911
|
+
include_indexes: Include index information (default True)
|
|
912
|
+
include_constraints: Include constraint information (default True)
|
|
913
|
+
columns: Optional list of specific columns to return. If None, returns all columns.
|
|
914
|
+
user_id: Optional user identifier (defaults to authenticated user or "default")
|
|
915
|
+
|
|
916
|
+
Returns:
|
|
917
|
+
Dict with:
|
|
918
|
+
- status: "success" or "error"
|
|
919
|
+
- table_name: Name of the table
|
|
920
|
+
- columns: List of column definitions with:
|
|
921
|
+
- name: Column name
|
|
922
|
+
- type: PostgreSQL data type
|
|
923
|
+
- nullable: Whether NULL is allowed
|
|
924
|
+
- default: Default value if any
|
|
925
|
+
- description: Column comment if available
|
|
926
|
+
- indexes: List of indexes (if include_indexes=True)
|
|
927
|
+
- constraints: List of constraints (if include_constraints=True)
|
|
928
|
+
- primary_key: Primary key column(s)
|
|
929
|
+
|
|
930
|
+
Examples:
|
|
931
|
+
# Get full schema for resources table
|
|
932
|
+
get_schema(table_name="resources")
|
|
933
|
+
|
|
934
|
+
# Get only specific columns
|
|
935
|
+
get_schema(
|
|
936
|
+
table_name="resources",
|
|
937
|
+
columns=["id", "name", "created_at"]
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
# Get schema without indexes
|
|
941
|
+
get_schema(
|
|
942
|
+
table_name="moments",
|
|
943
|
+
include_indexes=False
|
|
944
|
+
)
|
|
945
|
+
"""
|
|
946
|
+
rem_service = await get_rem_service()
|
|
947
|
+
user_id = AgentContext.get_user_id_or_default(user_id, source="get_schema")
|
|
948
|
+
|
|
949
|
+
# Access postgres service
|
|
950
|
+
postgres_service = _service_cache.get("postgres")
|
|
951
|
+
if not postgres_service:
|
|
952
|
+
postgres_service = rem_service._postgres
|
|
953
|
+
|
|
954
|
+
# Verify table exists
|
|
955
|
+
exists_query = """
|
|
956
|
+
SELECT EXISTS (
|
|
957
|
+
SELECT 1 FROM information_schema.tables
|
|
958
|
+
WHERE table_schema = 'public' AND table_name = $1
|
|
959
|
+
)
|
|
960
|
+
"""
|
|
961
|
+
exists = await postgres_service.fetchval(exists_query, table_name)
|
|
962
|
+
if not exists:
|
|
963
|
+
return {
|
|
964
|
+
"status": "error",
|
|
965
|
+
"error": f"Table '{table_name}' not found in public schema",
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
# Get columns
|
|
969
|
+
columns_filter = ""
|
|
970
|
+
if columns:
|
|
971
|
+
placeholders = ", ".join(f"${i+2}" for i in range(len(columns)))
|
|
972
|
+
columns_filter = f"AND column_name IN ({placeholders})"
|
|
973
|
+
|
|
974
|
+
columns_query = f"""
|
|
975
|
+
SELECT
|
|
976
|
+
c.column_name,
|
|
977
|
+
c.data_type,
|
|
978
|
+
c.udt_name,
|
|
979
|
+
c.is_nullable,
|
|
980
|
+
c.column_default,
|
|
981
|
+
c.character_maximum_length,
|
|
982
|
+
c.numeric_precision,
|
|
983
|
+
pg_catalog.col_description(
|
|
984
|
+
(quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))::regclass,
|
|
985
|
+
c.ordinal_position
|
|
986
|
+
) as description
|
|
987
|
+
FROM information_schema.columns c
|
|
988
|
+
WHERE c.table_schema = 'public'
|
|
989
|
+
AND c.table_name = $1
|
|
990
|
+
{columns_filter}
|
|
991
|
+
ORDER BY c.ordinal_position
|
|
992
|
+
"""
|
|
993
|
+
|
|
994
|
+
params = [table_name]
|
|
995
|
+
if columns:
|
|
996
|
+
params.extend(columns)
|
|
997
|
+
|
|
998
|
+
column_rows = await postgres_service.fetch(columns_query, *params)
|
|
999
|
+
|
|
1000
|
+
column_defs = []
|
|
1001
|
+
for row in column_rows:
|
|
1002
|
+
# Build a more readable type string
|
|
1003
|
+
data_type = row["data_type"]
|
|
1004
|
+
if row["character_maximum_length"]:
|
|
1005
|
+
data_type = f"{data_type}({row['character_maximum_length']})"
|
|
1006
|
+
elif row["udt_name"] in ("int4", "int8", "float4", "float8"):
|
|
1007
|
+
# Use common type names
|
|
1008
|
+
type_map = {"int4": "integer", "int8": "bigint", "float4": "real", "float8": "double precision"}
|
|
1009
|
+
data_type = type_map.get(row["udt_name"], data_type)
|
|
1010
|
+
elif row["udt_name"] == "vector":
|
|
1011
|
+
data_type = "vector"
|
|
1012
|
+
|
|
1013
|
+
column_defs.append({
|
|
1014
|
+
"name": row["column_name"],
|
|
1015
|
+
"type": data_type,
|
|
1016
|
+
"nullable": row["is_nullable"] == "YES",
|
|
1017
|
+
"default": row["column_default"],
|
|
1018
|
+
"description": row["description"],
|
|
1019
|
+
})
|
|
1020
|
+
|
|
1021
|
+
result = {
|
|
1022
|
+
"table_name": table_name,
|
|
1023
|
+
"columns": column_defs,
|
|
1024
|
+
"column_count": len(column_defs),
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
# Get primary key
|
|
1028
|
+
pk_query = """
|
|
1029
|
+
SELECT a.attname as column_name
|
|
1030
|
+
FROM pg_index i
|
|
1031
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
1032
|
+
WHERE i.indrelid = $1::regclass
|
|
1033
|
+
AND i.indisprimary
|
|
1034
|
+
ORDER BY array_position(i.indkey, a.attnum)
|
|
1035
|
+
"""
|
|
1036
|
+
pk_rows = await postgres_service.fetch(pk_query, table_name)
|
|
1037
|
+
result["primary_key"] = [row["column_name"] for row in pk_rows]
|
|
1038
|
+
|
|
1039
|
+
# Get indexes
|
|
1040
|
+
if include_indexes:
|
|
1041
|
+
indexes_query = """
|
|
1042
|
+
SELECT
|
|
1043
|
+
i.relname as index_name,
|
|
1044
|
+
am.amname as index_type,
|
|
1045
|
+
ix.indisunique as is_unique,
|
|
1046
|
+
ix.indisprimary as is_primary,
|
|
1047
|
+
array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns
|
|
1048
|
+
FROM pg_index ix
|
|
1049
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
1050
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
1051
|
+
JOIN pg_am am ON am.oid = i.relam
|
|
1052
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
|
|
1053
|
+
WHERE t.relname = $1
|
|
1054
|
+
GROUP BY i.relname, am.amname, ix.indisunique, ix.indisprimary
|
|
1055
|
+
ORDER BY i.relname
|
|
1056
|
+
"""
|
|
1057
|
+
index_rows = await postgres_service.fetch(indexes_query, table_name)
|
|
1058
|
+
result["indexes"] = [
|
|
1059
|
+
{
|
|
1060
|
+
"name": row["index_name"],
|
|
1061
|
+
"type": row["index_type"],
|
|
1062
|
+
"unique": row["is_unique"],
|
|
1063
|
+
"primary": row["is_primary"],
|
|
1064
|
+
"columns": row["columns"],
|
|
1065
|
+
}
|
|
1066
|
+
for row in index_rows
|
|
1067
|
+
]
|
|
1068
|
+
|
|
1069
|
+
# Get constraints
|
|
1070
|
+
if include_constraints:
|
|
1071
|
+
constraints_query = """
|
|
1072
|
+
SELECT
|
|
1073
|
+
con.conname as constraint_name,
|
|
1074
|
+
con.contype as constraint_type,
|
|
1075
|
+
array_agg(a.attname ORDER BY array_position(con.conkey, a.attnum)) as columns,
|
|
1076
|
+
pg_get_constraintdef(con.oid) as definition
|
|
1077
|
+
FROM pg_constraint con
|
|
1078
|
+
JOIN pg_class t ON t.oid = con.conrelid
|
|
1079
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(con.conkey)
|
|
1080
|
+
WHERE t.relname = $1
|
|
1081
|
+
GROUP BY con.conname, con.contype, con.oid
|
|
1082
|
+
ORDER BY con.contype, con.conname
|
|
1083
|
+
"""
|
|
1084
|
+
constraint_rows = await postgres_service.fetch(constraints_query, table_name)
|
|
1085
|
+
|
|
1086
|
+
# Map constraint types to readable names
|
|
1087
|
+
type_map = {
|
|
1088
|
+
"p": "PRIMARY KEY",
|
|
1089
|
+
"u": "UNIQUE",
|
|
1090
|
+
"f": "FOREIGN KEY",
|
|
1091
|
+
"c": "CHECK",
|
|
1092
|
+
"x": "EXCLUSION",
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
result["constraints"] = []
|
|
1096
|
+
for row in constraint_rows:
|
|
1097
|
+
# contype is returned as bytes (char type), decode it
|
|
1098
|
+
con_type = row["constraint_type"]
|
|
1099
|
+
if isinstance(con_type, bytes):
|
|
1100
|
+
con_type = con_type.decode("utf-8")
|
|
1101
|
+
result["constraints"].append({
|
|
1102
|
+
"name": row["constraint_name"],
|
|
1103
|
+
"type": type_map.get(con_type, con_type),
|
|
1104
|
+
"columns": row["columns"],
|
|
1105
|
+
"definition": row["definition"],
|
|
1106
|
+
})
|
|
1107
|
+
|
|
1108
|
+
logger.info(f"Retrieved schema for table '{table_name}' with {len(column_defs)} columns")
|
|
1109
|
+
|
|
1110
|
+
return result
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
@mcp_tool_error_handler
|
|
1114
|
+
async def save_agent(
|
|
1115
|
+
name: str,
|
|
1116
|
+
description: str,
|
|
1117
|
+
properties: dict[str, Any] | None = None,
|
|
1118
|
+
required: list[str] | None = None,
|
|
1119
|
+
tools: list[str] | None = None,
|
|
1120
|
+
tags: list[str] | None = None,
|
|
1121
|
+
version: str = "1.0.0",
|
|
1122
|
+
user_id: str | None = None,
|
|
1123
|
+
) -> dict[str, Any]:
|
|
1124
|
+
"""
|
|
1125
|
+
Save an agent schema to REM, making it available for use.
|
|
1126
|
+
|
|
1127
|
+
This tool creates or updates an agent definition in the user's schema space.
|
|
1128
|
+
The agent becomes immediately available for conversations.
|
|
1129
|
+
|
|
1130
|
+
**Default Tools**: All agents automatically get `search_rem` and `register_metadata`
|
|
1131
|
+
tools unless explicitly overridden.
|
|
1132
|
+
|
|
1133
|
+
Args:
|
|
1134
|
+
name: Agent name in kebab-case (e.g., "code-reviewer", "sales-assistant").
|
|
1135
|
+
Must be unique within the user's schema space.
|
|
1136
|
+
description: The agent's system prompt. This is the full instruction set
|
|
1137
|
+
that defines the agent's behavior, personality, and capabilities.
|
|
1138
|
+
Use markdown formatting for structure.
|
|
1139
|
+
properties: Output schema properties as a dict. Each property should have:
|
|
1140
|
+
- type: "string", "number", "boolean", "array", "object"
|
|
1141
|
+
- description: What this field captures
|
|
1142
|
+
Example: {"answer": {"type": "string", "description": "Response to user"}}
|
|
1143
|
+
If not provided, defaults to a simple {"answer": {"type": "string"}} schema.
|
|
1144
|
+
required: List of required property names. Defaults to ["answer"] if not provided.
|
|
1145
|
+
tools: List of tool names the agent can use. Defaults to ["search_rem", "register_metadata"].
|
|
1146
|
+
tags: Optional tags for categorizing the agent.
|
|
1147
|
+
version: Semantic version string (default: "1.0.0").
|
|
1148
|
+
user_id: User identifier for scoping. Uses authenticated user if not provided.
|
|
1149
|
+
|
|
1150
|
+
Returns:
|
|
1151
|
+
Dict with:
|
|
1152
|
+
- status: "success" or "error"
|
|
1153
|
+
- agent_name: Name of the saved agent
|
|
1154
|
+
- version: Version saved
|
|
1155
|
+
- message: Human-readable status
|
|
1156
|
+
|
|
1157
|
+
Examples:
|
|
1158
|
+
# Create a simple agent
|
|
1159
|
+
save_agent(
|
|
1160
|
+
name="greeting-bot",
|
|
1161
|
+
description="You are a friendly greeter. Say hello warmly.",
|
|
1162
|
+
properties={"answer": {"type": "string", "description": "Greeting message"}},
|
|
1163
|
+
required=["answer"]
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
# Create agent with structured output
|
|
1167
|
+
save_agent(
|
|
1168
|
+
name="sentiment-analyzer",
|
|
1169
|
+
description="Analyze sentiment of text provided by the user.",
|
|
1170
|
+
properties={
|
|
1171
|
+
"answer": {"type": "string", "description": "Analysis explanation"},
|
|
1172
|
+
"sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
|
|
1173
|
+
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
|
1174
|
+
},
|
|
1175
|
+
required=["answer", "sentiment"],
|
|
1176
|
+
tags=["analysis", "nlp"]
|
|
1177
|
+
)
|
|
1178
|
+
"""
|
|
1179
|
+
from ...agentic.agents.agent_manager import save_agent as _save_agent
|
|
1180
|
+
|
|
1181
|
+
# Get user_id from context if not provided
|
|
1182
|
+
user_id = AgentContext.get_user_id_or_default(user_id, source="save_agent")
|
|
1183
|
+
|
|
1184
|
+
# Delegate to agent_manager
|
|
1185
|
+
result = await _save_agent(
|
|
1186
|
+
name=name,
|
|
1187
|
+
description=description,
|
|
1188
|
+
user_id=user_id,
|
|
1189
|
+
properties=properties,
|
|
1190
|
+
required=required,
|
|
1191
|
+
tools=tools,
|
|
1192
|
+
tags=tags,
|
|
1193
|
+
version=version,
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
# Add helpful message for Slack users
|
|
1197
|
+
if result.get("status") == "success":
|
|
1198
|
+
result["message"] = f"Agent '{name}' saved. Use `/custom-agent {name}` to chat with it."
|
|
1199
|
+
|
|
1200
|
+
return result
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
# =============================================================================
|
|
1204
|
+
# Multi-Agent Tools
|
|
1205
|
+
# =============================================================================
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@mcp_tool_error_handler
|
|
1209
|
+
async def ask_agent(
|
|
1210
|
+
agent_name: str,
|
|
1211
|
+
input_text: str,
|
|
1212
|
+
input_data: dict[str, Any] | None = None,
|
|
1213
|
+
user_id: str | None = None,
|
|
1214
|
+
timeout_seconds: int = 300,
|
|
1215
|
+
) -> dict[str, Any]:
|
|
1216
|
+
"""
|
|
1217
|
+
Invoke another agent by name and return its response.
|
|
1218
|
+
|
|
1219
|
+
This tool enables multi-agent orchestration by allowing one agent to call
|
|
1220
|
+
another. The child agent inherits the parent's context (user_id, session_id,
|
|
1221
|
+
tenant_id, is_eval) for proper scoping and continuity.
|
|
1222
|
+
|
|
1223
|
+
Use Cases:
|
|
1224
|
+
- Orchestrator agents that delegate to specialized sub-agents
|
|
1225
|
+
- Workflow agents that chain multiple processing steps
|
|
1226
|
+
- Ensemble agents that aggregate responses from multiple specialists
|
|
1227
|
+
|
|
1228
|
+
Args:
|
|
1229
|
+
agent_name: Name of the agent to invoke. Can be:
|
|
1230
|
+
- A user-created agent (saved via save_agent)
|
|
1231
|
+
- A system agent (e.g., "ask_rem", "knowledge-query")
|
|
1232
|
+
input_text: The user message/query to send to the agent
|
|
1233
|
+
input_data: Optional structured input data for the agent
|
|
1234
|
+
user_id: Optional user override (defaults to parent's user_id)
|
|
1235
|
+
timeout_seconds: Maximum execution time (default: 300s)
|
|
1236
|
+
|
|
1237
|
+
Returns:
|
|
1238
|
+
Dict with:
|
|
1239
|
+
- status: "success" or "error"
|
|
1240
|
+
- output: Agent's structured output (if using output schema)
|
|
1241
|
+
- text_response: Agent's text response
|
|
1242
|
+
- agent_schema: Name of the invoked agent
|
|
1243
|
+
- metadata: Any metadata registered by the agent (confidence, etc.)
|
|
1244
|
+
|
|
1245
|
+
Examples:
|
|
1246
|
+
# Simple delegation
|
|
1247
|
+
ask_agent(
|
|
1248
|
+
agent_name="sentiment-analyzer",
|
|
1249
|
+
input_text="I love this product! Best purchase ever."
|
|
1250
|
+
)
|
|
1251
|
+
# Returns: {"status": "success", "output": {"sentiment": "positive"}, ...}
|
|
1252
|
+
|
|
1253
|
+
# Orchestrator pattern
|
|
1254
|
+
ask_agent(
|
|
1255
|
+
agent_name="knowledge-query",
|
|
1256
|
+
input_text="What are the latest Q3 results?"
|
|
1257
|
+
)
|
|
1258
|
+
|
|
1259
|
+
# Chain with structured input
|
|
1260
|
+
ask_agent(
|
|
1261
|
+
agent_name="summarizer",
|
|
1262
|
+
input_text="Summarize this document",
|
|
1263
|
+
input_data={"document_id": "doc-123", "max_length": 500}
|
|
1264
|
+
)
|
|
1265
|
+
"""
|
|
1266
|
+
import asyncio
|
|
1267
|
+
from ...agentic import create_agent
|
|
1268
|
+
from ...agentic.context import get_current_context, agent_context_scope, get_event_sink, push_event
|
|
1269
|
+
from ...agentic.agents.agent_manager import get_agent
|
|
1270
|
+
from ...utils.schema_loader import load_agent_schema
|
|
1271
|
+
|
|
1272
|
+
# Get parent context for inheritance
|
|
1273
|
+
parent_context = get_current_context()
|
|
1274
|
+
|
|
1275
|
+
# Determine effective user_id
|
|
1276
|
+
if parent_context is not None:
|
|
1277
|
+
effective_user_id = user_id or parent_context.user_id
|
|
1278
|
+
else:
|
|
1279
|
+
effective_user_id = AgentContext.get_user_id_or_default(
|
|
1280
|
+
user_id, source="ask_agent"
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
# Build child context
|
|
1284
|
+
if parent_context is not None:
|
|
1285
|
+
child_context = parent_context.child_context(agent_schema_uri=agent_name)
|
|
1286
|
+
if user_id is not None:
|
|
1287
|
+
# Explicit user_id override
|
|
1288
|
+
child_context = AgentContext(
|
|
1289
|
+
user_id=user_id,
|
|
1290
|
+
tenant_id=parent_context.tenant_id,
|
|
1291
|
+
session_id=parent_context.session_id,
|
|
1292
|
+
default_model=parent_context.default_model,
|
|
1293
|
+
agent_schema_uri=agent_name,
|
|
1294
|
+
is_eval=parent_context.is_eval,
|
|
1295
|
+
)
|
|
1296
|
+
logger.debug(
|
|
1297
|
+
f"ask_agent '{agent_name}' inheriting context: "
|
|
1298
|
+
f"user_id={child_context.user_id}, session_id={child_context.session_id}"
|
|
1299
|
+
)
|
|
1300
|
+
else:
|
|
1301
|
+
child_context = AgentContext(
|
|
1302
|
+
user_id=effective_user_id,
|
|
1303
|
+
tenant_id=effective_user_id or "default",
|
|
1304
|
+
default_model=settings.llm.default_model,
|
|
1305
|
+
agent_schema_uri=agent_name,
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
# Try to load agent schema from:
|
|
1309
|
+
# 1. Database (user-created or system agents)
|
|
1310
|
+
# 2. File system (packaged agents)
|
|
1311
|
+
schema = None
|
|
1312
|
+
|
|
1313
|
+
# Try database first
|
|
1314
|
+
if effective_user_id:
|
|
1315
|
+
schema = await get_agent(agent_name, user_id=effective_user_id)
|
|
1316
|
+
if schema:
|
|
1317
|
+
logger.debug(f"Loaded agent '{agent_name}' from database")
|
|
1318
|
+
|
|
1319
|
+
# Fall back to file system
|
|
1320
|
+
if schema is None:
|
|
1321
|
+
try:
|
|
1322
|
+
schema = load_agent_schema(agent_name)
|
|
1323
|
+
logger.debug(f"Loaded agent '{agent_name}' from file system")
|
|
1324
|
+
except FileNotFoundError:
|
|
1325
|
+
pass
|
|
1326
|
+
|
|
1327
|
+
if schema is None:
|
|
1328
|
+
return {
|
|
1329
|
+
"status": "error",
|
|
1330
|
+
"error": f"Agent not found: {agent_name}",
|
|
1331
|
+
"hint": "Use list_agents to see available agents, or save_agent to create one",
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
# Create agent runtime
|
|
1335
|
+
agent_runtime = await create_agent(
|
|
1336
|
+
context=child_context,
|
|
1337
|
+
agent_schema_override=schema,
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
# Build prompt with optional input_data
|
|
1341
|
+
prompt = input_text
|
|
1342
|
+
if input_data:
|
|
1343
|
+
prompt = f"{input_text}\n\nInput data: {json.dumps(input_data)}"
|
|
1344
|
+
|
|
1345
|
+
# Load session history for the sub-agent (CRITICAL for multi-turn conversations)
|
|
1346
|
+
# Sub-agents need to see the full conversation context, not just the summary
|
|
1347
|
+
pydantic_message_history = None
|
|
1348
|
+
if child_context.session_id and settings.postgres.enabled:
|
|
1349
|
+
try:
|
|
1350
|
+
from ...services.session import SessionMessageStore, session_to_pydantic_messages
|
|
1351
|
+
from ...agentic.schema import get_system_prompt
|
|
1352
|
+
|
|
1353
|
+
store = SessionMessageStore(user_id=child_context.user_id or "default")
|
|
1354
|
+
raw_session_history = await store.load_session_messages(
|
|
1355
|
+
session_id=child_context.session_id,
|
|
1356
|
+
user_id=child_context.user_id,
|
|
1357
|
+
compress_on_load=False, # Need full data for reconstruction
|
|
1358
|
+
)
|
|
1359
|
+
if raw_session_history:
|
|
1360
|
+
# Extract agent's system prompt from schema
|
|
1361
|
+
agent_system_prompt = get_system_prompt(schema) if schema else None
|
|
1362
|
+
pydantic_message_history = session_to_pydantic_messages(
|
|
1363
|
+
raw_session_history,
|
|
1364
|
+
system_prompt=agent_system_prompt,
|
|
1365
|
+
)
|
|
1366
|
+
logger.debug(
|
|
1367
|
+
f"ask_agent '{agent_name}': loaded {len(raw_session_history)} session messages "
|
|
1368
|
+
f"-> {len(pydantic_message_history)} pydantic-ai messages"
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
# Audit session history if enabled
|
|
1372
|
+
from ...services.session import audit_session_history
|
|
1373
|
+
audit_session_history(
|
|
1374
|
+
session_id=child_context.session_id,
|
|
1375
|
+
agent_name=agent_name,
|
|
1376
|
+
prompt=prompt,
|
|
1377
|
+
raw_session_history=raw_session_history,
|
|
1378
|
+
pydantic_messages_count=len(pydantic_message_history),
|
|
1379
|
+
)
|
|
1380
|
+
except Exception as e:
|
|
1381
|
+
logger.warning(f"ask_agent '{agent_name}': failed to load session history: {e}")
|
|
1382
|
+
# Fall back to running without history
|
|
1383
|
+
|
|
1384
|
+
# Run agent with timeout and context propagation
|
|
1385
|
+
logger.info(f"Invoking agent '{agent_name}' with prompt: {prompt[:100]}...")
|
|
1386
|
+
|
|
1387
|
+
# Check if we have an event sink for streaming
|
|
1388
|
+
push_event = get_event_sink()
|
|
1389
|
+
use_streaming = push_event is not None
|
|
1390
|
+
|
|
1391
|
+
streamed_content = "" # Track if content was streamed
|
|
1392
|
+
|
|
1393
|
+
try:
|
|
1394
|
+
# Set child context for nested tool calls
|
|
1395
|
+
with agent_context_scope(child_context):
|
|
1396
|
+
if use_streaming:
|
|
1397
|
+
# STREAMING MODE: Use iter() and proxy events to parent
|
|
1398
|
+
logger.debug(f"ask_agent '{agent_name}': using streaming mode with event proxying")
|
|
1399
|
+
|
|
1400
|
+
async def run_with_streaming():
|
|
1401
|
+
from pydantic_ai.messages import (
|
|
1402
|
+
PartStartEvent, PartDeltaEvent, PartEndEvent,
|
|
1403
|
+
FunctionToolResultEvent, FunctionToolCallEvent,
|
|
1404
|
+
)
|
|
1405
|
+
from pydantic_ai.agent import Agent
|
|
1406
|
+
|
|
1407
|
+
accumulated_content = []
|
|
1408
|
+
child_tool_calls = []
|
|
1409
|
+
|
|
1410
|
+
# iter() returns an async context manager, not an awaitable
|
|
1411
|
+
iter_kwargs = {"message_history": pydantic_message_history} if pydantic_message_history else {}
|
|
1412
|
+
async with agent_runtime.iter(prompt, **iter_kwargs) as agent_run:
|
|
1413
|
+
async for node in agent_run:
|
|
1414
|
+
if Agent.is_model_request_node(node):
|
|
1415
|
+
async with node.stream(agent_run.ctx) as request_stream:
|
|
1416
|
+
async for event in request_stream:
|
|
1417
|
+
# Proxy part starts (text content only - tool calls handled in is_call_tools_node)
|
|
1418
|
+
if isinstance(event, PartStartEvent):
|
|
1419
|
+
from pydantic_ai.messages import ToolCallPart, TextPart
|
|
1420
|
+
if isinstance(event.part, ToolCallPart):
|
|
1421
|
+
# Track tool call for later (args are incomplete at PartStartEvent)
|
|
1422
|
+
# Full args come via FunctionToolCallEvent in is_call_tools_node
|
|
1423
|
+
child_tool_calls.append({
|
|
1424
|
+
"tool_name": event.part.tool_name,
|
|
1425
|
+
"index": event.index,
|
|
1426
|
+
})
|
|
1427
|
+
elif isinstance(event.part, TextPart):
|
|
1428
|
+
# TextPart may have initial content
|
|
1429
|
+
if event.part.content:
|
|
1430
|
+
accumulated_content.append(event.part.content)
|
|
1431
|
+
await push_event.put({
|
|
1432
|
+
"type": "child_content",
|
|
1433
|
+
"agent_name": agent_name,
|
|
1434
|
+
"content": event.part.content,
|
|
1435
|
+
})
|
|
1436
|
+
# Proxy text content deltas to parent for real-time streaming
|
|
1437
|
+
elif isinstance(event, PartDeltaEvent):
|
|
1438
|
+
if hasattr(event, 'delta') and hasattr(event.delta, 'content_delta'):
|
|
1439
|
+
content = event.delta.content_delta
|
|
1440
|
+
if content:
|
|
1441
|
+
accumulated_content.append(content)
|
|
1442
|
+
# Push content chunk to parent for streaming
|
|
1443
|
+
await push_event.put({
|
|
1444
|
+
"type": "child_content",
|
|
1445
|
+
"agent_name": agent_name,
|
|
1446
|
+
"content": content,
|
|
1447
|
+
})
|
|
1448
|
+
|
|
1449
|
+
elif Agent.is_call_tools_node(node):
|
|
1450
|
+
async with node.stream(agent_run.ctx) as tools_stream:
|
|
1451
|
+
async for tool_event in tools_stream:
|
|
1452
|
+
# FunctionToolCallEvent fires when tool call is parsed
|
|
1453
|
+
# with complete arguments (before execution)
|
|
1454
|
+
if isinstance(tool_event, FunctionToolCallEvent):
|
|
1455
|
+
# Get full arguments from completed tool call
|
|
1456
|
+
tool_args = None
|
|
1457
|
+
if hasattr(tool_event, 'part') and hasattr(tool_event.part, 'args'):
|
|
1458
|
+
raw_args = tool_event.part.args
|
|
1459
|
+
if isinstance(raw_args, str):
|
|
1460
|
+
try:
|
|
1461
|
+
tool_args = json.loads(raw_args)
|
|
1462
|
+
except json.JSONDecodeError:
|
|
1463
|
+
tool_args = {"raw": raw_args}
|
|
1464
|
+
elif isinstance(raw_args, dict):
|
|
1465
|
+
tool_args = raw_args
|
|
1466
|
+
# Push tool start with full arguments
|
|
1467
|
+
await push_event.put({
|
|
1468
|
+
"type": "child_tool_start",
|
|
1469
|
+
"agent_name": agent_name,
|
|
1470
|
+
"tool_name": tool_event.part.tool_name if hasattr(tool_event, 'part') else "unknown",
|
|
1471
|
+
"arguments": tool_args,
|
|
1472
|
+
})
|
|
1473
|
+
elif isinstance(tool_event, FunctionToolResultEvent):
|
|
1474
|
+
result_content = tool_event.result.content if hasattr(tool_event.result, 'content') else tool_event.result
|
|
1475
|
+
# Push tool result to parent
|
|
1476
|
+
await push_event.put({
|
|
1477
|
+
"type": "child_tool_result",
|
|
1478
|
+
"agent_name": agent_name,
|
|
1479
|
+
"result": result_content,
|
|
1480
|
+
})
|
|
1481
|
+
|
|
1482
|
+
# Get final result (inside context manager)
|
|
1483
|
+
return agent_run.result, "".join(accumulated_content), child_tool_calls
|
|
1484
|
+
|
|
1485
|
+
result, streamed_content, tool_calls = await asyncio.wait_for(
|
|
1486
|
+
run_with_streaming(),
|
|
1487
|
+
timeout=timeout_seconds
|
|
1488
|
+
)
|
|
1489
|
+
else:
|
|
1490
|
+
# NON-STREAMING MODE: Use run() for backwards compatibility
|
|
1491
|
+
if pydantic_message_history:
|
|
1492
|
+
result = await asyncio.wait_for(
|
|
1493
|
+
agent_runtime.run(prompt, message_history=pydantic_message_history),
|
|
1494
|
+
timeout=timeout_seconds
|
|
1495
|
+
)
|
|
1496
|
+
else:
|
|
1497
|
+
result = await asyncio.wait_for(
|
|
1498
|
+
agent_runtime.run(prompt),
|
|
1499
|
+
timeout=timeout_seconds
|
|
1500
|
+
)
|
|
1501
|
+
except asyncio.TimeoutError:
|
|
1502
|
+
return {
|
|
1503
|
+
"status": "error",
|
|
1504
|
+
"error": f"Agent '{agent_name}' timed out after {timeout_seconds}s",
|
|
1505
|
+
"agent_schema": agent_name,
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
# Serialize output
|
|
1509
|
+
from rem.agentic.serialization import serialize_agent_result
|
|
1510
|
+
output = serialize_agent_result(result.output)
|
|
1511
|
+
|
|
1512
|
+
logger.info(f"Agent '{agent_name}' completed successfully")
|
|
1513
|
+
|
|
1514
|
+
response = {
|
|
1515
|
+
"status": "success",
|
|
1516
|
+
"output": output,
|
|
1517
|
+
"agent_schema": agent_name,
|
|
1518
|
+
"input_text": input_text,
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
# Only include text_response if content was NOT streamed
|
|
1522
|
+
# When streaming, child_content events already delivered the content
|
|
1523
|
+
if not use_streaming or not streamed_content:
|
|
1524
|
+
response["text_response"] = str(result.output)
|
|
1525
|
+
|
|
1526
|
+
return response
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
# =============================================================================
|
|
1530
|
+
# Test/Debug Tools (for development only)
|
|
1531
|
+
# =============================================================================
|
|
1532
|
+
|
|
1533
|
+
@mcp_tool_error_handler
|
|
1534
|
+
async def test_error_handling(
|
|
1535
|
+
error_type: Literal["exception", "error_response", "timeout", "success"] = "success",
|
|
1536
|
+
delay_seconds: float = 0,
|
|
1537
|
+
error_message: str = "Test error occurred",
|
|
1538
|
+
) -> dict[str, Any]:
|
|
1539
|
+
"""
|
|
1540
|
+
Test tool for simulating different error scenarios.
|
|
1541
|
+
|
|
1542
|
+
**FOR DEVELOPMENT/TESTING ONLY** - This tool helps verify that error
|
|
1543
|
+
handling works correctly through the streaming layer.
|
|
1544
|
+
|
|
1545
|
+
Args:
|
|
1546
|
+
error_type: Type of error to simulate:
|
|
1547
|
+
- "success": Returns successful response (default)
|
|
1548
|
+
- "exception": Raises an exception (tests @mcp_tool_error_handler)
|
|
1549
|
+
- "error_response": Returns {"status": "error", ...} dict
|
|
1550
|
+
- "timeout": Delays for 60 seconds (simulates timeout)
|
|
1551
|
+
delay_seconds: Optional delay before responding (0-10 seconds)
|
|
1552
|
+
error_message: Custom error message for error scenarios
|
|
1553
|
+
|
|
1554
|
+
Returns:
|
|
1555
|
+
Dict with test results or error information
|
|
1556
|
+
|
|
1557
|
+
Examples:
|
|
1558
|
+
# Test successful response
|
|
1559
|
+
test_error_handling(error_type="success")
|
|
1560
|
+
|
|
1561
|
+
# Test exception handling
|
|
1562
|
+
test_error_handling(error_type="exception", error_message="Database connection failed")
|
|
1563
|
+
|
|
1564
|
+
# Test error response format
|
|
1565
|
+
test_error_handling(error_type="error_response", error_message="Resource not found")
|
|
1566
|
+
|
|
1567
|
+
# Test with delay
|
|
1568
|
+
test_error_handling(error_type="success", delay_seconds=2)
|
|
1569
|
+
"""
|
|
1570
|
+
import asyncio
|
|
1571
|
+
|
|
1572
|
+
logger.info(f"test_error_handling called: type={error_type}, delay={delay_seconds}")
|
|
1573
|
+
|
|
1574
|
+
# Apply delay (capped at 10 seconds for safety)
|
|
1575
|
+
if delay_seconds > 0:
|
|
1576
|
+
await asyncio.sleep(min(delay_seconds, 10))
|
|
1577
|
+
|
|
1578
|
+
if error_type == "exception":
|
|
1579
|
+
# This tests the @mcp_tool_error_handler decorator
|
|
1580
|
+
raise RuntimeError(f"TEST EXCEPTION: {error_message}")
|
|
1581
|
+
|
|
1582
|
+
elif error_type == "error_response":
|
|
1583
|
+
# This tests how the streaming layer handles error status responses
|
|
1584
|
+
return {
|
|
1585
|
+
"status": "error",
|
|
1586
|
+
"error": error_message,
|
|
1587
|
+
"error_code": "TEST_ERROR",
|
|
1588
|
+
"recoverable": True,
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
elif error_type == "timeout":
|
|
1592
|
+
# Simulate a very long operation (for testing client-side timeouts)
|
|
1593
|
+
await asyncio.sleep(60)
|
|
1594
|
+
return {"status": "success", "message": "Timeout test completed (should not reach here)"}
|
|
1595
|
+
|
|
1596
|
+
else: # success
|
|
1597
|
+
return {
|
|
1598
|
+
"status": "success",
|
|
1599
|
+
"message": "Test completed successfully",
|
|
1600
|
+
"test_data": {
|
|
1601
|
+
"error_type": error_type,
|
|
1602
|
+
"delay_applied": delay_seconds,
|
|
1603
|
+
"timestamp": str(asyncio.get_event_loop().time()),
|
|
1604
|
+
},
|
|
1605
|
+
}
|