remdb 0.3.181__py3-none-any.whl → 0.3.223__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 (48) hide show
  1. rem/agentic/README.md +262 -2
  2. rem/agentic/context.py +173 -0
  3. rem/agentic/context_builder.py +12 -2
  4. rem/agentic/mcp/tool_wrapper.py +2 -2
  5. rem/agentic/providers/pydantic_ai.py +1 -1
  6. rem/agentic/schema.py +2 -2
  7. rem/api/main.py +1 -1
  8. rem/api/mcp_router/server.py +4 -0
  9. rem/api/mcp_router/tools.py +542 -170
  10. rem/api/routers/admin.py +30 -4
  11. rem/api/routers/auth.py +106 -10
  12. rem/api/routers/chat/completions.py +66 -18
  13. rem/api/routers/chat/sse_events.py +7 -3
  14. rem/api/routers/chat/streaming.py +254 -22
  15. rem/api/routers/common.py +18 -0
  16. rem/api/routers/dev.py +7 -1
  17. rem/api/routers/feedback.py +9 -1
  18. rem/api/routers/messages.py +176 -38
  19. rem/api/routers/models.py +9 -1
  20. rem/api/routers/query.py +12 -1
  21. rem/api/routers/shared_sessions.py +16 -0
  22. rem/auth/jwt.py +19 -4
  23. rem/auth/middleware.py +42 -28
  24. rem/cli/README.md +62 -0
  25. rem/cli/commands/db.py +33 -19
  26. rem/cli/commands/process.py +171 -43
  27. rem/models/entities/ontology.py +18 -20
  28. rem/schemas/agents/rem.yaml +1 -1
  29. rem/services/content/service.py +18 -5
  30. rem/services/postgres/__init__.py +28 -3
  31. rem/services/postgres/diff_service.py +57 -5
  32. rem/services/postgres/programmable_diff_service.py +635 -0
  33. rem/services/postgres/pydantic_to_sqlalchemy.py +2 -2
  34. rem/services/postgres/register_type.py +11 -10
  35. rem/services/postgres/repository.py +14 -4
  36. rem/services/session/__init__.py +8 -1
  37. rem/services/session/compression.py +40 -2
  38. rem/services/session/pydantic_messages.py +276 -0
  39. rem/settings.py +28 -0
  40. rem/sql/migrations/001_install.sql +125 -7
  41. rem/sql/migrations/002_install_models.sql +136 -126
  42. rem/sql/migrations/004_cache_system.sql +7 -275
  43. rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
  44. rem/utils/schema_loader.py +6 -6
  45. {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/METADATA +1 -1
  46. {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/RECORD +48 -44
  47. {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/WHEEL +0 -0
  48. {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/entry_points.txt +0 -0
@@ -20,6 +20,7 @@ Available Tools:
20
20
  - get_schema: Get detailed schema for a table (columns, types, indexes)
21
21
  """
22
22
 
23
+ import json
23
24
  from functools import wraps
24
25
  from typing import Any, Callable, Literal, cast
25
26
 
@@ -128,205 +129,228 @@ def mcp_tool_error_handler(func: Callable) -> Callable:
128
129
 
129
130
  @mcp_tool_error_handler
130
131
  async def search_rem(
131
- query_type: Literal["lookup", "fuzzy", "search", "sql", "traverse"],
132
- # LOOKUP parameters
133
- entity_key: str | None = None,
134
- # FUZZY parameters
135
- query_text: str | None = None,
136
- threshold: float = 0.7,
137
- # SEARCH parameters
138
- table: str | None = None,
132
+ query: str,
139
133
  limit: int = 20,
140
- # SQL parameters
141
- sql_query: str | None = None,
142
- # TRAVERSE parameters
143
- initial_query: str | None = None,
144
- edge_types: list[str] | None = None,
145
- depth: int = 1,
146
- # Optional context override (defaults to authenticated user)
147
- user_id: str | None = None,
148
134
  ) -> dict[str, Any]:
149
135
  """
150
- Execute REM queries for entity lookup, semantic search, and graph traversal.
151
-
152
- REM supports multiple query types for different retrieval patterns:
136
+ Execute a REM query using the REM query dialect.
153
137
 
154
- **LOOKUP** - O(1) entity resolution by natural language key:
155
- - Fast exact match across all tables
156
- - Uses indexed label_vector for instant retrieval
157
- - Example: LOOKUP "Sarah Chen" returns all entities named "Sarah Chen"
158
- - **Ontology Note**: Ontology content may contain markdown links like
159
- `[sertraline](../../drugs/antidepressants/sertraline.md)`. The link name
160
- (e.g., "sertraline") can be used as a LOOKUP subject, while the relative
161
- path provides semantic context (e.g., it's a drug, specifically an antidepressant).
138
+ **REM Query Syntax:**
162
139
 
163
- **FUZZY** - Fuzzy text matching with similarity threshold:
164
- - Finds partial matches and typos
165
- - Example: FUZZY "sara" threshold=0.7 finds "Sarah Chen", "Sara Martinez"
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
166
144
 
167
- **SEARCH** - Semantic vector search (table-specific):
168
- - Finds conceptually similar entities
169
- - Example: SEARCH "database migration" table=resources returns related documents
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
170
151
 
171
- **SQL** - Direct SQL queries for structured data:
172
- - Full PostgreSQL query power (scoped to table)
173
- - Example: SQL "role = 'engineer'" (WHERE clause only)
152
+ FUZZY <text>
153
+ Fuzzy text matching for partial matches and typos.
154
+ Example: FUZZY setraline
174
155
 
175
- **TRAVERSE** - Graph traversal following relationships:
176
- - Explores entity neighborhood via graph edges
177
- - Supports depth control and edge type filtering
178
- - Example: TRAVERSE "Sarah Chen" edge_types=["manages", "reports_to"] depth=2
156
+ TRAVERSE <start_entity>
157
+ Graph traversal from a starting entity.
158
+ Example: TRAVERSE sarah-chen
179
159
 
180
160
  Args:
181
- query_type: Type of query (lookup, fuzzy, search, sql, traverse)
182
- entity_key: Entity key for LOOKUP (e.g., "Sarah Chen")
183
- query_text: Search text for FUZZY or SEARCH
184
- threshold: Similarity threshold for FUZZY (0.0-1.0)
185
- table: Target table for SEARCH (resources, moments, users, etc.)
186
- limit: Max results for SEARCH
187
- sql_query: SQL WHERE clause for SQL type (e.g. "id = '123'")
188
- initial_query: Starting entity for TRAVERSE
189
- edge_types: Edge types to follow for TRAVERSE (e.g., ["manages", "reports_to"])
190
- depth: Traversal depth for TRAVERSE (0=plan only, 1-5=actual traversal)
191
- user_id: Optional user identifier (defaults to authenticated user or "default")
161
+ query: REM query string (e.g., "LOOKUP phq-9-procedure", "SEARCH depression IN ontologies")
162
+ limit: Maximum results to return (default: 20)
192
163
 
193
164
  Returns:
194
- Dict with query results, metadata, and execution info
165
+ Dict with query results and metadata. If no results found, includes
166
+ 'suggestions' with alternative search strategies.
195
167
 
196
168
  Examples:
197
- # Lookup entity (uses authenticated user context)
198
- search_rem(
199
- query_type="lookup",
200
- entity_key="Sarah Chen"
201
- )
202
-
203
- # Semantic search
204
- search_rem(
205
- query_type="search",
206
- query_text="database migration",
207
- table="resources",
208
- limit=10
209
- )
210
-
211
- # SQL query (WHERE clause only)
212
- search_rem(
213
- query_type="sql",
214
- table="resources",
215
- sql_query="category = 'document'"
216
- )
217
-
218
- # Graph traversal
219
- search_rem(
220
- query_type="traverse",
221
- initial_query="Sarah Chen",
222
- edge_types=["manages", "reports_to"],
223
- depth=2
224
- )
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")
225
173
  """
226
174
  # Get RemService instance (lazy initialization)
227
175
  rem_service = await get_rem_service()
228
176
 
229
- # Get user_id from context if not provided
230
- # TODO: Extract from authenticated session context when auth is enabled
231
- user_id = AgentContext.get_user_id_or_default(user_id, source="search_rem")
177
+ # Get user_id from context
178
+ user_id = AgentContext.get_user_id_or_default(None, source="search_rem")
232
179
 
233
- # Normalize query_type to lowercase for case-insensitive REM dialect
234
- query_type = cast(Literal["lookup", "fuzzy", "search", "sql", "traverse"], query_type.lower())
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()
235
198
 
236
199
  # Build RemQuery based on query_type
237
- if query_type == "lookup":
238
- if not entity_key:
239
- return {"status": "error", "error": "entity_key required for LOOKUP"}
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
+ }
240
206
 
241
- query = RemQuery(
207
+ rem_query = RemQuery(
242
208
  query_type=QueryType.LOOKUP,
243
209
  parameters=LookupParameters(
244
- key=entity_key,
210
+ key=remainder,
245
211
  user_id=user_id,
246
212
  ),
247
213
  user_id=user_id,
248
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
+ }
249
231
 
250
- elif query_type == "fuzzy":
251
- if not query_text:
252
- return {"status": "error", "error": "query_text required for FUZZY"}
253
-
254
- query = RemQuery(
255
- query_type=QueryType.FUZZY,
256
- parameters=FuzzyParameters(
257
- query_text=query_text,
258
- threshold=threshold,
259
- limit=limit, # Limit was missing in original logic but likely intended
260
- ),
261
- user_id=user_id,
262
- )
263
-
264
- elif query_type == "search":
265
- if not query_text:
266
- return {"status": "error", "error": "query_text required for SEARCH"}
267
- if not table:
268
- return {"status": "error", "error": "table required for SEARCH"}
232
+ if not search_text:
233
+ return {
234
+ "status": "error",
235
+ "error": "SEARCH requires search text. Example: SEARCH depression IN ontologies",
236
+ }
269
237
 
270
- query = RemQuery(
238
+ rem_query = RemQuery(
271
239
  query_type=QueryType.SEARCH,
272
240
  parameters=SearchParameters(
273
- query_text=query_text,
241
+ query_text=search_text,
274
242
  table_name=table,
275
243
  limit=limit,
276
244
  ),
277
245
  user_id=user_id,
278
246
  )
279
247
 
280
- elif query_type == "sql":
281
- if not sql_query:
282
- return {"status": "error", "error": "sql_query required for SQL"}
283
-
284
- # SQLParameters requires table_name. If not provided, we cannot execute.
285
- # Assuming sql_query is just the WHERE clause based on RemService implementation,
286
- # OR if table is provided we use it.
287
- if not table:
288
- return {"status": "error", "error": "table required for SQL queries (parameter: table)"}
289
-
290
- query = RemQuery(
291
- query_type=QueryType.SQL,
292
- parameters=SQLParameters(
293
- table_name=table,
294
- where_clause=sql_query,
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
295
260
  limit=limit,
296
261
  ),
297
262
  user_id=user_id,
298
263
  )
264
+ table = None
299
265
 
300
- elif query_type == "traverse":
301
- if not initial_query:
266
+ elif query_type == "TRAVERSE":
267
+ if not remainder:
302
268
  return {
303
269
  "status": "error",
304
- "error": "initial_query required for TRAVERSE",
270
+ "error": "TRAVERSE requires a starting entity. Example: TRAVERSE sarah-chen",
305
271
  }
306
272
 
307
- query = RemQuery(
273
+ rem_query = RemQuery(
308
274
  query_type=QueryType.TRAVERSE,
309
275
  parameters=TraverseParameters(
310
- initial_query=initial_query,
311
- edge_types=edge_types or [],
312
- max_depth=depth,
276
+ initial_query=remainder,
277
+ edge_types=[],
278
+ max_depth=1,
313
279
  ),
314
280
  user_id=user_id,
315
281
  )
282
+ table = None
316
283
 
317
284
  else:
318
- return {"status": "error", "error": f"Unknown query_type: {query_type}"}
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
+ }
319
290
 
320
291
  # Execute query (errors handled by decorator)
321
292
  logger.info(f"Executing REM query: {query_type} for user {user_id}")
322
- result = await rem_service.execute_query(query)
293
+ result = await rem_service.execute_query(rem_query)
323
294
 
324
295
  logger.info(f"Query completed successfully: {query_type}")
325
- return {
296
+
297
+ # Provide helpful guidance when no results found
298
+ response: dict[str, Any] = {
326
299
  "query_type": query_type,
327
300
  "results": result,
328
301
  }
329
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
+
330
354
 
331
355
  @mcp_tool_error_handler
332
356
  async def ask_rem_agent(
@@ -377,20 +401,45 @@ async def ask_rem_agent(
377
401
  query="Show me Sarah's reporting chain and their recent projects"
378
402
  )
379
403
  """
380
- # Get user_id from context if not provided
381
- # TODO: Extract from authenticated session context when auth is enabled
382
- user_id = AgentContext.get_user_id_or_default(user_id, source="ask_rem_agent")
383
-
384
404
  from ...agentic import create_agent
405
+ from ...agentic.context import get_current_context
385
406
  from ...utils.schema_loader import load_agent_schema
386
407
 
387
- # Create agent context
388
- # Note: tenant_id defaults to "default" if user_id is None
389
- context = AgentContext(
390
- user_id=user_id,
391
- tenant_id=user_id or "default", # Use default tenant for anonymous users
392
- default_model=settings.llm.default_model,
393
- )
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
+ )
394
443
 
395
444
  # Load agent schema
396
445
  try:
@@ -430,15 +479,18 @@ async def ingest_into_rem(
430
479
  category: str | None = None,
431
480
  tags: list[str] | None = None,
432
481
  is_local_server: bool = False,
433
- user_id: str | None = None,
434
482
  resource_type: str | None = None,
435
483
  ) -> dict[str, Any]:
436
484
  """
437
- Ingest file into REM, creating searchable resources and embeddings.
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.
438
490
 
439
491
  This tool provides the complete file ingestion pipeline:
440
492
  1. **Read**: File from local/S3/HTTP
441
- 2. **Store**: To user-scoped internal storage
493
+ 2. **Store**: To internal storage (public namespace)
442
494
  3. **Parse**: Extract content, metadata, tables, images
443
495
  4. **Chunk**: Semantic chunking for embeddings
444
496
  5. **Embed**: Create Resource chunks with vector embeddings
@@ -457,7 +509,6 @@ async def ingest_into_rem(
457
509
  category: Optional category (document, code, audio, etc.)
458
510
  tags: Optional tags for file
459
511
  is_local_server: True if running as local/stdio MCP server
460
- user_id: Optional user identifier (defaults to authenticated user or "default")
461
512
  resource_type: Optional resource type for storing chunks (case-insensitive).
462
513
  Supports flexible naming:
463
514
  - "resource", "resources", "Resource" → Resource (default)
@@ -476,10 +527,10 @@ async def ingest_into_rem(
476
527
  - message: Human-readable status message
477
528
 
478
529
  Examples:
479
- # Ingest local file (local server only, uses authenticated user context)
530
+ # Ingest local file (local server only)
480
531
  ingest_into_rem(
481
- file_uri="/Users/me/contract.pdf",
482
- category="legal",
532
+ file_uri="/Users/me/procedure.pdf",
533
+ category="medical",
483
534
  is_local_server=True
484
535
  )
485
536
 
@@ -503,15 +554,14 @@ async def ingest_into_rem(
503
554
  """
504
555
  from ...services.content import ContentService
505
556
 
506
- # Get user_id from context if not provided
507
- # TODO: Extract from authenticated session context when auth is enabled
508
- user_id = AgentContext.get_user_id_or_default(user_id, source="ingest_into_rem")
557
+ # Data is PUBLIC by default (user_id=None)
558
+ # Private user-scoped data requires CLI with --make-private flag
509
559
 
510
560
  # Delegate to ContentService for centralized ingestion (errors handled by decorator)
511
561
  content_service = ContentService()
512
562
  result = await content_service.ingest_file(
513
563
  file_uri=file_uri,
514
- user_id=user_id,
564
+ user_id=None, # PUBLIC - all ingested data is shared/public
515
565
  category=category,
516
566
  tags=tags,
517
567
  is_local_server=is_local_server,
@@ -544,15 +594,18 @@ async def read_resource(uri: str) -> dict[str, Any]:
544
594
  **Available Resources:**
545
595
 
546
596
  Agent Schemas:
547
- • rem://schemas - List all agent schemas
548
- • rem://schema/{name} - Get specific schema definition
549
- • rem://schema/{name}/{version} - Get specific version
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
550
603
 
551
604
  System Status:
552
605
  • rem://status - System health and statistics
553
606
 
554
607
  Args:
555
- uri: Resource URI (e.g., "rem://schemas", "rem://schema/ask_rem")
608
+ uri: Resource URI (e.g., "rem://agents", "rem://agents/ask_rem")
556
609
 
557
610
  Returns:
558
611
  Dict with:
@@ -561,14 +614,11 @@ async def read_resource(uri: str) -> dict[str, Any]:
561
614
  - data: Resource data (format depends on resource type)
562
615
 
563
616
  Examples:
564
- # List all schemas
565
- read_resource(uri="rem://schemas")
566
-
567
- # Get specific schema
568
- read_resource(uri="rem://schema/ask_rem")
617
+ # List all agents
618
+ read_resource(uri="rem://agents")
569
619
 
570
- # Get schema version
571
- read_resource(uri="rem://schema/ask_rem/v1.0.0")
620
+ # Get specific agent
621
+ read_resource(uri="rem://agents/ask_rem")
572
622
 
573
623
  # Check system status
574
624
  read_resource(uri="rem://status")
@@ -621,6 +671,8 @@ async def register_metadata(
621
671
  recommended_action: str | None = None,
622
672
  # Generic extension - any additional key-value pairs
623
673
  extra: dict[str, Any] | None = None,
674
+ # Agent schema (auto-populated from context if not provided)
675
+ agent_schema: str | None = None,
624
676
  ) -> dict[str, Any]:
625
677
  """
626
678
  Register response metadata to be emitted as an SSE MetadataEvent.
@@ -661,6 +713,8 @@ async def register_metadata(
661
713
  extra: Dict of arbitrary additional metadata. Use this for any
662
714
  domain-specific fields not covered by the standard parameters.
663
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).
664
718
 
665
719
  Returns:
666
720
  Dict with:
@@ -704,10 +758,17 @@ async def register_metadata(
704
758
  }
705
759
  )
706
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
+
707
768
  logger.debug(
708
769
  f"Registering metadata: confidence={confidence}, "
709
770
  f"risk_level={risk_level}, refs={len(references or [])}, "
710
- f"sources={len(sources or [])}"
771
+ f"sources={len(sources or [])}, agent_schema={agent_schema}"
711
772
  )
712
773
 
713
774
  result = {
@@ -717,6 +778,7 @@ async def register_metadata(
717
778
  "references": references,
718
779
  "sources": sources,
719
780
  "flags": flags,
781
+ "agent_schema": agent_schema, # Include agent schema for tracing
720
782
  }
721
783
 
722
784
  # Add session name if provided
@@ -1138,6 +1200,316 @@ async def save_agent(
1138
1200
  return result
1139
1201
 
1140
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
1418
+ if isinstance(event, PartStartEvent):
1419
+ from pydantic_ai.messages import ToolCallPart, TextPart
1420
+ if isinstance(event.part, ToolCallPart):
1421
+ # Push tool start event to parent
1422
+ await push_event.put({
1423
+ "type": "child_tool_start",
1424
+ "agent_name": agent_name,
1425
+ "tool_name": event.part.tool_name,
1426
+ "arguments": event.part.args if hasattr(event.part, 'args') else None,
1427
+ })
1428
+ child_tool_calls.append({
1429
+ "tool_name": event.part.tool_name,
1430
+ "index": event.index,
1431
+ })
1432
+ elif isinstance(event.part, TextPart):
1433
+ # TextPart may have initial content
1434
+ if event.part.content:
1435
+ accumulated_content.append(event.part.content)
1436
+ await push_event.put({
1437
+ "type": "child_content",
1438
+ "agent_name": agent_name,
1439
+ "content": event.part.content,
1440
+ })
1441
+ # Proxy text content deltas to parent for real-time streaming
1442
+ elif isinstance(event, PartDeltaEvent):
1443
+ if hasattr(event, 'delta') and hasattr(event.delta, 'content_delta'):
1444
+ content = event.delta.content_delta
1445
+ if content:
1446
+ accumulated_content.append(content)
1447
+ # Push content chunk to parent for streaming
1448
+ await push_event.put({
1449
+ "type": "child_content",
1450
+ "agent_name": agent_name,
1451
+ "content": content,
1452
+ })
1453
+
1454
+ elif Agent.is_call_tools_node(node):
1455
+ async with node.stream(agent_run.ctx) as tools_stream:
1456
+ async for tool_event in tools_stream:
1457
+ if isinstance(tool_event, FunctionToolResultEvent):
1458
+ result_content = tool_event.result.content if hasattr(tool_event.result, 'content') else tool_event.result
1459
+ # Push tool result to parent
1460
+ await push_event.put({
1461
+ "type": "child_tool_result",
1462
+ "agent_name": agent_name,
1463
+ "result": result_content,
1464
+ })
1465
+
1466
+ # Get final result (inside context manager)
1467
+ return agent_run.result, "".join(accumulated_content), child_tool_calls
1468
+
1469
+ result, streamed_content, tool_calls = await asyncio.wait_for(
1470
+ run_with_streaming(),
1471
+ timeout=timeout_seconds
1472
+ )
1473
+ else:
1474
+ # NON-STREAMING MODE: Use run() for backwards compatibility
1475
+ if pydantic_message_history:
1476
+ result = await asyncio.wait_for(
1477
+ agent_runtime.run(prompt, message_history=pydantic_message_history),
1478
+ timeout=timeout_seconds
1479
+ )
1480
+ else:
1481
+ result = await asyncio.wait_for(
1482
+ agent_runtime.run(prompt),
1483
+ timeout=timeout_seconds
1484
+ )
1485
+ except asyncio.TimeoutError:
1486
+ return {
1487
+ "status": "error",
1488
+ "error": f"Agent '{agent_name}' timed out after {timeout_seconds}s",
1489
+ "agent_schema": agent_name,
1490
+ }
1491
+
1492
+ # Serialize output
1493
+ from rem.agentic.serialization import serialize_agent_result
1494
+ output = serialize_agent_result(result.output)
1495
+
1496
+ logger.info(f"Agent '{agent_name}' completed successfully")
1497
+
1498
+ response = {
1499
+ "status": "success",
1500
+ "output": output,
1501
+ "agent_schema": agent_name,
1502
+ "input_text": input_text,
1503
+ }
1504
+
1505
+ # Only include text_response if content was NOT streamed
1506
+ # When streaming, child_content events already delivered the content
1507
+ if not use_streaming or not streamed_content:
1508
+ response["text_response"] = str(result.output)
1509
+
1510
+ return response
1511
+
1512
+
1141
1513
  # =============================================================================
1142
1514
  # Test/Debug Tools (for development only)
1143
1515
  # =============================================================================