remdb 0.3.200__py3-none-any.whl → 0.3.226__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.
- rem/agentic/README.md +262 -2
- rem/agentic/context.py +73 -1
- rem/agentic/mcp/tool_wrapper.py +2 -2
- rem/agentic/providers/pydantic_ai.py +1 -1
- rem/agentic/schema.py +2 -2
- rem/api/mcp_router/tools.py +154 -18
- rem/api/routers/admin.py +30 -4
- rem/api/routers/auth.py +106 -10
- rem/api/routers/chat/completions.py +24 -29
- rem/api/routers/chat/sse_events.py +5 -1
- rem/api/routers/chat/streaming.py +163 -2
- rem/api/routers/common.py +18 -0
- rem/api/routers/dev.py +7 -1
- rem/api/routers/feedback.py +9 -1
- rem/api/routers/messages.py +80 -15
- rem/api/routers/models.py +9 -1
- rem/api/routers/query.py +12 -1
- rem/api/routers/shared_sessions.py +16 -0
- rem/auth/jwt.py +19 -4
- rem/cli/commands/ask.py +61 -81
- rem/cli/commands/process.py +3 -3
- rem/models/entities/ontology.py +18 -20
- rem/schemas/agents/rem.yaml +1 -1
- rem/services/postgres/repository.py +14 -4
- rem/services/session/__init__.py +2 -1
- rem/services/session/compression.py +40 -2
- rem/services/session/pydantic_messages.py +66 -0
- rem/settings.py +28 -0
- rem/sql/migrations/001_install.sql +13 -3
- rem/sql/migrations/002_install_models.sql +20 -22
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/schema_loader.py +73 -45
- {remdb-0.3.200.dist-info → remdb-0.3.226.dist-info}/METADATA +1 -1
- {remdb-0.3.200.dist-info → remdb-0.3.226.dist-info}/RECORD +36 -34
- {remdb-0.3.200.dist-info → remdb-0.3.226.dist-info}/WHEEL +0 -0
- {remdb-0.3.200.dist-info → remdb-0.3.226.dist-info}/entry_points.txt +0 -0
rem/api/routers/admin.py
CHANGED
|
@@ -31,6 +31,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Background
|
|
|
31
31
|
from loguru import logger
|
|
32
32
|
from pydantic import BaseModel
|
|
33
33
|
|
|
34
|
+
from .common import ErrorResponse
|
|
35
|
+
|
|
34
36
|
from ..deps import require_admin
|
|
35
37
|
from ...models.entities import Message, Session, SessionMode
|
|
36
38
|
from ...services.postgres import Repository
|
|
@@ -103,7 +105,13 @@ class SystemStats(BaseModel):
|
|
|
103
105
|
# =============================================================================
|
|
104
106
|
|
|
105
107
|
|
|
106
|
-
@router.get(
|
|
108
|
+
@router.get(
|
|
109
|
+
"/users",
|
|
110
|
+
response_model=UserListResponse,
|
|
111
|
+
responses={
|
|
112
|
+
503: {"model": ErrorResponse, "description": "Database not enabled"},
|
|
113
|
+
},
|
|
114
|
+
)
|
|
107
115
|
async def list_all_users(
|
|
108
116
|
user: dict = Depends(require_admin),
|
|
109
117
|
limit: int = Query(default=50, ge=1, le=100),
|
|
@@ -155,7 +163,13 @@ async def list_all_users(
|
|
|
155
163
|
return UserListResponse(data=summaries, total=total, has_more=has_more)
|
|
156
164
|
|
|
157
165
|
|
|
158
|
-
@router.get(
|
|
166
|
+
@router.get(
|
|
167
|
+
"/sessions",
|
|
168
|
+
response_model=SessionListResponse,
|
|
169
|
+
responses={
|
|
170
|
+
503: {"model": ErrorResponse, "description": "Database not enabled"},
|
|
171
|
+
},
|
|
172
|
+
)
|
|
159
173
|
async def list_all_sessions(
|
|
160
174
|
user: dict = Depends(require_admin),
|
|
161
175
|
user_id: str | None = Query(default=None, description="Filter by user ID"),
|
|
@@ -202,7 +216,13 @@ async def list_all_sessions(
|
|
|
202
216
|
return SessionListResponse(data=sessions, total=total, has_more=has_more)
|
|
203
217
|
|
|
204
218
|
|
|
205
|
-
@router.get(
|
|
219
|
+
@router.get(
|
|
220
|
+
"/messages",
|
|
221
|
+
response_model=MessageListResponse,
|
|
222
|
+
responses={
|
|
223
|
+
503: {"model": ErrorResponse, "description": "Database not enabled"},
|
|
224
|
+
},
|
|
225
|
+
)
|
|
206
226
|
async def list_all_messages(
|
|
207
227
|
user: dict = Depends(require_admin),
|
|
208
228
|
user_id: str | None = Query(default=None, description="Filter by user ID"),
|
|
@@ -252,7 +272,13 @@ async def list_all_messages(
|
|
|
252
272
|
return MessageListResponse(data=messages, total=total, has_more=has_more)
|
|
253
273
|
|
|
254
274
|
|
|
255
|
-
@router.get(
|
|
275
|
+
@router.get(
|
|
276
|
+
"/stats",
|
|
277
|
+
response_model=SystemStats,
|
|
278
|
+
responses={
|
|
279
|
+
503: {"model": ErrorResponse, "description": "Database not enabled"},
|
|
280
|
+
},
|
|
281
|
+
)
|
|
256
282
|
async def get_system_stats(
|
|
257
283
|
user: dict = Depends(require_admin),
|
|
258
284
|
) -> SystemStats:
|
rem/api/routers/auth.py
CHANGED
|
@@ -101,6 +101,8 @@ from authlib.integrations.starlette_client import OAuth
|
|
|
101
101
|
from pydantic import BaseModel, EmailStr
|
|
102
102
|
from loguru import logger
|
|
103
103
|
|
|
104
|
+
from .common import ErrorResponse
|
|
105
|
+
|
|
104
106
|
from ...settings import settings
|
|
105
107
|
from ...services.postgres.service import PostgresService
|
|
106
108
|
from ...services.user_service import UserService
|
|
@@ -159,7 +161,14 @@ class EmailVerifyRequest(BaseModel):
|
|
|
159
161
|
code: str
|
|
160
162
|
|
|
161
163
|
|
|
162
|
-
@router.post(
|
|
164
|
+
@router.post(
|
|
165
|
+
"/email/send-code",
|
|
166
|
+
responses={
|
|
167
|
+
400: {"model": ErrorResponse, "description": "Invalid request or email rejected"},
|
|
168
|
+
500: {"model": ErrorResponse, "description": "Failed to send login code"},
|
|
169
|
+
501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
|
|
170
|
+
},
|
|
171
|
+
)
|
|
163
172
|
async def send_email_code(request: Request, body: EmailSendCodeRequest):
|
|
164
173
|
"""
|
|
165
174
|
Send a login code to an email address.
|
|
@@ -221,7 +230,14 @@ async def send_email_code(request: Request, body: EmailSendCodeRequest):
|
|
|
221
230
|
await db.disconnect()
|
|
222
231
|
|
|
223
232
|
|
|
224
|
-
@router.post(
|
|
233
|
+
@router.post(
|
|
234
|
+
"/email/verify",
|
|
235
|
+
responses={
|
|
236
|
+
400: {"model": ErrorResponse, "description": "Invalid or expired code"},
|
|
237
|
+
500: {"model": ErrorResponse, "description": "Failed to verify login code"},
|
|
238
|
+
501: {"model": ErrorResponse, "description": "Email auth or database not configured"},
|
|
239
|
+
},
|
|
240
|
+
)
|
|
225
241
|
async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
226
242
|
"""
|
|
227
243
|
Verify login code and create session with JWT tokens.
|
|
@@ -319,7 +335,13 @@ async def verify_email_code(request: Request, body: EmailVerifyRequest):
|
|
|
319
335
|
# =============================================================================
|
|
320
336
|
|
|
321
337
|
|
|
322
|
-
@router.get(
|
|
338
|
+
@router.get(
|
|
339
|
+
"/{provider}/login",
|
|
340
|
+
responses={
|
|
341
|
+
400: {"model": ErrorResponse, "description": "Unknown OAuth provider"},
|
|
342
|
+
501: {"model": ErrorResponse, "description": "Authentication is disabled"},
|
|
343
|
+
},
|
|
344
|
+
)
|
|
323
345
|
async def login(provider: str, request: Request):
|
|
324
346
|
"""
|
|
325
347
|
Initiate OAuth flow with provider.
|
|
@@ -361,7 +383,13 @@ async def login(provider: str, request: Request):
|
|
|
361
383
|
return await client.authorize_redirect(request, redirect_uri)
|
|
362
384
|
|
|
363
385
|
|
|
364
|
-
@router.get(
|
|
386
|
+
@router.get(
|
|
387
|
+
"/{provider}/callback",
|
|
388
|
+
responses={
|
|
389
|
+
400: {"model": ErrorResponse, "description": "Authentication failed or unknown provider"},
|
|
390
|
+
501: {"model": ErrorResponse, "description": "Authentication is disabled"},
|
|
391
|
+
},
|
|
392
|
+
)
|
|
365
393
|
async def callback(provider: str, request: Request):
|
|
366
394
|
"""
|
|
367
395
|
OAuth callback endpoint.
|
|
@@ -498,7 +526,12 @@ async def logout(request: Request):
|
|
|
498
526
|
return {"message": "Logged out successfully"}
|
|
499
527
|
|
|
500
528
|
|
|
501
|
-
@router.get(
|
|
529
|
+
@router.get(
|
|
530
|
+
"/me",
|
|
531
|
+
responses={
|
|
532
|
+
401: {"model": ErrorResponse, "description": "Not authenticated"},
|
|
533
|
+
},
|
|
534
|
+
)
|
|
502
535
|
async def me(request: Request):
|
|
503
536
|
"""
|
|
504
537
|
Get current user information from session or JWT.
|
|
@@ -536,11 +569,19 @@ class TokenRefreshRequest(BaseModel):
|
|
|
536
569
|
refresh_token: str
|
|
537
570
|
|
|
538
571
|
|
|
539
|
-
@router.post(
|
|
572
|
+
@router.post(
|
|
573
|
+
"/token/refresh",
|
|
574
|
+
responses={
|
|
575
|
+
401: {"model": ErrorResponse, "description": "Invalid or expired refresh token"},
|
|
576
|
+
},
|
|
577
|
+
)
|
|
540
578
|
async def refresh_token(body: TokenRefreshRequest):
|
|
541
579
|
"""
|
|
542
580
|
Refresh access token using refresh token.
|
|
543
581
|
|
|
582
|
+
Fetches the user's current role/tier from the database to ensure
|
|
583
|
+
the new access token reflects their actual permissions.
|
|
584
|
+
|
|
544
585
|
Args:
|
|
545
586
|
body: TokenRefreshRequest with refresh_token
|
|
546
587
|
|
|
@@ -548,7 +589,46 @@ async def refresh_token(body: TokenRefreshRequest):
|
|
|
548
589
|
New access token or 401 if refresh token is invalid
|
|
549
590
|
"""
|
|
550
591
|
jwt_service = get_jwt_service()
|
|
551
|
-
|
|
592
|
+
|
|
593
|
+
# First decode the refresh token to get user_id (without full verification yet)
|
|
594
|
+
payload = jwt_service.decode_without_verification(body.refresh_token)
|
|
595
|
+
if not payload:
|
|
596
|
+
raise HTTPException(
|
|
597
|
+
status_code=401,
|
|
598
|
+
detail="Invalid refresh token format"
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
user_id = payload.get("sub")
|
|
602
|
+
if not user_id:
|
|
603
|
+
raise HTTPException(
|
|
604
|
+
status_code=401,
|
|
605
|
+
detail="Invalid refresh token: missing user ID"
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Fetch user from database to get current role/tier
|
|
609
|
+
user_override = None
|
|
610
|
+
if settings.postgres.enabled:
|
|
611
|
+
db = PostgresService()
|
|
612
|
+
try:
|
|
613
|
+
await db.connect()
|
|
614
|
+
user_service = UserService(db)
|
|
615
|
+
user_entity = await user_service.get_user_by_id(user_id)
|
|
616
|
+
if user_entity:
|
|
617
|
+
user_override = {
|
|
618
|
+
"role": user_entity.role or "user",
|
|
619
|
+
"roles": [user_entity.role] if user_entity.role else ["user"],
|
|
620
|
+
"tier": user_entity.tier.value if user_entity.tier else "free",
|
|
621
|
+
"name": user_entity.name,
|
|
622
|
+
}
|
|
623
|
+
logger.debug(f"Refresh token: fetched user {user_id} with role={user_override['role']}, tier={user_override['tier']}")
|
|
624
|
+
except Exception as e:
|
|
625
|
+
logger.warning(f"Could not fetch user for token refresh: {e}")
|
|
626
|
+
# Continue without override - will use defaults
|
|
627
|
+
finally:
|
|
628
|
+
await db.disconnect()
|
|
629
|
+
|
|
630
|
+
# Now do the actual refresh with proper verification
|
|
631
|
+
result = jwt_service.refresh_access_token(body.refresh_token, user_override=user_override)
|
|
552
632
|
|
|
553
633
|
if not result:
|
|
554
634
|
raise HTTPException(
|
|
@@ -559,7 +639,12 @@ async def refresh_token(body: TokenRefreshRequest):
|
|
|
559
639
|
return result
|
|
560
640
|
|
|
561
641
|
|
|
562
|
-
@router.post(
|
|
642
|
+
@router.post(
|
|
643
|
+
"/token/verify",
|
|
644
|
+
responses={
|
|
645
|
+
401: {"model": ErrorResponse, "description": "Missing, invalid, or expired token"},
|
|
646
|
+
},
|
|
647
|
+
)
|
|
563
648
|
async def verify_token(request: Request):
|
|
564
649
|
"""
|
|
565
650
|
Verify an access token is valid.
|
|
@@ -623,7 +708,12 @@ def verify_dev_token(token: str) -> bool:
|
|
|
623
708
|
return token == expected
|
|
624
709
|
|
|
625
710
|
|
|
626
|
-
@router.get(
|
|
711
|
+
@router.get(
|
|
712
|
+
"/dev/token",
|
|
713
|
+
responses={
|
|
714
|
+
401: {"model": ErrorResponse, "description": "Dev tokens not available in production"},
|
|
715
|
+
},
|
|
716
|
+
)
|
|
627
717
|
async def get_dev_token(request: Request):
|
|
628
718
|
"""
|
|
629
719
|
Get a development token for testing (non-production only).
|
|
@@ -659,7 +749,13 @@ async def get_dev_token(request: Request):
|
|
|
659
749
|
}
|
|
660
750
|
|
|
661
751
|
|
|
662
|
-
@router.get(
|
|
752
|
+
@router.get(
|
|
753
|
+
"/dev/mock-code/{email}",
|
|
754
|
+
responses={
|
|
755
|
+
401: {"model": ErrorResponse, "description": "Mock codes not available in production"},
|
|
756
|
+
404: {"model": ErrorResponse, "description": "No code found for email"},
|
|
757
|
+
},
|
|
758
|
+
)
|
|
663
759
|
async def get_mock_code(email: str, request: Request):
|
|
664
760
|
"""
|
|
665
761
|
Get the mock login code for testing (non-production only).
|
|
@@ -164,7 +164,7 @@ from .models import (
|
|
|
164
164
|
ChatCompletionUsage,
|
|
165
165
|
ChatMessage,
|
|
166
166
|
)
|
|
167
|
-
from .streaming import stream_openai_response, stream_openai_response_with_save, stream_simulator_response
|
|
167
|
+
from .streaming import stream_openai_response, stream_openai_response_with_save, stream_simulator_response, save_user_message
|
|
168
168
|
|
|
169
169
|
router = APIRouter(prefix="/api/v1", tags=["chat"])
|
|
170
170
|
|
|
@@ -215,7 +215,7 @@ async def ensure_session_with_metadata(
|
|
|
215
215
|
Merges request metadata with existing session metadata.
|
|
216
216
|
|
|
217
217
|
Args:
|
|
218
|
-
session_id: Session
|
|
218
|
+
session_id: Session UUID from X-Session-Id header
|
|
219
219
|
user_id: User identifier
|
|
220
220
|
tenant_id: Tenant identifier
|
|
221
221
|
is_eval: Whether this is an evaluation session
|
|
@@ -228,12 +228,8 @@ async def ensure_session_with_metadata(
|
|
|
228
228
|
try:
|
|
229
229
|
repo = Repository(Session, table_name="sessions")
|
|
230
230
|
|
|
231
|
-
#
|
|
232
|
-
|
|
233
|
-
filters={"name": session_id, "tenant_id": tenant_id},
|
|
234
|
-
limit=1,
|
|
235
|
-
)
|
|
236
|
-
existing = existing_list[0] if existing_list else None
|
|
231
|
+
# Look up session by UUID (id field)
|
|
232
|
+
existing = await repo.get_by_id(session_id)
|
|
237
233
|
|
|
238
234
|
if existing:
|
|
239
235
|
# Merge metadata if provided
|
|
@@ -254,9 +250,10 @@ async def ensure_session_with_metadata(
|
|
|
254
250
|
await repo.upsert(existing)
|
|
255
251
|
logger.debug(f"Updated session {session_id} (eval={is_eval}, metadata keys={list(merged_metadata.keys())})")
|
|
256
252
|
else:
|
|
257
|
-
# Create new session
|
|
253
|
+
# Create new session with the provided UUID as the id
|
|
258
254
|
session = Session(
|
|
259
|
-
|
|
255
|
+
id=session_id, # Use the provided UUID as session id
|
|
256
|
+
name=session_id, # Default name to UUID, can be updated later with LLM-generated name
|
|
260
257
|
mode=SessionMode.EVALUATION if is_eval else SessionMode.NORMAL,
|
|
261
258
|
user_id=user_id,
|
|
262
259
|
tenant_id=tenant_id,
|
|
@@ -513,7 +510,7 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
513
510
|
|
|
514
511
|
# Load raw session history for proper pydantic-ai message_history format
|
|
515
512
|
# This enables proper tool call/return pairing for LLM API compatibility
|
|
516
|
-
from ....services.session import SessionMessageStore, session_to_pydantic_messages
|
|
513
|
+
from ....services.session import SessionMessageStore, session_to_pydantic_messages, audit_session_history
|
|
517
514
|
from ....agentic.schema import get_system_prompt
|
|
518
515
|
|
|
519
516
|
pydantic_message_history = None
|
|
@@ -535,6 +532,15 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
535
532
|
system_prompt=agent_system_prompt,
|
|
536
533
|
)
|
|
537
534
|
logger.debug(f"Converted {len(raw_session_history)} session messages to {len(pydantic_message_history)} pydantic-ai messages (with system prompt)")
|
|
535
|
+
|
|
536
|
+
# Audit session history if enabled (for debugging)
|
|
537
|
+
audit_session_history(
|
|
538
|
+
session_id=context.session_id,
|
|
539
|
+
agent_name=schema_name or "default",
|
|
540
|
+
prompt=body.messages[-1].content if body.messages else "",
|
|
541
|
+
raw_session_history=raw_session_history,
|
|
542
|
+
pydantic_messages_count=len(pydantic_message_history),
|
|
543
|
+
)
|
|
538
544
|
except Exception as e:
|
|
539
545
|
logger.warning(f"Failed to load session history for message_history: {e}")
|
|
540
546
|
# Fall back to old behavior (concatenated prompt)
|
|
@@ -576,24 +582,13 @@ async def chat_completions(body: ChatCompletionRequest, request: Request):
|
|
|
576
582
|
|
|
577
583
|
# Streaming mode
|
|
578
584
|
if body.stream:
|
|
579
|
-
# Save user message before streaming starts
|
|
580
|
-
if
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
"
|
|
585
|
-
|
|
586
|
-
try:
|
|
587
|
-
store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
|
|
588
|
-
await store.store_session_messages(
|
|
589
|
-
session_id=context.session_id,
|
|
590
|
-
messages=[user_message],
|
|
591
|
-
user_id=context.user_id,
|
|
592
|
-
compress=False, # User messages are typically short
|
|
593
|
-
)
|
|
594
|
-
logger.debug(f"Saved user message to session {context.session_id}")
|
|
595
|
-
except Exception as e:
|
|
596
|
-
logger.error(f"Failed to save user message: {e}", exc_info=True)
|
|
585
|
+
# Save user message before streaming starts (using shared utility)
|
|
586
|
+
if context.session_id:
|
|
587
|
+
await save_user_message(
|
|
588
|
+
session_id=context.session_id,
|
|
589
|
+
user_id=context.user_id,
|
|
590
|
+
content=body.messages[-1].content if body.messages else "",
|
|
591
|
+
)
|
|
597
592
|
|
|
598
593
|
return StreamingResponse(
|
|
599
594
|
stream_openai_response_with_save(
|
|
@@ -321,7 +321,11 @@ class MetadataEvent(BaseModel):
|
|
|
321
321
|
# Agent info
|
|
322
322
|
agent_schema: str | None = Field(
|
|
323
323
|
default=None,
|
|
324
|
-
description="Name of the agent schema
|
|
324
|
+
description="Name of the top-level agent schema (e.g., 'siggy', 'rem')"
|
|
325
|
+
)
|
|
326
|
+
responding_agent: str | None = Field(
|
|
327
|
+
default=None,
|
|
328
|
+
description="Name of the agent that produced this response (may differ from agent_schema if delegated via ask_agent)"
|
|
325
329
|
)
|
|
326
330
|
|
|
327
331
|
# Session info
|
|
@@ -165,12 +165,14 @@ async def stream_openai_response(
|
|
|
165
165
|
pending_tool_completions: list[tuple[str, str]] = []
|
|
166
166
|
# Track if metadata was registered via register_metadata tool
|
|
167
167
|
metadata_registered = False
|
|
168
|
+
# Track which agent is actually responding (may be child agent if delegated)
|
|
169
|
+
responding_agent: str | None = None
|
|
168
170
|
# Track pending tool calls with full data for persistence
|
|
169
171
|
# Maps tool_id -> {"tool_name": str, "tool_id": str, "arguments": dict}
|
|
170
172
|
pending_tool_data: dict[str, dict] = {}
|
|
171
173
|
|
|
172
174
|
# Import context functions for multi-agent support
|
|
173
|
-
from ....agentic.context import set_current_context
|
|
175
|
+
from ....agentic.context import set_current_context, set_event_sink
|
|
174
176
|
|
|
175
177
|
# Set up context for multi-agent propagation
|
|
176
178
|
# This allows child agents (via ask_agent tool) to access parent context
|
|
@@ -180,6 +182,12 @@ async def stream_openai_response(
|
|
|
180
182
|
previous_context = get_current_context()
|
|
181
183
|
set_current_context(agent_context)
|
|
182
184
|
|
|
185
|
+
# Set up event sink for child agent event proxying
|
|
186
|
+
# Child agents (via ask_agent) will push their events here
|
|
187
|
+
import asyncio
|
|
188
|
+
child_event_sink: asyncio.Queue = asyncio.Queue()
|
|
189
|
+
set_event_sink(child_event_sink)
|
|
190
|
+
|
|
183
191
|
try:
|
|
184
192
|
# Emit initial progress event
|
|
185
193
|
current_step = 1
|
|
@@ -419,6 +427,73 @@ async def stream_openai_response(
|
|
|
419
427
|
elif Agent.is_call_tools_node(node):
|
|
420
428
|
async with node.stream(agent_run.ctx) as tools_stream:
|
|
421
429
|
async for tool_event in tools_stream:
|
|
430
|
+
# First, drain any child agent events that were pushed while tool was executing
|
|
431
|
+
# This handles ask_agent streaming - child events are proxied here
|
|
432
|
+
while not child_event_sink.empty():
|
|
433
|
+
try:
|
|
434
|
+
child_event = child_event_sink.get_nowait()
|
|
435
|
+
event_type = child_event.get("type", "")
|
|
436
|
+
child_agent = child_event.get("agent_name", "child")
|
|
437
|
+
|
|
438
|
+
if event_type == "child_tool_start":
|
|
439
|
+
# Emit child tool start as a nested tool call
|
|
440
|
+
child_tool_id = f"call_{uuid.uuid4().hex[:8]}"
|
|
441
|
+
# Ensure arguments is a dict or None (not empty string)
|
|
442
|
+
child_args = child_event.get("arguments")
|
|
443
|
+
if not isinstance(child_args, dict):
|
|
444
|
+
child_args = None
|
|
445
|
+
yield format_sse_event(ToolCallEvent(
|
|
446
|
+
tool_name=f"{child_agent}:{child_event.get('tool_name', 'tool')}",
|
|
447
|
+
tool_id=child_tool_id,
|
|
448
|
+
status="started",
|
|
449
|
+
arguments=child_args,
|
|
450
|
+
))
|
|
451
|
+
elif event_type == "child_content":
|
|
452
|
+
# Emit child content as assistant content
|
|
453
|
+
# Track which child agent is responding
|
|
454
|
+
responding_agent = child_agent
|
|
455
|
+
content = child_event.get("content", "")
|
|
456
|
+
if content:
|
|
457
|
+
content_chunk = ChatCompletionStreamResponse(
|
|
458
|
+
id=request_id,
|
|
459
|
+
created=created_at,
|
|
460
|
+
model=model,
|
|
461
|
+
choices=[
|
|
462
|
+
ChatCompletionStreamChoice(
|
|
463
|
+
index=0,
|
|
464
|
+
delta=ChatCompletionMessageDelta(
|
|
465
|
+
role="assistant" if is_first_chunk else None,
|
|
466
|
+
content=content,
|
|
467
|
+
),
|
|
468
|
+
finish_reason=None,
|
|
469
|
+
)
|
|
470
|
+
],
|
|
471
|
+
)
|
|
472
|
+
is_first_chunk = False
|
|
473
|
+
yield f"data: {content_chunk.model_dump_json()}\n\n"
|
|
474
|
+
elif event_type == "child_tool_result":
|
|
475
|
+
# Emit child tool completion
|
|
476
|
+
result = child_event.get("result", {})
|
|
477
|
+
# Emit metadata event for child agent if it registered metadata
|
|
478
|
+
if isinstance(result, dict) and result.get("_metadata_event"):
|
|
479
|
+
responding_agent = result.get("agent_schema") or responding_agent
|
|
480
|
+
yield format_sse_event(MetadataEvent(
|
|
481
|
+
message_id=message_id,
|
|
482
|
+
session_id=session_id,
|
|
483
|
+
agent_schema=agent_schema,
|
|
484
|
+
responding_agent=responding_agent,
|
|
485
|
+
confidence=result.get("confidence"),
|
|
486
|
+
extra={"risk_level": result.get("risk_level")} if result.get("risk_level") else None,
|
|
487
|
+
))
|
|
488
|
+
yield format_sse_event(ToolCallEvent(
|
|
489
|
+
tool_name=f"{child_agent}:tool",
|
|
490
|
+
tool_id=f"call_{uuid.uuid4().hex[:8]}",
|
|
491
|
+
status="completed",
|
|
492
|
+
result=str(result)[:200] if result else None,
|
|
493
|
+
))
|
|
494
|
+
except Exception as e:
|
|
495
|
+
logger.warning(f"Error processing child event: {e}")
|
|
496
|
+
|
|
422
497
|
# Tool result event - emit completion
|
|
423
498
|
if isinstance(tool_event, FunctionToolResultEvent):
|
|
424
499
|
# Get the tool name/id from the pending queue (FIFO)
|
|
@@ -451,6 +526,10 @@ async def stream_openai_response(
|
|
|
451
526
|
registered_recommended_action = result_content.get("recommended_action")
|
|
452
527
|
# Extra fields
|
|
453
528
|
registered_extra = result_content.get("extra")
|
|
529
|
+
# Only set responding_agent if not already set by child events
|
|
530
|
+
# Child agents should take precedence - they're the actual responders
|
|
531
|
+
if not responding_agent:
|
|
532
|
+
responding_agent = result_content.get("agent_schema")
|
|
454
533
|
|
|
455
534
|
logger.info(
|
|
456
535
|
f"📊 Metadata registered: confidence={registered_confidence}, "
|
|
@@ -477,6 +556,7 @@ async def stream_openai_response(
|
|
|
477
556
|
in_reply_to=in_reply_to,
|
|
478
557
|
session_id=session_id,
|
|
479
558
|
agent_schema=agent_schema,
|
|
559
|
+
responding_agent=responding_agent,
|
|
480
560
|
session_name=registered_session_name,
|
|
481
561
|
confidence=registered_confidence,
|
|
482
562
|
sources=registered_sources,
|
|
@@ -502,6 +582,11 @@ async def stream_openai_response(
|
|
|
502
582
|
del pending_tool_data[tool_id]
|
|
503
583
|
|
|
504
584
|
if not is_metadata_event:
|
|
585
|
+
# NOTE: text_response fallback is DISABLED
|
|
586
|
+
# Child agents now stream content via child_content events (above)
|
|
587
|
+
# which provides real-time streaming. The text_response in tool
|
|
588
|
+
# result would duplicate that content, so we skip it entirely.
|
|
589
|
+
|
|
505
590
|
# Normal tool completion - emit ToolCallEvent
|
|
506
591
|
# For finalize_intake, send full result dict for frontend
|
|
507
592
|
if tool_name == "finalize_intake" and isinstance(result_content, dict):
|
|
@@ -624,6 +709,7 @@ async def stream_openai_response(
|
|
|
624
709
|
in_reply_to=in_reply_to,
|
|
625
710
|
session_id=session_id,
|
|
626
711
|
agent_schema=agent_schema,
|
|
712
|
+
responding_agent=responding_agent,
|
|
627
713
|
confidence=1.0, # Default to 100% confidence
|
|
628
714
|
model_version=model,
|
|
629
715
|
latency_ms=latency_ms,
|
|
@@ -716,6 +802,8 @@ async def stream_openai_response(
|
|
|
716
802
|
yield "data: [DONE]\n\n"
|
|
717
803
|
|
|
718
804
|
finally:
|
|
805
|
+
# Clean up event sink for multi-agent streaming
|
|
806
|
+
set_event_sink(None)
|
|
719
807
|
# Restore previous context for multi-agent support
|
|
720
808
|
# This ensures nested agent calls don't pollute the parent's context
|
|
721
809
|
if agent_context is not None:
|
|
@@ -823,6 +911,47 @@ async def stream_minimal_simulator(
|
|
|
823
911
|
yield sse_string
|
|
824
912
|
|
|
825
913
|
|
|
914
|
+
async def save_user_message(
|
|
915
|
+
session_id: str,
|
|
916
|
+
user_id: str | None,
|
|
917
|
+
content: str,
|
|
918
|
+
) -> None:
|
|
919
|
+
"""
|
|
920
|
+
Save user message to database before streaming.
|
|
921
|
+
|
|
922
|
+
This is a shared utility used by both API and CLI to ensure consistent
|
|
923
|
+
user message storage.
|
|
924
|
+
|
|
925
|
+
Args:
|
|
926
|
+
session_id: Session ID for message storage
|
|
927
|
+
user_id: User ID for message storage
|
|
928
|
+
content: The user's message content
|
|
929
|
+
"""
|
|
930
|
+
from ....utils.date_utils import utc_now, to_iso
|
|
931
|
+
from ....services.session import SessionMessageStore
|
|
932
|
+
from ....settings import settings
|
|
933
|
+
|
|
934
|
+
if not settings.postgres.enabled or not session_id:
|
|
935
|
+
return
|
|
936
|
+
|
|
937
|
+
user_msg = {
|
|
938
|
+
"role": "user",
|
|
939
|
+
"content": content,
|
|
940
|
+
"timestamp": to_iso(utc_now()),
|
|
941
|
+
}
|
|
942
|
+
try:
|
|
943
|
+
store = SessionMessageStore(user_id=user_id or settings.test.effective_user_id)
|
|
944
|
+
await store.store_session_messages(
|
|
945
|
+
session_id=session_id,
|
|
946
|
+
messages=[user_msg],
|
|
947
|
+
user_id=user_id,
|
|
948
|
+
compress=False,
|
|
949
|
+
)
|
|
950
|
+
logger.debug(f"Saved user message to session {session_id}")
|
|
951
|
+
except Exception as e:
|
|
952
|
+
logger.error(f"Failed to save user message: {e}", exc_info=True)
|
|
953
|
+
|
|
954
|
+
|
|
826
955
|
async def stream_openai_response_with_save(
|
|
827
956
|
agent: Agent,
|
|
828
957
|
prompt: str,
|
|
@@ -842,6 +971,9 @@ async def stream_openai_response_with_save(
|
|
|
842
971
|
This accumulates all text content during streaming and saves it to the database
|
|
843
972
|
after the stream completes.
|
|
844
973
|
|
|
974
|
+
NOTE: Call save_user_message() BEFORE this function to save the user's message.
|
|
975
|
+
This function only saves tool calls and assistant responses.
|
|
976
|
+
|
|
845
977
|
Args:
|
|
846
978
|
agent: Pydantic AI agent instance
|
|
847
979
|
prompt: User prompt
|
|
@@ -899,6 +1031,9 @@ async def stream_openai_response_with_save(
|
|
|
899
1031
|
delta = data["choices"][0].get("delta", {})
|
|
900
1032
|
content = delta.get("content")
|
|
901
1033
|
if content:
|
|
1034
|
+
# DEBUG: Check for [Calling markers in content
|
|
1035
|
+
if "[Calling" in content:
|
|
1036
|
+
logger.warning(f"DEBUG: Found [Calling in content chunk: {repr(content[:100])}")
|
|
902
1037
|
accumulated_content.append(content)
|
|
903
1038
|
except (json.JSONDecodeError, KeyError, IndexError):
|
|
904
1039
|
pass # Skip non-JSON or malformed chunks
|
|
@@ -931,8 +1066,34 @@ async def stream_openai_response_with_save(
|
|
|
931
1066
|
messages_to_store.append(tool_message)
|
|
932
1067
|
|
|
933
1068
|
# Then store assistant text response (if any)
|
|
1069
|
+
# Priority: direct TextPartDelta content > tool call text_response
|
|
1070
|
+
# When an agent delegates via ask_agent, the child's text_response becomes
|
|
1071
|
+
# the parent's assistant response (the parent is just orchestrating)
|
|
1072
|
+
full_content = None
|
|
1073
|
+
|
|
934
1074
|
if accumulated_content:
|
|
935
1075
|
full_content = "".join(accumulated_content)
|
|
1076
|
+
logger.warning(f"DEBUG: Using accumulated_content ({len(accumulated_content)} chunks, {len(full_content)} chars)")
|
|
1077
|
+
logger.warning(f"DEBUG: First 200 chars: {repr(full_content[:200])}")
|
|
1078
|
+
else:
|
|
1079
|
+
logger.warning("DEBUG: accumulated_content is empty, checking text_response fallback")
|
|
1080
|
+
# No direct text from TextPartDelta - check tool results for text_response
|
|
1081
|
+
# This handles multi-agent delegation where child agent output is the response
|
|
1082
|
+
for tool_call in tool_calls:
|
|
1083
|
+
if not tool_call:
|
|
1084
|
+
continue
|
|
1085
|
+
result = tool_call.get("result")
|
|
1086
|
+
if isinstance(result, dict) and result.get("text_response"):
|
|
1087
|
+
text_response = result["text_response"]
|
|
1088
|
+
if text_response and str(text_response).strip():
|
|
1089
|
+
full_content = str(text_response)
|
|
1090
|
+
logger.debug(
|
|
1091
|
+
f"Using text_response from {tool_call.get('tool_name', 'tool')} "
|
|
1092
|
+
f"({len(full_content)} chars) as assistant message"
|
|
1093
|
+
)
|
|
1094
|
+
break
|
|
1095
|
+
|
|
1096
|
+
if full_content:
|
|
936
1097
|
assistant_message = {
|
|
937
1098
|
"id": message_id, # Use pre-generated ID for consistency with metadata event
|
|
938
1099
|
"role": "assistant",
|
|
@@ -954,7 +1115,7 @@ async def stream_openai_response_with_save(
|
|
|
954
1115
|
)
|
|
955
1116
|
logger.debug(
|
|
956
1117
|
f"Saved {len(tool_calls)} tool calls and "
|
|
957
|
-
f"{'assistant response' if
|
|
1118
|
+
f"{'assistant response' if full_content else 'no text'} "
|
|
958
1119
|
f"to session {session_id}"
|
|
959
1120
|
)
|
|
960
1121
|
except Exception as e:
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common models shared across API routers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorResponse(BaseModel):
|
|
9
|
+
"""Standard error response format for HTTPException errors.
|
|
10
|
+
|
|
11
|
+
This is different from FastAPI's HTTPValidationError which is used
|
|
12
|
+
for Pydantic validation failures (422 errors with loc/msg/type array).
|
|
13
|
+
|
|
14
|
+
HTTPException errors return this simpler format:
|
|
15
|
+
{"detail": "Error message here"}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
detail: str = Field(description="Error message describing what went wrong")
|
rem/api/routers/dev.py
CHANGED
|
@@ -11,6 +11,7 @@ Endpoints:
|
|
|
11
11
|
from fastapi import APIRouter, HTTPException, Request
|
|
12
12
|
from loguru import logger
|
|
13
13
|
|
|
14
|
+
from .common import ErrorResponse
|
|
14
15
|
from ...settings import settings
|
|
15
16
|
|
|
16
17
|
router = APIRouter(prefix="/api/dev", tags=["dev"])
|
|
@@ -45,7 +46,12 @@ def verify_dev_token(token: str) -> bool:
|
|
|
45
46
|
return token == expected
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
@router.get(
|
|
49
|
+
@router.get(
|
|
50
|
+
"/token",
|
|
51
|
+
responses={
|
|
52
|
+
401: {"model": ErrorResponse, "description": "Dev tokens not available in production"},
|
|
53
|
+
},
|
|
54
|
+
)
|
|
49
55
|
async def get_dev_token(request: Request):
|
|
50
56
|
"""
|
|
51
57
|
Get a development token for testing (non-production only).
|