remdb 0.3.0__py3-none-any.whl → 0.3.127__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 (106) hide show
  1. rem/__init__.py +129 -2
  2. rem/agentic/README.md +76 -0
  3. rem/agentic/__init__.py +15 -0
  4. rem/agentic/agents/__init__.py +16 -2
  5. rem/agentic/agents/sse_simulator.py +502 -0
  6. rem/agentic/context.py +51 -25
  7. rem/agentic/llm_provider_models.py +301 -0
  8. rem/agentic/mcp/tool_wrapper.py +29 -3
  9. rem/agentic/otel/setup.py +93 -4
  10. rem/agentic/providers/phoenix.py +32 -43
  11. rem/agentic/providers/pydantic_ai.py +168 -24
  12. rem/agentic/schema.py +358 -21
  13. rem/agentic/tools/rem_tools.py +3 -3
  14. rem/api/README.md +238 -1
  15. rem/api/deps.py +255 -0
  16. rem/api/main.py +154 -37
  17. rem/api/mcp_router/resources.py +1 -1
  18. rem/api/mcp_router/server.py +26 -5
  19. rem/api/mcp_router/tools.py +465 -7
  20. rem/api/middleware/tracking.py +172 -0
  21. rem/api/routers/admin.py +494 -0
  22. rem/api/routers/auth.py +124 -0
  23. rem/api/routers/chat/completions.py +402 -20
  24. rem/api/routers/chat/models.py +88 -10
  25. rem/api/routers/chat/otel_utils.py +33 -0
  26. rem/api/routers/chat/sse_events.py +542 -0
  27. rem/api/routers/chat/streaming.py +642 -45
  28. rem/api/routers/dev.py +81 -0
  29. rem/api/routers/feedback.py +268 -0
  30. rem/api/routers/messages.py +473 -0
  31. rem/api/routers/models.py +78 -0
  32. rem/api/routers/query.py +360 -0
  33. rem/api/routers/shared_sessions.py +406 -0
  34. rem/auth/middleware.py +126 -27
  35. rem/cli/commands/README.md +237 -64
  36. rem/cli/commands/ask.py +13 -10
  37. rem/cli/commands/cluster.py +1808 -0
  38. rem/cli/commands/configure.py +5 -6
  39. rem/cli/commands/db.py +396 -139
  40. rem/cli/commands/experiments.py +293 -73
  41. rem/cli/commands/process.py +22 -15
  42. rem/cli/commands/scaffold.py +47 -0
  43. rem/cli/commands/schema.py +97 -50
  44. rem/cli/main.py +29 -6
  45. rem/config.py +10 -3
  46. rem/models/core/core_model.py +7 -1
  47. rem/models/core/rem_query.py +5 -2
  48. rem/models/entities/__init__.py +21 -0
  49. rem/models/entities/domain_resource.py +38 -0
  50. rem/models/entities/feedback.py +123 -0
  51. rem/models/entities/message.py +30 -1
  52. rem/models/entities/session.py +83 -0
  53. rem/models/entities/shared_session.py +180 -0
  54. rem/models/entities/user.py +10 -3
  55. rem/registry.py +373 -0
  56. rem/schemas/agents/rem.yaml +7 -3
  57. rem/services/content/providers.py +94 -140
  58. rem/services/content/service.py +92 -20
  59. rem/services/dreaming/affinity_service.py +2 -16
  60. rem/services/dreaming/moment_service.py +2 -15
  61. rem/services/embeddings/api.py +24 -17
  62. rem/services/embeddings/worker.py +16 -16
  63. rem/services/phoenix/EXPERIMENT_DESIGN.md +3 -3
  64. rem/services/phoenix/client.py +302 -28
  65. rem/services/postgres/README.md +159 -15
  66. rem/services/postgres/__init__.py +2 -1
  67. rem/services/postgres/diff_service.py +531 -0
  68. rem/services/postgres/pydantic_to_sqlalchemy.py +427 -129
  69. rem/services/postgres/repository.py +132 -0
  70. rem/services/postgres/schema_generator.py +291 -9
  71. rem/services/postgres/service.py +6 -6
  72. rem/services/rate_limit.py +113 -0
  73. rem/services/rem/README.md +14 -0
  74. rem/services/rem/parser.py +44 -9
  75. rem/services/rem/service.py +36 -2
  76. rem/services/session/compression.py +24 -1
  77. rem/services/session/reload.py +1 -1
  78. rem/services/user_service.py +98 -0
  79. rem/settings.py +313 -29
  80. rem/sql/background_indexes.sql +21 -16
  81. rem/sql/migrations/001_install.sql +387 -54
  82. rem/sql/migrations/002_install_models.sql +2320 -393
  83. rem/sql/migrations/003_optional_extensions.sql +326 -0
  84. rem/sql/migrations/004_cache_system.sql +548 -0
  85. rem/utils/__init__.py +18 -0
  86. rem/utils/constants.py +97 -0
  87. rem/utils/date_utils.py +228 -0
  88. rem/utils/embeddings.py +17 -4
  89. rem/utils/files.py +167 -0
  90. rem/utils/mime_types.py +158 -0
  91. rem/utils/model_helpers.py +156 -1
  92. rem/utils/schema_loader.py +282 -35
  93. rem/utils/sql_paths.py +146 -0
  94. rem/utils/sql_types.py +3 -1
  95. rem/utils/vision.py +9 -14
  96. rem/workers/README.md +14 -14
  97. rem/workers/__init__.py +3 -1
  98. rem/workers/db_listener.py +579 -0
  99. rem/workers/db_maintainer.py +74 -0
  100. rem/workers/unlogged_maintainer.py +463 -0
  101. {remdb-0.3.0.dist-info → remdb-0.3.127.dist-info}/METADATA +464 -289
  102. {remdb-0.3.0.dist-info → remdb-0.3.127.dist-info}/RECORD +104 -73
  103. {remdb-0.3.0.dist-info → remdb-0.3.127.dist-info}/WHEEL +1 -1
  104. rem/sql/002_install_models.sql +0 -1068
  105. rem/sql/install_models.sql +0 -1038
  106. {remdb-0.3.0.dist-info → remdb-0.3.127.dist-info}/entry_points.txt +0 -0
@@ -16,8 +16,12 @@ Embedding Field Detection:
16
16
  Table Name Inference:
17
17
  1. model_config.json_schema_extra.table_name
18
18
  2. CamelCase → snake_case + pluralization
19
+
20
+ Model Resolution:
21
+ - model_from_arbitrary_casing: Resolve model class from flexible input casing
19
22
  """
20
23
 
24
+ import re
21
25
  from typing import Any, Type
22
26
 
23
27
  from loguru import logger
@@ -94,7 +98,9 @@ def get_table_name(model: Type[BaseModel]) -> str:
94
98
  if isinstance(model_config, dict):
95
99
  json_extra = model_config.get("json_schema_extra", {})
96
100
  if isinstance(json_extra, dict) and "table_name" in json_extra:
97
- return json_extra["table_name"]
101
+ table_name = json_extra["table_name"]
102
+ if isinstance(table_name, str):
103
+ return table_name
98
104
 
99
105
  # Infer from class name
100
106
  name = model.__name__
@@ -234,3 +240,152 @@ def get_model_metadata(model: Type[BaseModel]) -> dict[str, Any]:
234
240
  "entity_key_field": get_entity_key_field(model),
235
241
  "embeddable_fields": get_embeddable_fields(model),
236
242
  }
243
+
244
+
245
+ def normalize_to_title_case(name: str) -> str:
246
+ """
247
+ Normalize arbitrary casing to TitleCase (PascalCase).
248
+
249
+ Handles various input formats:
250
+ - kebab-case: domain-resource → DomainResource
251
+ - snake_case: domain_resource → DomainResource
252
+ - lowercase: domainresource → Domainresource (single word)
253
+ - TitleCase: DomainResource → DomainResource (passthrough)
254
+ - Mixed: Domain-Resource, DOMAIN_RESOURCE → DomainResource
255
+
256
+ Args:
257
+ name: Input name in any casing format
258
+
259
+ Returns:
260
+ TitleCase (PascalCase) version of the name
261
+
262
+ Example:
263
+ >>> normalize_to_title_case("domain-resource")
264
+ 'DomainResource'
265
+ >>> normalize_to_title_case("domain_resources")
266
+ 'DomainResources'
267
+ >>> normalize_to_title_case("DomainResource")
268
+ 'DomainResource'
269
+ """
270
+ # If already TitleCase (starts with uppercase, has no delimiters, and has
271
+ # at least one lowercase letter), return as-is
272
+ if (
273
+ name
274
+ and name[0].isupper()
275
+ and '-' not in name
276
+ and '_' not in name
277
+ and any(c.islower() for c in name)
278
+ ):
279
+ return name
280
+
281
+ # Split on common delimiters (hyphen, underscore)
282
+ parts = re.split(r'[-_]', name)
283
+
284
+ # Capitalize first letter of each part, lowercase the rest
285
+ normalized_parts = [part.capitalize() for part in parts if part]
286
+
287
+ return "".join(normalized_parts)
288
+
289
+
290
+ def model_from_arbitrary_casing(
291
+ name: str,
292
+ registry: dict[str, Type[BaseModel]] | None = None,
293
+ ) -> Type[BaseModel]:
294
+ """
295
+ Resolve a model class from arbitrary casing input.
296
+
297
+ REM entity models use strict TitleCase (PascalCase) naming. This function
298
+ allows flexible input formats while maintaining consistency:
299
+
300
+ Input formats supported:
301
+ - kebab-case: domain-resource, domain-resources
302
+ - snake_case: domain_resource, domain_resources
303
+ - lowercase: resource, domainresource
304
+ - TitleCase: Resource, DomainResource
305
+
306
+ Args:
307
+ name: Model name in any supported casing format
308
+ registry: Optional dict mapping TitleCase names to model classes.
309
+ If not provided, uses rem.models.entities module.
310
+
311
+ Returns:
312
+ The resolved Pydantic model class
313
+
314
+ Raises:
315
+ ValueError: If no model matches the normalized name
316
+
317
+ Example:
318
+ >>> model = model_from_arbitrary_casing("domain-resources")
319
+ >>> model.__name__
320
+ 'DomainResource'
321
+ >>> model = model_from_arbitrary_casing("Resource")
322
+ >>> model.__name__
323
+ 'Resource'
324
+ """
325
+ # Build default registry from entities module if not provided
326
+ if registry is None:
327
+ from rem.models.entities import (
328
+ DomainResource,
329
+ Feedback,
330
+ File,
331
+ ImageResource,
332
+ Message,
333
+ Moment,
334
+ Ontology,
335
+ OntologyConfig,
336
+ Resource,
337
+ Schema,
338
+ Session,
339
+ User,
340
+ )
341
+
342
+ registry = {
343
+ "Resource": Resource,
344
+ "Resources": Resource, # Plural alias
345
+ "DomainResource": DomainResource,
346
+ "DomainResources": DomainResource, # Plural alias
347
+ "ImageResource": ImageResource,
348
+ "ImageResources": ImageResource,
349
+ "File": File,
350
+ "Files": File,
351
+ "Message": Message,
352
+ "Messages": Message,
353
+ "Moment": Moment,
354
+ "Moments": Moment,
355
+ "Session": Session,
356
+ "Sessions": Session,
357
+ "Feedback": Feedback,
358
+ "User": User,
359
+ "Users": User,
360
+ "Schema": Schema,
361
+ "Schemas": Schema,
362
+ "Ontology": Ontology,
363
+ "Ontologies": Ontology,
364
+ "OntologyConfig": OntologyConfig,
365
+ "OntologyConfigs": OntologyConfig,
366
+ }
367
+
368
+ # Normalize input to TitleCase
369
+ normalized = normalize_to_title_case(name)
370
+
371
+ # Look up in registry
372
+ if normalized in registry:
373
+ logger.debug(f"Resolved model '{name}' → {registry[normalized].__name__}")
374
+ return registry[normalized]
375
+
376
+ # Try without trailing 's' (singular form)
377
+ if normalized.endswith("s") and normalized[:-1] in registry:
378
+ logger.debug(f"Resolved model '{name}' → {registry[normalized[:-1]].__name__} (singular)")
379
+ return registry[normalized[:-1]]
380
+
381
+ # Try with trailing 's' (plural form)
382
+ plural = normalized + "s"
383
+ if plural in registry:
384
+ logger.debug(f"Resolved model '{name}' → {registry[plural].__name__} (plural)")
385
+ return registry[plural]
386
+
387
+ available = sorted(set(m.__name__ for m in registry.values()))
388
+ raise ValueError(
389
+ f"Unknown model: '{name}' (normalized: '{normalized}'). "
390
+ f"Available models: {', '.join(available)}"
391
+ )
@@ -9,7 +9,7 @@ Design Pattern:
9
9
  - Support short names: "contract-analyzer" → "schemas/agents/contract-analyzer.yaml"
10
10
  - Support relative/absolute paths
11
11
  - Consistent error messages and logging
12
- i
12
+
13
13
  Usage:
14
14
  # From API
15
15
  schema = load_agent_schema("rem")
@@ -20,6 +20,26 @@ Usage:
20
20
  # From agent factory
21
21
  schema = load_agent_schema("contract-analyzer")
22
22
 
23
+ TODO: Git FS Integration
24
+ The schema loader currently uses importlib.resources for package schemas
25
+ and direct filesystem access for custom paths. The FS abstraction layer
26
+ (rem.services.fs.FS) could be used to abstract storage backends:
27
+
28
+ - Local filesystem (current)
29
+ - Git repositories (GitService)
30
+ - S3 (via FS provider)
31
+
32
+ This would enable loading schemas from versioned Git repos or S3 buckets
33
+ without changing the API. The FS provider pattern already exists and just
34
+ needs integration testing with the schema loader.
35
+
36
+ Example future usage:
37
+ # Load from Git at specific version
38
+ schema = load_agent_schema("git://rem/schemas/agents/rem.yaml?ref=v1.0.0")
39
+
40
+ # Load from S3
41
+ schema = load_agent_schema("s3://rem-schemas/agents/cv-parser.yaml")
42
+
23
43
  Schema Caching Status:
24
44
 
25
45
  ✅ IMPLEMENTED: Filesystem Schema Caching (2025-11-22)
@@ -71,13 +91,14 @@ import yaml
71
91
  from loguru import logger
72
92
 
73
93
 
74
- # Standard search paths for agent schemas (in priority order)
94
+ # Standard search paths for agent/evaluator schemas (in priority order)
75
95
  SCHEMA_SEARCH_PATHS = [
76
96
  "schemas/agents/{name}.yaml", # Top-level agents (e.g., rem.yaml)
77
97
  "schemas/agents/core/{name}.yaml", # Core system agents
78
98
  "schemas/agents/examples/{name}.yaml", # Example agents
79
- "schemas/evaluators/{name}.yaml",
80
- "schemas/{name}.yaml",
99
+ "schemas/evaluators/{name}.yaml", # Nested evaluators (e.g., hello-world/default)
100
+ "schemas/evaluators/rem/{name}.yaml", # REM evaluators (e.g., lookup-correctness)
101
+ "schemas/{name}.yaml", # Generic schemas
81
102
  ]
82
103
 
83
104
  # In-memory cache for filesystem schemas (no TTL - immutable)
@@ -125,7 +146,6 @@ def _load_schema_from_database(schema_name: str, user_id: str) -> dict[str, Any]
125
146
  async def _async_lookup():
126
147
  """Async helper to query database."""
127
148
  from rem.services.postgres import get_postgres_service
128
- from rem.models.entities import Schema
129
149
 
130
150
  db = get_postgres_service()
131
151
  if not db:
@@ -135,19 +155,20 @@ def _load_schema_from_database(schema_name: str, user_id: str) -> dict[str, Any]
135
155
  try:
136
156
  await db.connect()
137
157
 
138
- # Use REM LOOKUP query to find schema
139
- query = f"LOOKUP '{schema_name}' FROM schemas"
140
- logger.debug(f"Executing: {query} (user_id={user_id})")
158
+ # Query schemas table directly by name
159
+ # Note: Schema name lookup is case-insensitive for user convenience
160
+ query = """
161
+ SELECT spec FROM schemas
162
+ WHERE LOWER(name) = LOWER($1)
163
+ AND (user_id = $2 OR user_id = 'system')
164
+ LIMIT 1
165
+ """
166
+ logger.debug(f"Executing schema lookup: name={schema_name}, user_id={user_id}")
141
167
 
142
- result = await db.execute_rem_query(
143
- query=query,
144
- user_id=user_id,
145
- )
168
+ row = await db.fetchrow(query, schema_name, user_id)
146
169
 
147
- if result and isinstance(result, dict):
148
- # LOOKUP returns single entity or None
149
- # Extract spec field (JSON Schema)
150
- spec = result.get("spec")
170
+ if row:
171
+ spec = row.get("spec")
151
172
  if spec and isinstance(spec, dict):
152
173
  logger.debug(f"Found schema in database: {schema_name}")
153
174
  return spec
@@ -174,6 +195,8 @@ def load_agent_schema(
174
195
  """
175
196
  Load agent schema from YAML file with unified search logic and caching.
176
197
 
198
+ Schema names are case-invariant - "Rem", "rem", "REM" all resolve to the same schema.
199
+
177
200
  Filesystem schemas are cached indefinitely (immutable, versioned with code).
178
201
  Database schemas (future) will be cached with TTL for invalidation.
179
202
 
@@ -188,16 +211,17 @@ def load_agent_schema(
188
211
  Search Order:
189
212
  1. Check cache (if use_cache=True and schema found in FS cache)
190
213
  2. Exact path if it exists (absolute or relative)
191
- 3. Package resources: schemas/agents/{name}.yaml (top-level)
192
- 4. Package resources: schemas/agents/core/{name}.yaml
193
- 5. Package resources: schemas/agents/examples/{name}.yaml
194
- 6. Package resources: schemas/evaluators/{name}.yaml
195
- 7. Package resources: schemas/{name}.yaml
196
- 8. Database LOOKUP: schemas table (if enable_db_fallback=True and user_id provided)
214
+ 3. Custom paths from rem.register_schema_path() and SCHEMA__PATHS env var
215
+ 4. Package resources: schemas/agents/{name}.yaml (top-level)
216
+ 5. Package resources: schemas/agents/core/{name}.yaml
217
+ 6. Package resources: schemas/agents/examples/{name}.yaml
218
+ 7. Package resources: schemas/evaluators/{name}.yaml
219
+ 8. Package resources: schemas/{name}.yaml
220
+ 9. Database LOOKUP: schemas table (if enable_db_fallback=True and user_id provided)
197
221
 
198
222
  Args:
199
- schema_name_or_path: Schema name or file path
200
- Examples: "rem-query-agent", "contract-analyzer", "./my-schema.yaml"
223
+ schema_name_or_path: Schema name or file path (case-invariant for names)
224
+ Examples: "rem-query-agent", "Contract-Analyzer", "./my-schema.yaml"
201
225
  use_cache: If True, uses in-memory cache for filesystem schemas
202
226
  user_id: User ID for database schema lookup (required for DB fallback)
203
227
  enable_db_fallback: If True, falls back to database LOOKUP when file not found
@@ -210,8 +234,8 @@ def load_agent_schema(
210
234
  yaml.YAMLError: If schema file is invalid YAML
211
235
 
212
236
  Examples:
213
- >>> # Load by short name (cached after first load)
214
- >>> schema = load_agent_schema("contract-analyzer")
237
+ >>> # Load by short name (cached after first load) - case invariant
238
+ >>> schema = load_agent_schema("Contract-Analyzer") # same as "contract-analyzer"
215
239
  >>>
216
240
  >>> # Load from custom path (not cached - custom paths may change)
217
241
  >>> schema = load_agent_schema("./my-agent.yaml")
@@ -219,11 +243,11 @@ def load_agent_schema(
219
243
  >>> # Load evaluator schema (cached)
220
244
  >>> schema = load_agent_schema("rem-lookup-correctness")
221
245
  >>>
222
- >>> # Load custom user schema from database
223
- >>> schema = load_agent_schema("my-custom-agent", user_id="user-123")
246
+ >>> # Load custom user schema from database (case invariant)
247
+ >>> schema = load_agent_schema("My-Agent", user_id="user-123") # same as "my-agent"
224
248
  """
225
- # Normalize the name for cache key
226
- cache_key = str(schema_name_or_path).replace('agents/', '').replace('schemas/', '').replace('evaluators/', '').replace('core/', '').replace('examples/', '')
249
+ # Normalize the name for cache key (lowercase for case-invariant lookups)
250
+ cache_key = str(schema_name_or_path).replace('agents/', '').replace('schemas/', '').replace('evaluators/', '').replace('core/', '').replace('examples/', '').lower()
227
251
  if cache_key.endswith('.yaml') or cache_key.endswith('.yml'):
228
252
  cache_key = cache_key.rsplit('.', 1)[0]
229
253
 
@@ -244,10 +268,41 @@ def load_agent_schema(
244
268
  # Don't cache custom paths (they may change)
245
269
  return cast(dict[str, Any], schema)
246
270
 
247
- # 2. Normalize name for package resource search
271
+ # 2. Normalize name for package resource search (lowercase)
248
272
  base_name = cache_key
249
273
 
250
- # 3. Try package resources with standard search paths
274
+ # 3. Try custom schema paths (from registry + SCHEMA__PATHS env var + auto-detected)
275
+ from ..registry import get_schema_paths
276
+
277
+ custom_paths = get_schema_paths()
278
+
279
+ # Auto-detect local folders if they exist (convention over configuration)
280
+ auto_detect_folders = ["./agents", "./schemas", "./evaluators"]
281
+ for auto_folder in auto_detect_folders:
282
+ auto_path = Path(auto_folder)
283
+ if auto_path.exists() and auto_path.is_dir():
284
+ resolved = str(auto_path.resolve())
285
+ if resolved not in custom_paths:
286
+ custom_paths.insert(0, resolved)
287
+ logger.debug(f"Auto-detected schema directory: {auto_folder}")
288
+ for custom_dir in custom_paths:
289
+ # Try various patterns within each custom directory
290
+ for pattern in [
291
+ f"{base_name}.yaml",
292
+ f"{base_name}.yml",
293
+ f"agents/{base_name}.yaml",
294
+ f"evaluators/{base_name}.yaml",
295
+ ]:
296
+ custom_path = Path(custom_dir) / pattern
297
+ if custom_path.exists():
298
+ logger.debug(f"Loading schema from custom path: {custom_path}")
299
+ with open(custom_path, "r") as f:
300
+ schema = yaml.safe_load(f)
301
+ logger.debug(f"Loaded schema with keys: {list(schema.keys())}")
302
+ # Don't cache custom paths (they may change during development)
303
+ return cast(dict[str, Any], schema)
304
+
305
+ # 4. Try package resources with standard search paths
251
306
  for search_pattern in SCHEMA_SEARCH_PATHS:
252
307
  search_path = search_pattern.format(name=base_name)
253
308
 
@@ -272,7 +327,7 @@ def load_agent_schema(
272
327
  logger.debug(f"Could not load from {search_path}: {e}")
273
328
  continue
274
329
 
275
- # 4. Try database LOOKUP fallback (if enabled and user_id provided)
330
+ # 5. Try database LOOKUP fallback (if enabled and user_id provided)
276
331
  if enable_db_fallback and user_id:
277
332
  try:
278
333
  logger.debug(f"Attempting database LOOKUP for schema: {base_name} (user_id={user_id})")
@@ -284,8 +339,13 @@ def load_agent_schema(
284
339
  logger.debug(f"Database schema lookup failed: {e}")
285
340
  # Fall through to error below
286
341
 
287
- # 5. Schema not found in any location
342
+ # 6. Schema not found in any location
288
343
  searched_paths = [pattern.format(name=base_name) for pattern in SCHEMA_SEARCH_PATHS]
344
+
345
+ custom_paths_note = ""
346
+ if custom_paths:
347
+ custom_paths_note = f"\n - Custom paths: {', '.join(custom_paths)}"
348
+
289
349
  db_search_note = ""
290
350
  if enable_db_fallback:
291
351
  if user_id:
@@ -296,12 +356,129 @@ def load_agent_schema(
296
356
  raise FileNotFoundError(
297
357
  f"Schema not found: {schema_name_or_path}\n"
298
358
  f"Searched locations:\n"
299
- f" - Exact path: {path}\n"
359
+ f" - Exact path: {path}"
360
+ f"{custom_paths_note}\n"
300
361
  f" - Package resources: {', '.join(searched_paths)}"
301
362
  f"{db_search_note}"
302
363
  )
303
364
 
304
365
 
366
+ async def load_agent_schema_async(
367
+ schema_name_or_path: str,
368
+ user_id: str | None = None,
369
+ db=None,
370
+ ) -> dict[str, Any]:
371
+ """
372
+ Async version of load_agent_schema for use in async contexts.
373
+
374
+ Schema names are case-invariant - "MyAgent", "myagent", "MYAGENT" all resolve to the same schema.
375
+
376
+ This version accepts an existing database connection to avoid creating new connections.
377
+
378
+ Args:
379
+ schema_name_or_path: Schema name or file path (case-invariant for names)
380
+ user_id: User ID for database schema lookup
381
+ db: Optional existing PostgresService connection (if None, will create one)
382
+
383
+ Returns:
384
+ Agent schema as dictionary
385
+
386
+ Raises:
387
+ FileNotFoundError: If schema not found
388
+ """
389
+ # First try filesystem search (sync operations are fine)
390
+ path = Path(schema_name_or_path)
391
+
392
+ # Normalize the name for cache key (lowercase for case-invariant lookups)
393
+ cache_key = str(schema_name_or_path).replace('agents/', '').replace('schemas/', '').replace('evaluators/', '').replace('core/', '').replace('examples/', '').lower()
394
+ if cache_key.endswith('.yaml') or cache_key.endswith('.yml'):
395
+ cache_key = cache_key.rsplit('.', 1)[0]
396
+
397
+ is_custom_path = path.exists() or '/' in str(schema_name_or_path) or '\\' in str(schema_name_or_path)
398
+
399
+ # Check cache
400
+ if not is_custom_path and cache_key in _fs_schema_cache:
401
+ logger.debug(f"Loading schema from cache: {cache_key}")
402
+ return _fs_schema_cache[cache_key]
403
+
404
+ # Try exact path
405
+ if path.exists():
406
+ logger.debug(f"Loading schema from exact path: {path}")
407
+ with open(path, "r") as f:
408
+ schema = yaml.safe_load(f)
409
+ return cast(dict[str, Any], schema)
410
+
411
+ base_name = cache_key
412
+
413
+ # Try custom schema paths (from registry + SCHEMA__PATHS env var + auto-detected)
414
+ from ..registry import get_schema_paths
415
+ custom_paths = get_schema_paths()
416
+
417
+ # Auto-detect local folders if they exist (convention over configuration)
418
+ auto_detect_folders = ["./agents", "./schemas", "./evaluators"]
419
+ for auto_folder in auto_detect_folders:
420
+ auto_path = Path(auto_folder)
421
+ if auto_path.exists() and auto_path.is_dir():
422
+ resolved = str(auto_path.resolve())
423
+ if resolved not in custom_paths:
424
+ custom_paths.insert(0, resolved)
425
+ logger.debug(f"Auto-detected schema directory: {auto_folder}")
426
+
427
+ for custom_dir in custom_paths:
428
+ for pattern in [f"{base_name}.yaml", f"{base_name}.yml", f"agents/{base_name}.yaml"]:
429
+ custom_path = Path(custom_dir) / pattern
430
+ if custom_path.exists():
431
+ with open(custom_path, "r") as f:
432
+ schema = yaml.safe_load(f)
433
+ return cast(dict[str, Any], schema)
434
+
435
+ # Try package resources
436
+ for search_pattern in SCHEMA_SEARCH_PATHS:
437
+ search_path = search_pattern.format(name=base_name)
438
+ try:
439
+ schema_ref = importlib.resources.files("rem") / search_path
440
+ schema_path = Path(str(schema_ref))
441
+ if schema_path.exists():
442
+ with open(schema_path, "r") as f:
443
+ schema = yaml.safe_load(f)
444
+ _fs_schema_cache[cache_key] = schema
445
+ return cast(dict[str, Any], schema)
446
+ except Exception:
447
+ continue
448
+
449
+ # Try database lookup
450
+ if user_id:
451
+ from rem.services.postgres import get_postgres_service
452
+
453
+ should_disconnect = False
454
+ if db is None:
455
+ db = get_postgres_service()
456
+ if db:
457
+ await db.connect()
458
+ should_disconnect = True
459
+
460
+ if db:
461
+ try:
462
+ query = """
463
+ SELECT spec FROM schemas
464
+ WHERE LOWER(name) = LOWER($1)
465
+ AND (user_id = $2 OR user_id = 'system' OR user_id IS NULL)
466
+ LIMIT 1
467
+ """
468
+ row = await db.fetchrow(query, base_name, user_id)
469
+ if row:
470
+ spec = row.get("spec")
471
+ if spec and isinstance(spec, dict):
472
+ logger.info(f"✅ Loaded schema from database: {base_name}")
473
+ return spec
474
+ finally:
475
+ if should_disconnect:
476
+ await db.disconnect()
477
+
478
+ # Not found
479
+ raise FileNotFoundError(f"Schema not found: {schema_name_or_path}")
480
+
481
+
305
482
  def validate_agent_schema(schema: dict[str, Any]) -> bool:
306
483
  """
307
484
  Validate agent schema structure.
@@ -334,3 +511,73 @@ def validate_agent_schema(schema: dict[str, Any]) -> bool:
334
511
 
335
512
  logger.debug("Schema validation passed")
336
513
  return True
514
+
515
+
516
+ def get_evaluator_schema_path(evaluator_name: str) -> Path | None:
517
+ """
518
+ Find the file path to an evaluator schema.
519
+
520
+ Searches standard locations for the evaluator schema YAML file:
521
+ - ./evaluators/{name}.yaml (local project)
522
+ - Custom schema paths from registry
523
+ - Package resources: schemas/evaluators/{name}.yaml
524
+
525
+ Args:
526
+ evaluator_name: Name of the evaluator (e.g., "mental-health-classifier")
527
+
528
+ Returns:
529
+ Path to the evaluator schema file, or None if not found
530
+
531
+ Example:
532
+ >>> path = get_evaluator_schema_path("mental-health-classifier")
533
+ >>> if path:
534
+ ... print(f"Found evaluator at: {path}")
535
+ """
536
+ from ..registry import get_schema_paths
537
+
538
+ base_name = evaluator_name.lower().replace('.yaml', '').replace('.yml', '')
539
+
540
+ # 1. Try custom schema paths (from registry + auto-detected)
541
+ custom_paths = get_schema_paths()
542
+
543
+ # Auto-detect local folders
544
+ auto_detect_folders = ["./evaluators", "./schemas", "./agents"]
545
+ for auto_folder in auto_detect_folders:
546
+ auto_path = Path(auto_folder)
547
+ if auto_path.exists() and auto_path.is_dir():
548
+ resolved = str(auto_path.resolve())
549
+ if resolved not in custom_paths:
550
+ custom_paths.insert(0, resolved)
551
+
552
+ for custom_dir in custom_paths:
553
+ # Try various patterns within each custom directory
554
+ for pattern in [
555
+ f"{base_name}.yaml",
556
+ f"{base_name}.yml",
557
+ f"evaluators/{base_name}.yaml",
558
+ ]:
559
+ custom_path = Path(custom_dir) / pattern
560
+ if custom_path.exists():
561
+ logger.debug(f"Found evaluator schema: {custom_path}")
562
+ return custom_path
563
+
564
+ # 2. Try package resources
565
+ evaluator_search_paths = [
566
+ f"schemas/evaluators/{base_name}.yaml",
567
+ f"schemas/evaluators/rem/{base_name}.yaml",
568
+ ]
569
+
570
+ for search_path in evaluator_search_paths:
571
+ try:
572
+ schema_ref = importlib.resources.files("rem") / search_path
573
+ schema_path = Path(str(schema_ref))
574
+
575
+ if schema_path.exists():
576
+ logger.debug(f"Found evaluator schema in package: {schema_path}")
577
+ return schema_path
578
+ except Exception as e:
579
+ logger.debug(f"Could not check {search_path}: {e}")
580
+ continue
581
+
582
+ logger.warning(f"Evaluator schema not found: {evaluator_name}")
583
+ return None