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.

Files changed (235) hide show
  1. rem/__init__.py +129 -0
  2. rem/agentic/README.md +760 -0
  3. rem/agentic/__init__.py +54 -0
  4. rem/agentic/agents/README.md +155 -0
  5. rem/agentic/agents/__init__.py +38 -0
  6. rem/agentic/agents/agent_manager.py +311 -0
  7. rem/agentic/agents/sse_simulator.py +502 -0
  8. rem/agentic/context.py +425 -0
  9. rem/agentic/context_builder.py +360 -0
  10. rem/agentic/llm_provider_models.py +301 -0
  11. rem/agentic/mcp/__init__.py +0 -0
  12. rem/agentic/mcp/tool_wrapper.py +273 -0
  13. rem/agentic/otel/__init__.py +5 -0
  14. rem/agentic/otel/setup.py +240 -0
  15. rem/agentic/providers/phoenix.py +926 -0
  16. rem/agentic/providers/pydantic_ai.py +854 -0
  17. rem/agentic/query.py +117 -0
  18. rem/agentic/query_helper.py +89 -0
  19. rem/agentic/schema.py +737 -0
  20. rem/agentic/serialization.py +245 -0
  21. rem/agentic/tools/__init__.py +5 -0
  22. rem/agentic/tools/rem_tools.py +242 -0
  23. rem/api/README.md +657 -0
  24. rem/api/deps.py +253 -0
  25. rem/api/main.py +460 -0
  26. rem/api/mcp_router/prompts.py +182 -0
  27. rem/api/mcp_router/resources.py +820 -0
  28. rem/api/mcp_router/server.py +243 -0
  29. rem/api/mcp_router/tools.py +1605 -0
  30. rem/api/middleware/tracking.py +172 -0
  31. rem/api/routers/admin.py +520 -0
  32. rem/api/routers/auth.py +898 -0
  33. rem/api/routers/chat/__init__.py +5 -0
  34. rem/api/routers/chat/child_streaming.py +394 -0
  35. rem/api/routers/chat/completions.py +702 -0
  36. rem/api/routers/chat/json_utils.py +76 -0
  37. rem/api/routers/chat/models.py +202 -0
  38. rem/api/routers/chat/otel_utils.py +33 -0
  39. rem/api/routers/chat/sse_events.py +546 -0
  40. rem/api/routers/chat/streaming.py +950 -0
  41. rem/api/routers/chat/streaming_utils.py +327 -0
  42. rem/api/routers/common.py +18 -0
  43. rem/api/routers/dev.py +87 -0
  44. rem/api/routers/feedback.py +276 -0
  45. rem/api/routers/messages.py +620 -0
  46. rem/api/routers/models.py +86 -0
  47. rem/api/routers/query.py +362 -0
  48. rem/api/routers/shared_sessions.py +422 -0
  49. rem/auth/README.md +258 -0
  50. rem/auth/__init__.py +36 -0
  51. rem/auth/jwt.py +367 -0
  52. rem/auth/middleware.py +318 -0
  53. rem/auth/providers/__init__.py +16 -0
  54. rem/auth/providers/base.py +376 -0
  55. rem/auth/providers/email.py +215 -0
  56. rem/auth/providers/google.py +163 -0
  57. rem/auth/providers/microsoft.py +237 -0
  58. rem/cli/README.md +517 -0
  59. rem/cli/__init__.py +8 -0
  60. rem/cli/commands/README.md +299 -0
  61. rem/cli/commands/__init__.py +3 -0
  62. rem/cli/commands/ask.py +549 -0
  63. rem/cli/commands/cluster.py +1808 -0
  64. rem/cli/commands/configure.py +495 -0
  65. rem/cli/commands/db.py +828 -0
  66. rem/cli/commands/dreaming.py +324 -0
  67. rem/cli/commands/experiments.py +1698 -0
  68. rem/cli/commands/mcp.py +66 -0
  69. rem/cli/commands/process.py +388 -0
  70. rem/cli/commands/query.py +109 -0
  71. rem/cli/commands/scaffold.py +47 -0
  72. rem/cli/commands/schema.py +230 -0
  73. rem/cli/commands/serve.py +106 -0
  74. rem/cli/commands/session.py +453 -0
  75. rem/cli/dreaming.py +363 -0
  76. rem/cli/main.py +123 -0
  77. rem/config.py +244 -0
  78. rem/mcp_server.py +41 -0
  79. rem/models/core/__init__.py +49 -0
  80. rem/models/core/core_model.py +70 -0
  81. rem/models/core/engram.py +333 -0
  82. rem/models/core/experiment.py +672 -0
  83. rem/models/core/inline_edge.py +132 -0
  84. rem/models/core/rem_query.py +246 -0
  85. rem/models/entities/__init__.py +68 -0
  86. rem/models/entities/domain_resource.py +38 -0
  87. rem/models/entities/feedback.py +123 -0
  88. rem/models/entities/file.py +57 -0
  89. rem/models/entities/image_resource.py +88 -0
  90. rem/models/entities/message.py +64 -0
  91. rem/models/entities/moment.py +123 -0
  92. rem/models/entities/ontology.py +181 -0
  93. rem/models/entities/ontology_config.py +131 -0
  94. rem/models/entities/resource.py +95 -0
  95. rem/models/entities/schema.py +87 -0
  96. rem/models/entities/session.py +84 -0
  97. rem/models/entities/shared_session.py +180 -0
  98. rem/models/entities/subscriber.py +175 -0
  99. rem/models/entities/user.py +93 -0
  100. rem/py.typed +0 -0
  101. rem/registry.py +373 -0
  102. rem/schemas/README.md +507 -0
  103. rem/schemas/__init__.py +6 -0
  104. rem/schemas/agents/README.md +92 -0
  105. rem/schemas/agents/core/agent-builder.yaml +235 -0
  106. rem/schemas/agents/core/moment-builder.yaml +178 -0
  107. rem/schemas/agents/core/rem-query-agent.yaml +226 -0
  108. rem/schemas/agents/core/resource-affinity-assessor.yaml +99 -0
  109. rem/schemas/agents/core/simple-assistant.yaml +19 -0
  110. rem/schemas/agents/core/user-profile-builder.yaml +163 -0
  111. rem/schemas/agents/examples/contract-analyzer.yaml +317 -0
  112. rem/schemas/agents/examples/contract-extractor.yaml +134 -0
  113. rem/schemas/agents/examples/cv-parser.yaml +263 -0
  114. rem/schemas/agents/examples/hello-world.yaml +37 -0
  115. rem/schemas/agents/examples/query.yaml +54 -0
  116. rem/schemas/agents/examples/simple.yaml +21 -0
  117. rem/schemas/agents/examples/test.yaml +29 -0
  118. rem/schemas/agents/rem.yaml +132 -0
  119. rem/schemas/evaluators/hello-world/default.yaml +77 -0
  120. rem/schemas/evaluators/rem/faithfulness.yaml +219 -0
  121. rem/schemas/evaluators/rem/lookup-correctness.yaml +182 -0
  122. rem/schemas/evaluators/rem/retrieval-precision.yaml +199 -0
  123. rem/schemas/evaluators/rem/retrieval-recall.yaml +211 -0
  124. rem/schemas/evaluators/rem/search-correctness.yaml +192 -0
  125. rem/services/__init__.py +18 -0
  126. rem/services/audio/INTEGRATION.md +308 -0
  127. rem/services/audio/README.md +376 -0
  128. rem/services/audio/__init__.py +15 -0
  129. rem/services/audio/chunker.py +354 -0
  130. rem/services/audio/transcriber.py +259 -0
  131. rem/services/content/README.md +1269 -0
  132. rem/services/content/__init__.py +5 -0
  133. rem/services/content/providers.py +760 -0
  134. rem/services/content/service.py +762 -0
  135. rem/services/dreaming/README.md +230 -0
  136. rem/services/dreaming/__init__.py +53 -0
  137. rem/services/dreaming/affinity_service.py +322 -0
  138. rem/services/dreaming/moment_service.py +251 -0
  139. rem/services/dreaming/ontology_service.py +54 -0
  140. rem/services/dreaming/user_model_service.py +297 -0
  141. rem/services/dreaming/utils.py +39 -0
  142. rem/services/email/__init__.py +10 -0
  143. rem/services/email/service.py +522 -0
  144. rem/services/email/templates.py +360 -0
  145. rem/services/embeddings/__init__.py +11 -0
  146. rem/services/embeddings/api.py +127 -0
  147. rem/services/embeddings/worker.py +435 -0
  148. rem/services/fs/README.md +662 -0
  149. rem/services/fs/__init__.py +62 -0
  150. rem/services/fs/examples.py +206 -0
  151. rem/services/fs/examples_paths.py +204 -0
  152. rem/services/fs/git_provider.py +935 -0
  153. rem/services/fs/local_provider.py +760 -0
  154. rem/services/fs/parsing-hooks-examples.md +172 -0
  155. rem/services/fs/paths.py +276 -0
  156. rem/services/fs/provider.py +460 -0
  157. rem/services/fs/s3_provider.py +1042 -0
  158. rem/services/fs/service.py +186 -0
  159. rem/services/git/README.md +1075 -0
  160. rem/services/git/__init__.py +17 -0
  161. rem/services/git/service.py +469 -0
  162. rem/services/phoenix/EXPERIMENT_DESIGN.md +1146 -0
  163. rem/services/phoenix/README.md +453 -0
  164. rem/services/phoenix/__init__.py +46 -0
  165. rem/services/phoenix/client.py +960 -0
  166. rem/services/phoenix/config.py +88 -0
  167. rem/services/phoenix/prompt_labels.py +477 -0
  168. rem/services/postgres/README.md +757 -0
  169. rem/services/postgres/__init__.py +49 -0
  170. rem/services/postgres/diff_service.py +599 -0
  171. rem/services/postgres/migration_service.py +427 -0
  172. rem/services/postgres/programmable_diff_service.py +635 -0
  173. rem/services/postgres/pydantic_to_sqlalchemy.py +562 -0
  174. rem/services/postgres/register_type.py +353 -0
  175. rem/services/postgres/repository.py +481 -0
  176. rem/services/postgres/schema_generator.py +661 -0
  177. rem/services/postgres/service.py +802 -0
  178. rem/services/postgres/sql_builder.py +355 -0
  179. rem/services/rate_limit.py +113 -0
  180. rem/services/rem/README.md +318 -0
  181. rem/services/rem/__init__.py +23 -0
  182. rem/services/rem/exceptions.py +71 -0
  183. rem/services/rem/executor.py +293 -0
  184. rem/services/rem/parser.py +180 -0
  185. rem/services/rem/queries.py +196 -0
  186. rem/services/rem/query.py +371 -0
  187. rem/services/rem/service.py +608 -0
  188. rem/services/session/README.md +374 -0
  189. rem/services/session/__init__.py +13 -0
  190. rem/services/session/compression.py +488 -0
  191. rem/services/session/pydantic_messages.py +310 -0
  192. rem/services/session/reload.py +85 -0
  193. rem/services/user_service.py +130 -0
  194. rem/settings.py +1877 -0
  195. rem/sql/background_indexes.sql +52 -0
  196. rem/sql/migrations/001_install.sql +983 -0
  197. rem/sql/migrations/002_install_models.sql +3157 -0
  198. rem/sql/migrations/003_optional_extensions.sql +326 -0
  199. rem/sql/migrations/004_cache_system.sql +282 -0
  200. rem/sql/migrations/005_schema_update.sql +145 -0
  201. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  202. rem/utils/AGENTIC_CHUNKING.md +597 -0
  203. rem/utils/README.md +628 -0
  204. rem/utils/__init__.py +61 -0
  205. rem/utils/agentic_chunking.py +622 -0
  206. rem/utils/batch_ops.py +343 -0
  207. rem/utils/chunking.py +108 -0
  208. rem/utils/clip_embeddings.py +276 -0
  209. rem/utils/constants.py +97 -0
  210. rem/utils/date_utils.py +228 -0
  211. rem/utils/dict_utils.py +98 -0
  212. rem/utils/embeddings.py +436 -0
  213. rem/utils/examples/embeddings_example.py +305 -0
  214. rem/utils/examples/sql_types_example.py +202 -0
  215. rem/utils/files.py +323 -0
  216. rem/utils/markdown.py +16 -0
  217. rem/utils/mime_types.py +158 -0
  218. rem/utils/model_helpers.py +492 -0
  219. rem/utils/schema_loader.py +649 -0
  220. rem/utils/sql_paths.py +146 -0
  221. rem/utils/sql_types.py +350 -0
  222. rem/utils/user_id.py +81 -0
  223. rem/utils/vision.py +325 -0
  224. rem/workers/README.md +506 -0
  225. rem/workers/__init__.py +7 -0
  226. rem/workers/db_listener.py +579 -0
  227. rem/workers/db_maintainer.py +74 -0
  228. rem/workers/dreaming.py +502 -0
  229. rem/workers/engram_processor.py +312 -0
  230. rem/workers/sqs_file_processor.py +193 -0
  231. rem/workers/unlogged_maintainer.py +463 -0
  232. remdb-0.3.242.dist-info/METADATA +1632 -0
  233. remdb-0.3.242.dist-info/RECORD +235 -0
  234. remdb-0.3.242.dist-info/WHEEL +4 -0
  235. 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
+ }