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.
- rem/agentic/__init__.py +10 -1
- rem/agentic/context.py +13 -2
- rem/agentic/context_builder.py +45 -34
- rem/agentic/providers/pydantic_ai.py +302 -110
- rem/api/mcp_router/resources.py +223 -0
- rem/api/mcp_router/tools.py +76 -10
- rem/api/routers/auth.py +113 -10
- rem/api/routers/chat/child_streaming.py +22 -8
- rem/api/routers/chat/completions.py +3 -3
- rem/api/routers/chat/sse_events.py +3 -3
- rem/api/routers/chat/streaming.py +40 -45
- rem/api/routers/chat/streaming_utils.py +5 -7
- rem/api/routers/feedback.py +2 -2
- rem/api/routers/query.py +5 -14
- rem/cli/commands/ask.py +144 -33
- rem/cli/commands/experiments.py +1 -1
- rem/cli/commands/process.py +9 -1
- rem/cli/commands/query.py +109 -0
- rem/cli/commands/session.py +117 -0
- rem/cli/main.py +2 -0
- rem/models/core/experiment.py +1 -1
- rem/models/entities/session.py +1 -0
- rem/schemas/agents/core/agent-builder.yaml +1 -1
- rem/schemas/agents/test_orchestrator.yaml +42 -0
- rem/schemas/agents/test_structured_output.yaml +52 -0
- rem/services/content/providers.py +151 -49
- rem/services/postgres/repository.py +1 -0
- rem/services/rem/README.md +4 -3
- rem/services/rem/parser.py +7 -10
- rem/services/rem/service.py +47 -0
- rem/services/session/compression.py +7 -3
- rem/services/session/pydantic_messages.py +25 -7
- rem/services/session/reload.py +2 -1
- rem/settings.py +64 -7
- rem/sql/migrations/004_cache_system.sql +3 -1
- rem/utils/schema_loader.py +135 -103
- {remdb-0.3.230.dist-info → remdb-0.3.258.dist-info}/METADATA +6 -5
- {remdb-0.3.230.dist-info → remdb-0.3.258.dist-info}/RECORD +40 -37
- {remdb-0.3.230.dist-info → remdb-0.3.258.dist-info}/WHEEL +0 -0
- {remdb-0.3.230.dist-info → remdb-0.3.258.dist-info}/entry_points.txt +0 -0
rem/api/mcp_router/resources.py
CHANGED
|
@@ -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()
|
rem/api/mcp_router/tools.py
CHANGED
|
@@ -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
|
-
#
|
|
1422
|
-
|
|
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
|
-
|
|
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.
|
|
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=
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
|
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:
|
|
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., '
|
|
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
|
|
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,
|