remdb 0.3.230__py3-none-any.whl → 0.3.258__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 (40) hide show
  1. rem/agentic/__init__.py +10 -1
  2. rem/agentic/context.py +13 -2
  3. rem/agentic/context_builder.py +45 -34
  4. rem/agentic/providers/pydantic_ai.py +302 -110
  5. rem/api/mcp_router/resources.py +223 -0
  6. rem/api/mcp_router/tools.py +76 -10
  7. rem/api/routers/auth.py +113 -10
  8. rem/api/routers/chat/child_streaming.py +22 -8
  9. rem/api/routers/chat/completions.py +3 -3
  10. rem/api/routers/chat/sse_events.py +3 -3
  11. rem/api/routers/chat/streaming.py +40 -45
  12. rem/api/routers/chat/streaming_utils.py +5 -7
  13. rem/api/routers/feedback.py +2 -2
  14. rem/api/routers/query.py +5 -14
  15. rem/cli/commands/ask.py +144 -33
  16. rem/cli/commands/experiments.py +1 -1
  17. rem/cli/commands/process.py +9 -1
  18. rem/cli/commands/query.py +109 -0
  19. rem/cli/commands/session.py +117 -0
  20. rem/cli/main.py +2 -0
  21. rem/models/core/experiment.py +1 -1
  22. rem/models/entities/session.py +1 -0
  23. rem/schemas/agents/core/agent-builder.yaml +1 -1
  24. rem/schemas/agents/test_orchestrator.yaml +42 -0
  25. rem/schemas/agents/test_structured_output.yaml +52 -0
  26. rem/services/content/providers.py +151 -49
  27. rem/services/postgres/repository.py +1 -0
  28. rem/services/rem/README.md +4 -3
  29. rem/services/rem/parser.py +7 -10
  30. rem/services/rem/service.py +47 -0
  31. rem/services/session/compression.py +7 -3
  32. rem/services/session/pydantic_messages.py +25 -7
  33. rem/services/session/reload.py +2 -1
  34. rem/settings.py +64 -7
  35. rem/sql/migrations/004_cache_system.sql +3 -1
  36. rem/utils/schema_loader.py +135 -103
  37. {remdb-0.3.230.dist-info → remdb-0.3.258.dist-info}/METADATA +6 -5
  38. {remdb-0.3.230.dist-info → remdb-0.3.258.dist-info}/RECORD +40 -37
  39. {remdb-0.3.230.dist-info → remdb-0.3.258.dist-info}/WHEEL +0 -0
  40. {remdb-0.3.230.dist-info → remdb-0.3.258.dist-info}/entry_points.txt +0 -0
@@ -542,6 +542,227 @@ def register_status_resources(mcp: FastMCP):
542
542
  """
543
543
 
544
544
 
545
+ def register_session_resources(mcp: FastMCP):
546
+ """
547
+ Register session resources for loading conversation history.
548
+
549
+ Args:
550
+ mcp: FastMCP server instance
551
+ """
552
+
553
+ @mcp.resource("rem://sessions/{session_id}")
554
+ async def get_session_messages(session_id: str) -> str:
555
+ """
556
+ Load a conversation session by ID.
557
+
558
+ Returns the full message history including user messages, assistant responses,
559
+ and tool calls. Useful for evaluators and analysis agents.
560
+
561
+ Args:
562
+ session_id: Session UUID or identifier
563
+
564
+ Returns:
565
+ Formatted conversation history as markdown string with:
566
+ - Message type (user/assistant/tool)
567
+ - Content
568
+ - Timestamps
569
+ - Tool call details (if any)
570
+ """
571
+ from ...services.postgres import get_postgres_service
572
+
573
+ pg = get_postgres_service()
574
+ await pg.connect()
575
+
576
+ try:
577
+ # Query messages for session
578
+ query = """
579
+ SELECT id, message_type, content, metadata, created_at
580
+ FROM messages
581
+ WHERE session_id = $1
582
+ ORDER BY created_at ASC
583
+ """
584
+ messages = await pg.fetch(query, session_id)
585
+
586
+ if not messages:
587
+ return f"# Session Not Found\n\nNo messages found for session_id: {session_id}"
588
+
589
+ # Format output
590
+ output = [f"# Session: {session_id}\n"]
591
+ output.append(f"**Total messages:** {len(messages)}\n")
592
+
593
+ for i, msg in enumerate(messages, 1):
594
+ msg_type = msg['message_type']
595
+ content = msg['content'] or "(empty)"
596
+ created = msg['created_at']
597
+ metadata = msg.get('metadata') or {}
598
+
599
+ # Format based on message type
600
+ if msg_type == 'user':
601
+ output.append(f"\n## [{i}] USER ({created})")
602
+ output.append(f"```\n{content[:1000]}{'...' if len(content) > 1000 else ''}\n```")
603
+ elif msg_type == 'assistant':
604
+ output.append(f"\n## [{i}] ASSISTANT ({created})")
605
+ output.append(f"```\n{content[:1000]}{'...' if len(content) > 1000 else ''}\n```")
606
+ elif msg_type == 'tool':
607
+ tool_name = metadata.get('tool_name', 'unknown')
608
+ output.append(f"\n## [{i}] TOOL: {tool_name} ({created})")
609
+ # Truncate tool results more aggressively
610
+ output.append(f"```json\n{content[:500]}{'...' if len(content) > 500 else ''}\n```")
611
+ else:
612
+ output.append(f"\n## [{i}] {msg_type.upper()} ({created})")
613
+ output.append(f"```\n{content[:500]}{'...' if len(content) > 500 else ''}\n```")
614
+
615
+ return "\n".join(output)
616
+
617
+ finally:
618
+ await pg.disconnect()
619
+
620
+ @mcp.resource("rem://sessions")
621
+ async def list_recent_sessions() -> str:
622
+ """
623
+ List recent sessions with basic info.
624
+
625
+ Returns the most recent 20 sessions with:
626
+ - Session ID
627
+ - First user message (preview)
628
+ - Message count
629
+ - Timestamp
630
+ """
631
+ from ...services.postgres import get_postgres_service
632
+
633
+ pg = get_postgres_service()
634
+ await pg.connect()
635
+
636
+ try:
637
+ # Query recent sessions
638
+ query = """
639
+ SELECT
640
+ session_id,
641
+ MIN(created_at) as started_at,
642
+ COUNT(*) as message_count,
643
+ MIN(CASE WHEN message_type = 'user' THEN content END) as first_message
644
+ FROM messages
645
+ WHERE session_id IS NOT NULL
646
+ GROUP BY session_id
647
+ ORDER BY MIN(created_at) DESC
648
+ LIMIT 20
649
+ """
650
+ sessions = await pg.fetch(query)
651
+
652
+ if not sessions:
653
+ return "# Recent Sessions\n\nNo sessions found."
654
+
655
+ output = ["# Recent Sessions\n"]
656
+ output.append(f"Showing {len(sessions)} most recent sessions:\n")
657
+
658
+ for session in sessions:
659
+ session_id = session['session_id']
660
+ started = session['started_at']
661
+ count = session['message_count']
662
+ first_msg = session['first_message'] or "(no user message)"
663
+ preview = first_msg[:80] + "..." if len(first_msg) > 80 else first_msg
664
+
665
+ output.append(f"\n## {session_id}")
666
+ output.append(f"- **Started:** {started}")
667
+ output.append(f"- **Messages:** {count}")
668
+ output.append(f"- **First message:** {preview}")
669
+ output.append(f"- **Load:** `rem://sessions/{session_id}`")
670
+
671
+ return "\n".join(output)
672
+
673
+ finally:
674
+ await pg.disconnect()
675
+
676
+
677
+ def register_user_resources(mcp: FastMCP):
678
+ """
679
+ Register user profile resources for on-demand profile loading.
680
+
681
+ Args:
682
+ mcp: FastMCP server instance
683
+ """
684
+
685
+ @mcp.resource("user://profile/{user_id}")
686
+ async def get_user_profile(user_id: str) -> str:
687
+ """
688
+ Load a user's profile by ID.
689
+
690
+ Returns the user's profile information including:
691
+ - Email and name
692
+ - Summary (AI-generated profile summary)
693
+ - Interests and preferred topics
694
+ - Activity level
695
+
696
+ This resource is protected - each user can only access their own profile.
697
+ The user_id should match the authenticated user's ID from the JWT token.
698
+
699
+ Args:
700
+ user_id: User UUID from authentication
701
+
702
+ Returns:
703
+ Formatted user profile as markdown string, or error if not found
704
+ """
705
+ from ...services.postgres import get_postgres_service
706
+ from ...services.postgres.repository import Repository
707
+ from ...models.entities.user import User
708
+
709
+ pg = get_postgres_service()
710
+ await pg.connect()
711
+
712
+ try:
713
+ user_repo = Repository(User, "users", db=pg)
714
+ # Look up user by ID (user_id from JWT is the primary key)
715
+ user = await user_repo.get_by_id(user_id, tenant_id=None)
716
+
717
+ if not user:
718
+ return f"# User Profile Not Found\n\nNo user found with ID: {user_id}"
719
+
720
+ # Build profile output
721
+ output = [f"# User Profile: {user.name or user.email or 'Unknown'}"]
722
+ output.append("")
723
+
724
+ if user.email:
725
+ output.append(f"**Email:** {user.email}")
726
+
727
+ if user.role:
728
+ output.append(f"**Role:** {user.role}")
729
+
730
+ if user.tier:
731
+ output.append(f"**Tier:** {user.tier.value if hasattr(user.tier, 'value') else user.tier}")
732
+
733
+ if user.summary:
734
+ output.append(f"\n## Summary\n{user.summary}")
735
+
736
+ if user.interests:
737
+ output.append(f"\n## Interests\n- " + "\n- ".join(user.interests[:10]))
738
+
739
+ if user.preferred_topics:
740
+ output.append(f"\n## Preferred Topics\n- " + "\n- ".join(user.preferred_topics[:10]))
741
+
742
+ if user.activity_level:
743
+ output.append(f"\n**Activity Level:** {user.activity_level}")
744
+
745
+ if user.last_active_at:
746
+ output.append(f"**Last Active:** {user.last_active_at}")
747
+
748
+ # Add metadata if present (but redact sensitive fields)
749
+ if user.metadata:
750
+ safe_metadata = {k: v for k, v in user.metadata.items()
751
+ if k not in ('login_code', 'password', 'token', 'secret')}
752
+ if safe_metadata:
753
+ output.append(f"\n## Additional Info")
754
+ for key, value in list(safe_metadata.items())[:5]:
755
+ output.append(f"- **{key}:** {value}")
756
+
757
+ return "\n".join(output)
758
+
759
+ except Exception as e:
760
+ return f"# Error Loading Profile\n\nFailed to load user profile: {e}"
761
+
762
+ finally:
763
+ await pg.disconnect()
764
+
765
+
545
766
  # Resource dispatcher for read_resource tool
546
767
  async def load_resource(uri: str) -> dict | str:
547
768
  """
@@ -571,6 +792,8 @@ async def load_resource(uri: str) -> dict | str:
571
792
  register_agent_resources(mcp)
572
793
  register_file_resources(mcp)
573
794
  register_status_resources(mcp)
795
+ register_session_resources(mcp)
796
+ register_user_resources(mcp)
574
797
 
575
798
  # 1. Try exact match in regular resources
576
799
  resources = await mcp.get_resources()
@@ -1414,17 +1414,12 @@ async def ask_agent(
1414
1414
  if Agent.is_model_request_node(node):
1415
1415
  async with node.stream(agent_run.ctx) as request_stream:
1416
1416
  async for event in request_stream:
1417
- # Proxy part starts
1417
+ # Proxy part starts (text content only - tool calls handled in is_call_tools_node)
1418
1418
  if isinstance(event, PartStartEvent):
1419
1419
  from pydantic_ai.messages import ToolCallPart, TextPart
1420
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
- })
1421
+ # Track tool call for later (args are incomplete at PartStartEvent)
1422
+ # Full args come via FunctionToolCallEvent in is_call_tools_node
1428
1423
  child_tool_calls.append({
1429
1424
  "tool_name": event.part.tool_name,
1430
1425
  "index": event.index,
@@ -1454,7 +1449,28 @@ async def ask_agent(
1454
1449
  elif Agent.is_call_tools_node(node):
1455
1450
  async with node.stream(agent_run.ctx) as tools_stream:
1456
1451
  async for tool_event in tools_stream:
1457
- if isinstance(tool_event, FunctionToolResultEvent):
1452
+ # FunctionToolCallEvent fires when tool call is parsed
1453
+ # with complete arguments (before execution)
1454
+ if isinstance(tool_event, FunctionToolCallEvent):
1455
+ # Get full arguments from completed tool call
1456
+ tool_args = None
1457
+ if hasattr(tool_event, 'part') and hasattr(tool_event.part, 'args'):
1458
+ raw_args = tool_event.part.args
1459
+ if isinstance(raw_args, str):
1460
+ try:
1461
+ tool_args = json.loads(raw_args)
1462
+ except json.JSONDecodeError:
1463
+ tool_args = {"raw": raw_args}
1464
+ elif isinstance(raw_args, dict):
1465
+ tool_args = raw_args
1466
+ # Push tool start with full arguments
1467
+ await push_event.put({
1468
+ "type": "child_tool_start",
1469
+ "agent_name": agent_name,
1470
+ "tool_name": tool_event.part.tool_name if hasattr(tool_event, 'part') else "unknown",
1471
+ "arguments": tool_args,
1472
+ })
1473
+ elif isinstance(tool_event, FunctionToolResultEvent):
1458
1474
  result_content = tool_event.result.content if hasattr(tool_event.result, 'content') else tool_event.result
1459
1475
  # Push tool result to parent
1460
1476
  await push_event.put({
@@ -1490,16 +1506,66 @@ async def ask_agent(
1490
1506
  }
1491
1507
 
1492
1508
  # Serialize output
1493
- from rem.agentic.serialization import serialize_agent_result
1509
+ from rem.agentic.serialization import serialize_agent_result, is_pydantic_model
1494
1510
  output = serialize_agent_result(result.output)
1495
1511
 
1496
1512
  logger.info(f"Agent '{agent_name}' completed successfully")
1497
1513
 
1514
+ # If child agent returned structured output (Pydantic model), emit as tool_call SSE event
1515
+ # This allows the frontend to render structured results (forms, cards, etc.)
1516
+ is_structured_output = is_pydantic_model(result.output)
1517
+ structured_tool_id = f"{agent_name}_structured_output"
1518
+ logger.debug(f"ask_agent '{agent_name}': is_structured_output={is_structured_output}, output_type={type(result.output).__name__}")
1519
+
1520
+ if use_streaming and is_structured_output and push_event is not None:
1521
+ # Emit structured output as a tool_call event with the serialized result
1522
+ # Use agent_name as tool_name so it appears as the logical tool (e.g., "finalize_intake_agent")
1523
+ await push_event.put({
1524
+ "type": "tool_call",
1525
+ "tool_name": agent_name, # Use agent name as tool name for clarity
1526
+ "tool_id": structured_tool_id,
1527
+ "status": "completed",
1528
+ "arguments": {"input_text": input_text},
1529
+ "result": output, # Serialized Pydantic model as dict
1530
+ })
1531
+ logger.debug(f"ask_agent '{agent_name}': emitted structured output as tool_call SSE event")
1532
+
1533
+ # Save structured output as a tool message in the database
1534
+ # This makes structured output agents look like tool calls in session history
1535
+ if is_structured_output and child_context and child_context.session_id and settings.postgres.enabled:
1536
+ try:
1537
+ from ...services.session import SessionMessageStore
1538
+ from ...utils.date_utils import utc_now, to_iso
1539
+
1540
+ store = SessionMessageStore(user_id=child_context.user_id or "default")
1541
+
1542
+ # Build tool message in the same format as regular tool calls
1543
+ tool_message = {
1544
+ "role": "tool",
1545
+ "content": json.dumps(output, default=str), # Structured output as JSON
1546
+ "timestamp": to_iso(utc_now()),
1547
+ "tool_call_id": structured_tool_id,
1548
+ "tool_name": agent_name, # Agent name as tool name
1549
+ "tool_arguments": {"input_text": input_text},
1550
+ }
1551
+
1552
+ # Store as a single message (not using store_session_messages to avoid compression)
1553
+ await store.store_session_messages(
1554
+ session_id=child_context.session_id,
1555
+ messages=[tool_message],
1556
+ user_id=child_context.user_id,
1557
+ compress=False, # Don't compress tool results
1558
+ )
1559
+ logger.debug(f"ask_agent '{agent_name}': saved structured output as tool message in session")
1560
+ except Exception as e:
1561
+ logger.warning(f"ask_agent '{agent_name}': failed to save structured output to database: {e}")
1562
+
1498
1563
  response = {
1499
1564
  "status": "success",
1500
1565
  "output": output,
1501
1566
  "agent_schema": agent_name,
1502
1567
  "input_text": input_text,
1568
+ "is_structured_output": is_structured_output, # Flag for caller to know result type
1503
1569
  }
1504
1570
 
1505
1571
  # Only include text_response if content was NOT streamed
rem/api/routers/auth.py CHANGED
@@ -3,11 +3,12 @@ Authentication Router.
3
3
 
4
4
  Supports multiple authentication methods:
5
5
  1. Email (passwordless): POST /api/auth/email/send-code, POST /api/auth/email/verify
6
- 2. OAuth (Google, Microsoft): GET /api/auth/{provider}/login, GET /api/auth/{provider}/callback
6
+ 2. Pre-approved codes: POST /api/auth/email/verify (with pre-approved code, no send-code needed)
7
+ 3. OAuth (Google, Microsoft): GET /api/auth/{provider}/login, GET /api/auth/{provider}/callback
7
8
 
8
9
  Endpoints:
9
10
  - POST /api/auth/email/send-code - Send login code to email
10
- - POST /api/auth/email/verify - Verify code and create session
11
+ - POST /api/auth/email/verify - Verify code and create session (supports pre-approved codes)
11
12
  - GET /api/auth/{provider}/login - Initiate OAuth flow
12
13
  - GET /api/auth/{provider}/callback - OAuth callback
13
14
  - POST /api/auth/logout - Clear session
@@ -15,9 +16,39 @@ Endpoints:
15
16
 
16
17
  Supported providers:
17
18
  - email: Passwordless email login
19
+ - preapproved: Pre-approved codes (bypass email, set via AUTH__PREAPPROVED_CODES)
18
20
  - google: Google OAuth 2.0 / OIDC
19
21
  - microsoft: Microsoft Entra ID OIDC
20
22
 
23
+ =============================================================================
24
+ Pre-Approved Code Authentication
25
+ =============================================================================
26
+
27
+ Pre-approved codes allow login without email verification. Useful for:
28
+ - Demo accounts
29
+ - Testing
30
+ - Beta access codes
31
+ - Admin provisioning
32
+
33
+ Configuration:
34
+ AUTH__PREAPPROVED_CODES=A12345,A67890,B11111,B22222
35
+
36
+ Code prefixes:
37
+ A = Admin role (e.g., A12345, AADMIN1)
38
+ B = Normal user role (e.g., B11111, BUSER1)
39
+
40
+ Flow:
41
+ 1. User enters email + pre-approved code (no send-code step needed)
42
+ 2. POST /api/auth/email/verify with email and code
43
+ 3. System validates code against AUTH__PREAPPROVED_CODES
44
+ 4. Creates user if not exists, sets role based on prefix
45
+ 5. Returns JWT tokens (same as email auth)
46
+
47
+ Example:
48
+ curl -X POST http://localhost:8000/api/auth/email/verify \
49
+ -H "Content-Type: application/json" \
50
+ -d '{"email": "admin@example.com", "code": "A12345"}'
51
+
21
52
  =============================================================================
22
53
  Email Authentication Access Control
23
54
  =============================================================================
@@ -52,7 +83,7 @@ User Tiers (models.entities.UserTier):
52
83
 
53
84
  Configuration:
54
85
  # Allow only specific domains for new signups
55
- EMAIL__TRUSTED_EMAIL_DOMAINS=siggymd.ai,example.com
86
+ EMAIL__TRUSTED_EMAIL_DOMAINS=mycompany.com,example.com
56
87
 
57
88
  # Allow all domains (no restrictions)
58
89
  EMAIL__TRUSTED_EMAIL_DOMAINS=
@@ -242,6 +273,12 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
242
273
  """
243
274
  Verify login code and create session with JWT tokens.
244
275
 
276
+ Supports two authentication methods:
277
+ 1. Pre-approved codes: Codes from AUTH__PREAPPROVED_CODES bypass email verification.
278
+ - A prefix = admin role, B prefix = normal user role
279
+ - Creates user if not exists, logs in directly
280
+ 2. Email verification: Standard 6-digit code sent via email
281
+
245
282
  Args:
246
283
  request: FastAPI request
247
284
  body: EmailVerifyRequest with email and code
@@ -249,12 +286,6 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
249
286
  Returns:
250
287
  Success status with user info and JWT tokens
251
288
  """
252
- if not settings.email.is_configured:
253
- raise HTTPException(
254
- status_code=501,
255
- detail="Email authentication is not configured"
256
- )
257
-
258
289
  if not settings.postgres.enabled:
259
290
  raise HTTPException(
260
291
  status_code=501,
@@ -264,6 +295,79 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
264
295
  db = PostgresService()
265
296
  try:
266
297
  await db.connect()
298
+ user_service = UserService(db)
299
+
300
+ # Check for pre-approved code first
301
+ preapproved = settings.auth.check_preapproved_code(body.code)
302
+ if preapproved:
303
+ logger.info(f"Pre-approved code login attempt for {body.email} (role: {preapproved['role']})")
304
+
305
+ # Get or create user with pre-approved role
306
+ user_id = email_to_user_id(body.email)
307
+ user_entity = await user_service.get_user_by_id(user_id)
308
+
309
+ if not user_entity:
310
+ # Create new user with role from pre-approved code
311
+ user_entity = await user_service.get_or_create_user(
312
+ email=body.email,
313
+ name=body.email.split("@")[0],
314
+ tenant_id="default",
315
+ )
316
+ # Update role based on pre-approved code prefix
317
+ user_entity.role = preapproved["role"]
318
+ from ...services.postgres.repository import Repository
319
+ from ...models.entities.user import User
320
+ user_repo = Repository(User, "users", db=db)
321
+ await user_repo.upsert(user_entity)
322
+ logger.info(f"Created user {body.email} with role={preapproved['role']} via pre-approved code")
323
+ else:
324
+ # Update existing user's role if admin code used
325
+ if preapproved["role"] == "admin" and user_entity.role != "admin":
326
+ user_entity.role = "admin"
327
+ from ...services.postgres.repository import Repository
328
+ from ...models.entities.user import User
329
+ user_repo = Repository(User, "users", db=db)
330
+ await user_repo.upsert(user_entity)
331
+ logger.info(f"Upgraded user {body.email} to admin via pre-approved code")
332
+
333
+ # Build user dict for session/JWT
334
+ user_dict = {
335
+ "id": str(user_entity.id),
336
+ "email": body.email,
337
+ "email_verified": True,
338
+ "name": user_entity.name or body.email.split("@")[0],
339
+ "provider": "preapproved",
340
+ "tenant_id": user_entity.tenant_id or "default",
341
+ "tier": user_entity.tier.value if user_entity.tier else "free",
342
+ "role": user_entity.role or preapproved["role"],
343
+ "roles": [user_entity.role or preapproved["role"]],
344
+ }
345
+
346
+ # Generate JWT tokens
347
+ jwt_service = get_jwt_service()
348
+ tokens = jwt_service.create_tokens(user_dict)
349
+
350
+ # Store user in session
351
+ request.session["user"] = user_dict
352
+
353
+ logger.info(f"User authenticated via pre-approved code: {body.email} (role: {user_dict['role']})")
354
+
355
+ return {
356
+ "success": True,
357
+ "message": "Successfully authenticated with pre-approved code!",
358
+ "user": user_dict,
359
+ "access_token": tokens["access_token"],
360
+ "refresh_token": tokens["refresh_token"],
361
+ "token_type": tokens["token_type"],
362
+ "expires_in": tokens["expires_in"],
363
+ }
364
+
365
+ # Standard email verification flow
366
+ if not settings.email.is_configured:
367
+ raise HTTPException(
368
+ status_code=501,
369
+ detail="Email authentication is not configured"
370
+ )
267
371
 
268
372
  # Initialize email auth provider
269
373
  email_auth = EmailAuthProvider()
@@ -288,7 +392,6 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
288
392
  )
289
393
 
290
394
  # Fetch actual user data from database to get role/tier
291
- user_service = UserService(db)
292
395
  try:
293
396
  user_entity = await user_service.get_user_by_id(result.user_id)
294
397
  if user_entity:
@@ -5,7 +5,7 @@ Handles events from child agents during multi-agent orchestration.
5
5
 
6
6
  Event Flow:
7
7
  ```
8
- Parent Agent (Siggy)
8
+ Parent Agent (Orchestrator)
9
9
 
10
10
 
11
11
  ask_agent tool
@@ -52,7 +52,7 @@ async def handle_child_tool_start(
52
52
  state: StreamingState,
53
53
  child_agent: str,
54
54
  tool_name: str,
55
- arguments: dict | None,
55
+ arguments: dict | str | None,
56
56
  session_id: str | None,
57
57
  user_id: str | None,
58
58
  ) -> AsyncGenerator[str, None]:
@@ -62,13 +62,18 @@ async def handle_child_tool_start(
62
62
  Actions:
63
63
  1. Log the tool call
64
64
  2. Emit SSE event
65
- 3. Save to database
65
+ 3. Save to database (with tool_arguments in metadata for consistency with parent)
66
66
  """
67
67
  full_tool_name = f"{child_agent}:{tool_name}"
68
68
  tool_id = f"call_{uuid.uuid4().hex[:8]}"
69
69
 
70
- # Normalize arguments
71
- if not isinstance(arguments, dict):
70
+ # Normalize arguments - may come as JSON string from ToolCallPart.args
71
+ if isinstance(arguments, str):
72
+ try:
73
+ arguments = json.loads(arguments)
74
+ except json.JSONDecodeError:
75
+ arguments = None
76
+ elif not isinstance(arguments, dict):
72
77
  arguments = None
73
78
 
74
79
  # 1. LOG
@@ -82,7 +87,7 @@ async def handle_child_tool_start(
82
87
  arguments=arguments,
83
88
  ))
84
89
 
85
- # 3. SAVE TO DB
90
+ # 3. SAVE TO DB - content contains args as JSON (pydantic_messages.py parses it)
86
91
  if session_id and settings.postgres.enabled:
87
92
  try:
88
93
  store = SessionMessageStore(
@@ -90,9 +95,12 @@ async def handle_child_tool_start(
90
95
  )
91
96
  tool_msg = {
92
97
  "role": "tool",
93
- "tool_name": full_tool_name,
98
+ # Content is the tool call args as JSON - this is what the agent sees on reload
99
+ # and what pydantic_messages.py parses for ToolCallPart.args
94
100
  "content": json.dumps(arguments) if arguments else "",
95
101
  "timestamp": to_iso(utc_now()),
102
+ "tool_call_id": tool_id,
103
+ "tool_name": full_tool_name,
96
104
  }
97
105
  await store.store_session_messages(
98
106
  session_id=session_id,
@@ -170,11 +178,17 @@ async def handle_child_tool_result(
170
178
  ))
171
179
 
172
180
  # Emit tool completion
181
+ # Preserve full result for dict/list types (needed for frontend)
182
+ if isinstance(result, (dict, list)):
183
+ result_for_sse = result
184
+ else:
185
+ result_for_sse = str(result) if result else None
186
+
173
187
  yield format_sse_event(ToolCallEvent(
174
188
  tool_name=f"{child_agent}:tool",
175
189
  tool_id=f"call_{uuid.uuid4().hex[:8]}",
176
190
  status="completed",
177
- result=str(result)[:200] if result else None,
191
+ result=result_for_sse,
178
192
  ))
179
193
 
180
194
 
@@ -16,11 +16,11 @@ IMPORTANT: Session IDs MUST be UUIDs. Non-UUID session IDs will cause message
16
16
  kubectl port-forward -n observability svc/otel-collector-collector 4318:4318
17
17
 
18
18
  # Terminal 2: Phoenix UI - view traces at http://localhost:6006
19
- kubectl port-forward -n siggy svc/phoenix 6006:6006
19
+ kubectl port-forward -n rem svc/phoenix 6006:6006
20
20
 
21
21
  2. Get Phoenix API Key (REQUIRED for feedback->Phoenix sync):
22
22
 
23
- export PHOENIX_API_KEY=$(kubectl get secret -n siggy rem-phoenix-api-key \\
23
+ export PHOENIX_API_KEY=$(kubectl get secret -n rem rem-phoenix-api-key \\
24
24
  -o jsonpath='{.data.PHOENIX_API_KEY}' | base64 -d)
25
25
 
26
26
  3. Start API with OTEL and Phoenix enabled:
@@ -70,7 +70,7 @@ OTEL Architecture
70
70
  =================
71
71
 
72
72
  REM API --[OTLP/HTTP]--> OTEL Collector --[relay]--> Phoenix
73
- (port 4318) (k8s: observability) (k8s: siggy)
73
+ (port 4318) (k8s: observability) (k8s: rem)
74
74
 
75
75
  Environment Variables:
76
76
  OTEL__ENABLED=true Enable OTEL tracing (required for trace capture)
@@ -321,7 +321,7 @@ class MetadataEvent(BaseModel):
321
321
  # Agent info
322
322
  agent_schema: str | None = Field(
323
323
  default=None,
324
- description="Name of the top-level agent schema (e.g., 'siggy', 'rem')"
324
+ description="Name of the top-level agent schema (e.g., 'rem', 'intake')"
325
325
  )
326
326
  responding_agent: str | None = Field(
327
327
  default=None,
@@ -413,9 +413,9 @@ class ToolCallEvent(BaseModel):
413
413
  default=None,
414
414
  description="Tool arguments (for 'started' status)"
415
415
  )
416
- result: str | dict[str, Any] | None = Field(
416
+ result: str | dict[str, Any] | list[Any] | None = Field(
417
417
  default=None,
418
- description="Tool result - full dict for finalize_intake, summary string for others"
418
+ description="Tool result - full dict/list for structured data, string for simple results"
419
419
  )
420
420
  error: str | None = Field(
421
421
  default=None,