remdb 0.3.157__py3-none-any.whl → 0.3.180__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.
Files changed (37) hide show
  1. rem/agentic/agents/agent_manager.py +2 -1
  2. rem/agentic/context.py +81 -3
  3. rem/agentic/context_builder.py +31 -6
  4. rem/agentic/mcp/tool_wrapper.py +43 -14
  5. rem/agentic/providers/pydantic_ai.py +76 -34
  6. rem/agentic/schema.py +4 -3
  7. rem/agentic/tools/rem_tools.py +11 -0
  8. rem/api/deps.py +1 -3
  9. rem/api/main.py +21 -2
  10. rem/api/mcp_router/resources.py +75 -14
  11. rem/api/mcp_router/server.py +27 -24
  12. rem/api/mcp_router/tools.py +83 -2
  13. rem/api/middleware/tracking.py +5 -5
  14. rem/api/routers/auth.py +152 -10
  15. rem/api/routers/chat/completions.py +5 -3
  16. rem/api/routers/chat/streaming.py +18 -0
  17. rem/api/routers/messages.py +24 -15
  18. rem/auth/jwt.py +352 -0
  19. rem/auth/middleware.py +70 -30
  20. rem/cli/commands/ask.py +1 -1
  21. rem/cli/commands/db.py +98 -44
  22. rem/models/entities/ontology.py +93 -101
  23. rem/schemas/agents/core/agent-builder.yaml +143 -42
  24. rem/services/email/service.py +72 -9
  25. rem/services/postgres/register_type.py +1 -1
  26. rem/services/postgres/repository.py +5 -4
  27. rem/services/user_service.py +41 -9
  28. rem/settings.py +15 -1
  29. rem/sql/background_indexes.sql +5 -0
  30. rem/sql/migrations/001_install.sql +33 -4
  31. rem/sql/migrations/002_install_models.sql +186 -168
  32. rem/utils/model_helpers.py +101 -0
  33. rem/utils/schema_loader.py +45 -7
  34. {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/METADATA +1 -1
  35. {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/RECORD +37 -36
  36. {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/WHEEL +0 -0
  37. {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/entry_points.txt +0 -0
@@ -349,6 +349,53 @@ def register_agent_resources(mcp: FastMCP):
349
349
  except Exception as e:
350
350
  return f"# Available Agents\n\nError listing agents: {e}"
351
351
 
352
+ @mcp.resource("rem://agents/{agent_name}")
353
+ def get_agent_schema(agent_name: str) -> str:
354
+ """
355
+ Get a specific agent schema by name.
356
+
357
+ Args:
358
+ agent_name: Name of the agent (e.g., "ask_rem", "agent-builder")
359
+
360
+ Returns:
361
+ Full agent schema as YAML string, or error message if not found.
362
+ """
363
+ import importlib.resources
364
+ import yaml
365
+ from pathlib import Path
366
+
367
+ try:
368
+ # Find packaged agent schemas
369
+ agents_ref = importlib.resources.files("rem") / "schemas" / "agents"
370
+ agents_dir = Path(str(agents_ref))
371
+
372
+ if not agents_dir.exists():
373
+ return f"# Agent Not Found\n\nNo agent schemas directory found."
374
+
375
+ # Search for agent file (try multiple extensions)
376
+ for ext in [".yaml", ".yml", ".json"]:
377
+ # Try exact match first
378
+ agent_file = agents_dir / f"{agent_name}{ext}"
379
+ if agent_file.exists():
380
+ with open(agent_file, "r") as f:
381
+ content = f.read()
382
+ return f"# Agent Schema: {agent_name}\n\n```yaml\n{content}\n```"
383
+
384
+ # Try recursive search
385
+ matches = list(agents_dir.rglob(f"{agent_name}{ext}"))
386
+ if matches:
387
+ with open(matches[0], "r") as f:
388
+ content = f.read()
389
+ return f"# Agent Schema: {agent_name}\n\n```yaml\n{content}\n```"
390
+
391
+ # Not found - list available agents
392
+ available = [f.stem for f in agents_dir.rglob("*.yaml")] + \
393
+ [f.stem for f in agents_dir.rglob("*.yml")]
394
+ return f"# Agent Not Found\n\nAgent '{agent_name}' not found.\n\nAvailable agents: {', '.join(sorted(set(available)))}"
395
+
396
+ except Exception as e:
397
+ return f"# Error\n\nError loading agent '{agent_name}': {e}"
398
+
352
399
 
353
400
  def register_file_resources(mcp: FastMCP):
354
401
  """
@@ -501,10 +548,11 @@ async def load_resource(uri: str) -> dict | str:
501
548
  Load an MCP resource by URI.
502
549
 
503
550
  This function is called by the read_resource tool to dispatch to
504
- registered resource handlers.
551
+ registered resource handlers. Supports both regular resources and
552
+ parameterized resource templates (e.g., rem://agents/{agent_name}).
505
553
 
506
554
  Args:
507
- uri: Resource URI (e.g., "rem://schemas", "rem://status")
555
+ uri: Resource URI (e.g., "rem://agents", "rem://agents/ask_rem", "rem://status")
508
556
 
509
557
  Returns:
510
558
  Resource data (dict or string)
@@ -512,9 +560,10 @@ async def load_resource(uri: str) -> dict | str:
512
560
  Raises:
513
561
  ValueError: If URI is invalid or resource not found
514
562
  """
515
- # Create temporary MCP instance with resources
563
+ import inspect
516
564
  from fastmcp import FastMCP
517
565
 
566
+ # Create temporary MCP instance with resources
518
567
  mcp = FastMCP(name="temp")
519
568
 
520
569
  # Register all resources
@@ -523,14 +572,26 @@ async def load_resource(uri: str) -> dict | str:
523
572
  register_file_resources(mcp)
524
573
  register_status_resources(mcp)
525
574
 
526
- # Get resource handlers from MCP internal registry
527
- # FastMCP stores resources in a dict by URI
528
- if hasattr(mcp, "_resources"):
529
- if uri in mcp._resources:
530
- handler = mcp._resources[uri]
531
- if callable(handler):
532
- result = handler()
533
- return result if result else {"error": "Resource returned None"}
534
-
535
- # If not found, raise error
536
- raise ValueError(f"Resource not found: {uri}. Available resources: {list(mcp._resources.keys()) if hasattr(mcp, '_resources') else 'unknown'}")
575
+ # 1. Try exact match in regular resources
576
+ resources = await mcp.get_resources()
577
+ if uri in resources:
578
+ resource = resources[uri]
579
+ result = resource.fn()
580
+ if inspect.iscoroutine(result):
581
+ result = await result
582
+ return result if result else {"error": "Resource returned None"}
583
+
584
+ # 2. Try matching against parameterized resource templates
585
+ templates = await mcp.get_resource_templates()
586
+ for template_uri, template in templates.items():
587
+ params = template.matches(uri)
588
+ if params is not None:
589
+ # Template matched - call function with extracted parameters
590
+ result = template.fn(**params)
591
+ if inspect.iscoroutine(result):
592
+ result = await result
593
+ return result if result else {"error": "Resource returned None"}
594
+
595
+ # 3. Not found - include both resources and templates in error
596
+ available = list(resources.keys()) + list(templates.keys())
597
+ raise ValueError(f"Resource not found: {uri}. Available resources: {available}")
@@ -1,7 +1,7 @@
1
1
  """
2
2
  MCP server creation and configuration for REM.
3
3
 
4
- Design Pattern
4
+ Design Pattern
5
5
  1. Create FastMCP server with tools and resources
6
6
  2. Register tools using @mcp.tool() decorator
7
7
  3. Register resources using resource registration functions
@@ -20,10 +20,30 @@ FastMCP Features:
20
20
  """
21
21
 
22
22
  import importlib.metadata
23
+ from functools import wraps
23
24
 
24
25
  from fastmcp import FastMCP
26
+ from loguru import logger
25
27
 
26
28
  from ...settings import settings
29
+ from .prompts import register_prompts
30
+ from .resources import (
31
+ register_agent_resources,
32
+ register_file_resources,
33
+ register_schema_resources,
34
+ register_status_resources,
35
+ )
36
+ from .tools import (
37
+ ask_rem_agent,
38
+ get_schema,
39
+ ingest_into_rem,
40
+ list_schema,
41
+ read_resource,
42
+ register_metadata,
43
+ save_agent,
44
+ search_rem,
45
+ test_error_handling,
46
+ )
27
47
 
28
48
  # Get package version
29
49
  try:
@@ -174,18 +194,7 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
174
194
  ),
175
195
  )
176
196
 
177
- # Register REM tools
178
- from .tools import (
179
- ask_rem_agent,
180
- get_schema,
181
- ingest_into_rem,
182
- list_schema,
183
- read_resource,
184
- register_metadata,
185
- save_agent,
186
- search_rem,
187
- )
188
-
197
+ # Register core REM tools
189
198
  mcp.tool()(search_rem)
190
199
  mcp.tool()(ask_rem_agent)
191
200
  mcp.tool()(read_resource)
@@ -194,10 +203,13 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
194
203
  mcp.tool()(get_schema)
195
204
  mcp.tool()(save_agent)
196
205
 
206
+ # Register test tool only in development environment (not staging/production)
207
+ if settings.environment not in ("staging", "production"):
208
+ mcp.tool()(test_error_handling)
209
+ logger.debug("Registered test_error_handling tool (dev environment only)")
210
+
197
211
  # File ingestion tool (with local path support for local servers)
198
212
  # Wrap to inject is_local parameter
199
- from functools import wraps
200
-
201
213
  @wraps(ingest_into_rem)
202
214
  async def ingest_into_rem_wrapper(
203
215
  file_uri: str,
@@ -216,18 +228,9 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
216
228
  mcp.tool()(ingest_into_rem_wrapper)
217
229
 
218
230
  # Register prompts
219
- from .prompts import register_prompts
220
-
221
231
  register_prompts(mcp)
222
232
 
223
233
  # Register schema resources
224
- from .resources import (
225
- register_agent_resources,
226
- register_file_resources,
227
- register_schema_resources,
228
- register_status_resources,
229
- )
230
-
231
234
  register_schema_resources(mcp)
232
235
  register_agent_resources(mcp)
233
236
  register_file_resources(mcp)
@@ -116,7 +116,8 @@ def mcp_tool_error_handler(func: Callable) -> Callable:
116
116
  # Otherwise wrap in success response
117
117
  return {"status": "success", **result}
118
118
  except Exception as e:
119
- logger.error(f"{func.__name__} failed: {e}", exc_info=True)
119
+ # Use %s format to avoid issues with curly braces in error messages
120
+ logger.opt(exception=True).error("{} failed: {}", func.__name__, str(e))
120
121
  return {
121
122
  "status": "error",
122
123
  "error": str(e),
@@ -380,9 +381,10 @@ async def ask_rem_agent(
380
381
  from ...utils.schema_loader import load_agent_schema
381
382
 
382
383
  # Create agent context
384
+ # Note: tenant_id defaults to "default" if user_id is None
383
385
  context = AgentContext(
384
386
  user_id=user_id,
385
- tenant_id=user_id, # Set tenant_id to user_id for backward compat
387
+ tenant_id=user_id or "default", # Use default tenant for anonymous users
386
388
  default_model=settings.llm.default_model,
387
389
  )
388
390
 
@@ -1130,3 +1132,82 @@ async def save_agent(
1130
1132
  result["message"] = f"Agent '{name}' saved. Use `/custom-agent {name}` to chat with it."
1131
1133
 
1132
1134
  return result
1135
+
1136
+
1137
+ # =============================================================================
1138
+ # Test/Debug Tools (for development only)
1139
+ # =============================================================================
1140
+
1141
+ @mcp_tool_error_handler
1142
+ async def test_error_handling(
1143
+ error_type: Literal["exception", "error_response", "timeout", "success"] = "success",
1144
+ delay_seconds: float = 0,
1145
+ error_message: str = "Test error occurred",
1146
+ ) -> dict[str, Any]:
1147
+ """
1148
+ Test tool for simulating different error scenarios.
1149
+
1150
+ **FOR DEVELOPMENT/TESTING ONLY** - This tool helps verify that error
1151
+ handling works correctly through the streaming layer.
1152
+
1153
+ Args:
1154
+ error_type: Type of error to simulate:
1155
+ - "success": Returns successful response (default)
1156
+ - "exception": Raises an exception (tests @mcp_tool_error_handler)
1157
+ - "error_response": Returns {"status": "error", ...} dict
1158
+ - "timeout": Delays for 60 seconds (simulates timeout)
1159
+ delay_seconds: Optional delay before responding (0-10 seconds)
1160
+ error_message: Custom error message for error scenarios
1161
+
1162
+ Returns:
1163
+ Dict with test results or error information
1164
+
1165
+ Examples:
1166
+ # Test successful response
1167
+ test_error_handling(error_type="success")
1168
+
1169
+ # Test exception handling
1170
+ test_error_handling(error_type="exception", error_message="Database connection failed")
1171
+
1172
+ # Test error response format
1173
+ test_error_handling(error_type="error_response", error_message="Resource not found")
1174
+
1175
+ # Test with delay
1176
+ test_error_handling(error_type="success", delay_seconds=2)
1177
+ """
1178
+ import asyncio
1179
+
1180
+ logger.info(f"test_error_handling called: type={error_type}, delay={delay_seconds}")
1181
+
1182
+ # Apply delay (capped at 10 seconds for safety)
1183
+ if delay_seconds > 0:
1184
+ await asyncio.sleep(min(delay_seconds, 10))
1185
+
1186
+ if error_type == "exception":
1187
+ # This tests the @mcp_tool_error_handler decorator
1188
+ raise RuntimeError(f"TEST EXCEPTION: {error_message}")
1189
+
1190
+ elif error_type == "error_response":
1191
+ # This tests how the streaming layer handles error status responses
1192
+ return {
1193
+ "status": "error",
1194
+ "error": error_message,
1195
+ "error_code": "TEST_ERROR",
1196
+ "recoverable": True,
1197
+ }
1198
+
1199
+ elif error_type == "timeout":
1200
+ # Simulate a very long operation (for testing client-side timeouts)
1201
+ await asyncio.sleep(60)
1202
+ return {"status": "success", "message": "Timeout test completed (should not reach here)"}
1203
+
1204
+ else: # success
1205
+ return {
1206
+ "status": "success",
1207
+ "message": "Test completed successfully",
1208
+ "test_data": {
1209
+ "error_type": error_type,
1210
+ "delay_applied": delay_seconds,
1211
+ "timestamp": str(asyncio.get_event_loop().time()),
1212
+ },
1213
+ }
@@ -102,14 +102,14 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
102
102
  # Tenant ID from header or default
103
103
  tenant_id = request.headers.get("X-Tenant-Id", "default")
104
104
 
105
- # 4. Rate Limiting
106
- if settings.postgres.enabled:
105
+ # 4. Rate Limiting (skip if disabled via settings)
106
+ if settings.postgres.enabled and settings.api.rate_limit_enabled:
107
107
  is_allowed, current, limit = await self.rate_limiter.check_rate_limit(
108
108
  tenant_id=tenant_id,
109
109
  identifier=identifier,
110
110
  tier=tier
111
111
  )
112
-
112
+
113
113
  if not is_allowed:
114
114
  return JSONResponse(
115
115
  status_code=429,
@@ -141,8 +141,8 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
141
141
  secure=settings.environment == "production"
142
142
  )
143
143
 
144
- # Add Rate Limit headers
145
- if settings.postgres.enabled and 'limit' in locals():
144
+ # Add Rate Limit headers (only if rate limiting is enabled)
145
+ if settings.postgres.enabled and settings.api.rate_limit_enabled and 'limit' in locals():
146
146
  response.headers["X-RateLimit-Limit"] = str(limit)
147
147
  response.headers["X-RateLimit-Remaining"] = str(max(0, limit - current))
148
148
 
rem/api/routers/auth.py CHANGED
@@ -30,14 +30,17 @@ Access Control Flow (send-code):
30
30
  │ ├── Yes → Check user.tier
31
31
  │ │ ├── tier == BLOCKED → Reject "Account is blocked"
32
32
  │ │ └── tier != BLOCKED → Allow (send code, existing users grandfathered)
33
- │ └── No (new user) → Check EMAIL__TRUSTED_EMAIL_DOMAINS
34
- │ ├── Setting configureddomain in trusted list?
35
- │ ├── Yes Create user & send code
36
- │ └── NoReject "Email domain not allowed for signup"
37
- └── Not configured (empty) → Create user & send code (no restrictions)
33
+ │ └── No (new user) → Check subscriber list first
34
+ │ ├── Email in subscribers table? Allow (create user & send code)
35
+ └── Not a subscriber Check EMAIL__TRUSTED_EMAIL_DOMAINS
36
+ ├── Setting configured → domain in trusted list?
37
+ │ ├── Yes → Create user & send code
38
+ │ │ └── No → Reject "Email domain not allowed for signup"
39
+ │ └── Not configured (empty) → Create user & send code (no restrictions)
38
40
 
39
41
  Key Behaviors:
40
42
  - Existing users: Always allowed to login (unless tier=BLOCKED)
43
+ - Subscribers: Always allowed to login (regardless of email domain)
41
44
  - New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
42
45
  - No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
43
46
 
@@ -102,6 +105,8 @@ from ...settings import settings
102
105
  from ...services.postgres.service import PostgresService
103
106
  from ...services.user_service import UserService
104
107
  from ...auth.providers.email import EmailAuthProvider
108
+ from ...auth.jwt import JWTService, get_jwt_service
109
+ from ...utils.user_id import email_to_user_id
105
110
 
106
111
  router = APIRouter(prefix="/api/auth", tags=["auth"])
107
112
 
@@ -219,14 +224,14 @@ async def send_email_code(request: Request, body: EmailSendCodeRequest):
219
224
  @router.post("/email/verify")
220
225
  async def verify_email_code(request: Request, body: EmailVerifyRequest):
221
226
  """
222
- Verify login code and create session.
227
+ Verify login code and create session with JWT tokens.
223
228
 
224
229
  Args:
225
230
  request: FastAPI request
226
231
  body: EmailVerifyRequest with email and code
227
232
 
228
233
  Returns:
229
- Success status with user info
234
+ Success status with user info and JWT tokens
230
235
  """
231
236
  if not settings.email.is_configured:
232
237
  raise HTTPException(
@@ -266,7 +271,25 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
266
271
  user_id=result.user_id,
267
272
  )
268
273
 
269
- # Store user in session
274
+ # Fetch actual user data from database to get role/tier
275
+ user_service = UserService(db)
276
+ try:
277
+ user_entity = await user_service.get_user_by_id(result.user_id)
278
+ if user_entity:
279
+ # Override defaults with actual database values
280
+ user_dict["role"] = user_entity.role or "user"
281
+ user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
282
+ user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
283
+ user_dict["name"] = user_entity.name or user_dict["name"]
284
+ except Exception as e:
285
+ logger.warning(f"Could not fetch user details: {e}")
286
+ # Continue with defaults from get_user_dict
287
+
288
+ # Generate JWT tokens
289
+ jwt_service = get_jwt_service()
290
+ tokens = jwt_service.create_tokens(user_dict)
291
+
292
+ # Store user in session (for backward compatibility)
270
293
  request.session["user"] = user_dict
271
294
 
272
295
  logger.info(f"User authenticated via email: {result.email}")
@@ -275,6 +298,11 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
275
298
  "success": True,
276
299
  "message": result.message,
277
300
  "user": user_dict,
301
+ # JWT tokens for stateless auth
302
+ "access_token": tokens["access_token"],
303
+ "refresh_token": tokens["refresh_token"],
304
+ "token_type": tokens["token_type"],
305
+ "expires_in": tokens["expires_in"],
278
306
  }
279
307
 
280
308
  except HTTPException:
@@ -405,8 +433,9 @@ async def callback(provider: str, request: Request):
405
433
  await user_service.link_anonymous_session(user_entity, anon_id)
406
434
 
407
435
  # Enrich session user with DB info
436
+ # user_id = UUID5 hash of email (deterministic, bijection)
408
437
  db_info = {
409
- "id": str(user_entity.id),
438
+ "id": email_to_user_id(user_info.get("email")),
410
439
  "tenant_id": user_entity.tenant_id,
411
440
  "tier": user_entity.tier.value if user_entity.tier else "free",
412
441
  "roles": [user_entity.role] if user_entity.role else [],
@@ -472,7 +501,7 @@ async def logout(request: Request):
472
501
  @router.get("/me")
473
502
  async def me(request: Request):
474
503
  """
475
- Get current user information from session.
504
+ Get current user information from session or JWT.
476
505
 
477
506
  Args:
478
507
  request: FastAPI request
@@ -480,6 +509,16 @@ async def me(request: Request):
480
509
  Returns:
481
510
  User information or 401 if not authenticated
482
511
  """
512
+ # First check for JWT in Authorization header
513
+ auth_header = request.headers.get("Authorization")
514
+ if auth_header and auth_header.startswith("Bearer "):
515
+ token = auth_header[7:]
516
+ jwt_service = get_jwt_service()
517
+ user = jwt_service.verify_token(token)
518
+ if user:
519
+ return user
520
+
521
+ # Fall back to session
483
522
  user = request.session.get("user")
484
523
  if not user:
485
524
  raise HTTPException(status_code=401, detail="Not authenticated")
@@ -487,6 +526,69 @@ async def me(request: Request):
487
526
  return user
488
527
 
489
528
 
529
+ # =============================================================================
530
+ # JWT Token Endpoints
531
+ # =============================================================================
532
+
533
+
534
+ class TokenRefreshRequest(BaseModel):
535
+ """Request to refresh access token."""
536
+ refresh_token: str
537
+
538
+
539
+ @router.post("/token/refresh")
540
+ async def refresh_token(body: TokenRefreshRequest):
541
+ """
542
+ Refresh access token using refresh token.
543
+
544
+ Args:
545
+ body: TokenRefreshRequest with refresh_token
546
+
547
+ Returns:
548
+ New access token or 401 if refresh token is invalid
549
+ """
550
+ jwt_service = get_jwt_service()
551
+ result = jwt_service.refresh_access_token(body.refresh_token)
552
+
553
+ if not result:
554
+ raise HTTPException(
555
+ status_code=401,
556
+ detail="Invalid or expired refresh token"
557
+ )
558
+
559
+ return result
560
+
561
+
562
+ @router.post("/token/verify")
563
+ async def verify_token(request: Request):
564
+ """
565
+ Verify an access token is valid.
566
+
567
+ Pass the token in the Authorization header: Bearer <token>
568
+
569
+ Returns:
570
+ User info if valid, 401 if invalid
571
+ """
572
+ auth_header = request.headers.get("Authorization")
573
+ if not auth_header or not auth_header.startswith("Bearer "):
574
+ raise HTTPException(
575
+ status_code=401,
576
+ detail="Missing Authorization header"
577
+ )
578
+
579
+ token = auth_header[7:]
580
+ jwt_service = get_jwt_service()
581
+ user = jwt_service.verify_token(token)
582
+
583
+ if not user:
584
+ raise HTTPException(
585
+ status_code=401,
586
+ detail="Invalid or expired token"
587
+ )
588
+
589
+ return {"valid": True, "user": user}
590
+
591
+
490
592
  # =============================================================================
491
593
  # Development Token Endpoints (non-production only)
492
594
  # =============================================================================
@@ -555,3 +657,43 @@ async def get_dev_token(request: Request):
555
657
  "usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
556
658
  "warning": "This token is for development/testing only and will not work in production.",
557
659
  }
660
+
661
+
662
+ @router.get("/dev/mock-code/{email}")
663
+ async def get_mock_code(email: str, request: Request):
664
+ """
665
+ Get the mock login code for testing (non-production only).
666
+
667
+ This endpoint retrieves the code that was "sent" via email in mock mode.
668
+ Use this for automated testing without real email delivery.
669
+
670
+ Usage:
671
+ 1. POST /api/auth/email/send-code with email
672
+ 2. GET /api/auth/dev/mock-code/{email} to retrieve the code
673
+ 3. POST /api/auth/email/verify with email and code
674
+
675
+ Returns:
676
+ 401 if in production environment
677
+ 404 if no code found for the email
678
+ The code and email otherwise
679
+ """
680
+ if settings.environment == "production":
681
+ raise HTTPException(
682
+ status_code=401,
683
+ detail="Mock codes are not available in production"
684
+ )
685
+
686
+ from ...services.email import EmailService
687
+
688
+ code = EmailService.get_mock_code(email)
689
+ if not code:
690
+ raise HTTPException(
691
+ status_code=404,
692
+ detail=f"No mock code found for {email}. Send a code first."
693
+ )
694
+
695
+ return {
696
+ "email": email,
697
+ "code": code,
698
+ "warning": "This endpoint is for testing only and will not work in production.",
699
+ }
@@ -97,7 +97,7 @@ Context Building Flow:
97
97
  - Long messages include REM LOOKUP hints: "... [REM LOOKUP session-{id}-msg-{index}] ..."
98
98
  - Agent can retrieve full content on-demand using REM LOOKUP
99
99
  3. User profile provided as REM LOOKUP hint (on-demand by default)
100
- - Agent receives: "User ID: {user_id}. To load user profile: Use REM LOOKUP users/{user_id}"
100
+ - Agent receives: "User: {email}. To load user profile: Use REM LOOKUP \"{email}\""
101
101
  - Agent decides whether to load profile based on query
102
102
  4. If CHAT__AUTO_INJECT_USER_CONTEXT=true: User profile auto-loaded and injected
103
103
  5. Combines: system context + compressed session history + new messages
@@ -330,8 +330,8 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
330
330
  - Useful for A/B testing, model comparison, and feedback collection
331
331
  """
332
332
  # Load agent schema: use header value from context or default
333
- # Extract AgentContext first to get schema name
334
- temp_context = AgentContext.from_headers(dict(request.headers))
333
+ # Extract AgentContext from request (gets user_id from JWT token)
334
+ temp_context = AgentContext.from_request(request)
335
335
  schema_name = temp_context.agent_schema_uri or DEFAULT_AGENT_SCHEMA
336
336
 
337
337
  # Resolve model: use body.model if provided, otherwise settings default
@@ -350,6 +350,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
350
350
  context, messages = await ContextBuilder.build_from_headers(
351
351
  headers=dict(request.headers),
352
352
  new_messages=new_messages,
353
+ user_id=temp_context.user_id, # From JWT token (source of truth)
353
354
  )
354
355
 
355
356
  # Ensure session exists with metadata and eval mode if applicable
@@ -509,6 +510,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
509
510
  context, messages = await ContextBuilder.build_from_headers(
510
511
  headers=dict(request.headers),
511
512
  new_messages=new_messages,
513
+ user_id=temp_context.user_id, # From JWT token (source of truth)
512
514
  )
513
515
 
514
516
  logger.info(f"Built context with {len(messages)} total messages (includes history + user context)")
@@ -835,3 +835,21 @@ async def stream_openai_response_with_save(
835
835
  )
836
836
  except Exception as e:
837
837
  logger.error(f"Failed to save session messages: {e}", exc_info=True)
838
+
839
+ # Update session description with session_name (non-blocking, after all yields)
840
+ for tool_call in tool_calls:
841
+ if tool_call.get("tool_name") == "register_metadata" and tool_call.get("is_metadata"):
842
+ session_name = tool_call.get("arguments", {}).get("session_name")
843
+ if session_name:
844
+ try:
845
+ from ....models.entities import Session
846
+ from ....services.postgres import Repository
847
+ repo = Repository(Session, table_name="sessions")
848
+ session = await repo.get_by_id(session_id)
849
+ if session and session.description != session_name:
850
+ session.description = session_name
851
+ await repo.update(session)
852
+ logger.debug(f"Updated session {session_id} description to '{session_name}'")
853
+ except Exception as e:
854
+ logger.warning(f"Failed to update session description: {e}")
855
+ break