remdb 0.3.133__py3-none-any.whl → 0.3.171__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 (60) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +36 -9
  5. rem/agentic/mcp/tool_wrapper.py +54 -6
  6. rem/agentic/providers/phoenix.py +91 -21
  7. rem/agentic/providers/pydantic_ai.py +88 -45
  8. rem/api/deps.py +3 -5
  9. rem/api/main.py +22 -3
  10. rem/api/mcp_router/server.py +2 -0
  11. rem/api/mcp_router/tools.py +94 -2
  12. rem/api/middleware/tracking.py +5 -5
  13. rem/api/routers/auth.py +349 -6
  14. rem/api/routers/chat/completions.py +5 -3
  15. rem/api/routers/chat/streaming.py +95 -22
  16. rem/api/routers/messages.py +24 -15
  17. rem/auth/__init__.py +13 -3
  18. rem/auth/jwt.py +352 -0
  19. rem/auth/middleware.py +115 -10
  20. rem/auth/providers/__init__.py +4 -1
  21. rem/auth/providers/email.py +215 -0
  22. rem/cli/commands/configure.py +3 -4
  23. rem/cli/commands/experiments.py +50 -49
  24. rem/cli/commands/session.py +336 -0
  25. rem/cli/dreaming.py +2 -2
  26. rem/cli/main.py +2 -0
  27. rem/models/core/experiment.py +4 -14
  28. rem/models/entities/__init__.py +4 -0
  29. rem/models/entities/ontology.py +1 -1
  30. rem/models/entities/ontology_config.py +1 -1
  31. rem/models/entities/subscriber.py +175 -0
  32. rem/models/entities/user.py +1 -0
  33. rem/schemas/agents/core/agent-builder.yaml +235 -0
  34. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  35. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  36. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  37. rem/services/__init__.py +3 -1
  38. rem/services/content/service.py +4 -3
  39. rem/services/email/__init__.py +10 -0
  40. rem/services/email/service.py +513 -0
  41. rem/services/email/templates.py +360 -0
  42. rem/services/postgres/README.md +38 -0
  43. rem/services/postgres/diff_service.py +19 -3
  44. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  45. rem/services/postgres/repository.py +5 -4
  46. rem/services/session/compression.py +113 -50
  47. rem/services/session/reload.py +14 -7
  48. rem/services/user_service.py +41 -9
  49. rem/settings.py +200 -5
  50. rem/sql/migrations/001_install.sql +1 -1
  51. rem/sql/migrations/002_install_models.sql +91 -91
  52. rem/sql/migrations/005_schema_update.sql +145 -0
  53. rem/utils/README.md +45 -0
  54. rem/utils/files.py +157 -1
  55. rem/utils/schema_loader.py +45 -7
  56. rem/utils/vision.py +1 -1
  57. {remdb-0.3.133.dist-info → remdb-0.3.171.dist-info}/METADATA +7 -5
  58. {remdb-0.3.133.dist-info → remdb-0.3.171.dist-info}/RECORD +60 -50
  59. {remdb-0.3.133.dist-info → remdb-0.3.171.dist-info}/WHEEL +0 -0
  60. {remdb-0.3.133.dist-info → remdb-0.3.171.dist-info}/entry_points.txt +0 -0
rem/api/routers/auth.py CHANGED
@@ -1,20 +1,68 @@
1
1
  """
2
- OAuth 2.1 Authentication Router.
2
+ Authentication Router.
3
3
 
4
- Leverages Authlib for standards-compliant OAuth/OIDC implementation.
5
- Minimal custom code - Authlib handles PKCE, token validation, JWKS.
4
+ Supports multiple authentication methods:
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
7
 
7
8
  Endpoints:
9
+ - POST /api/auth/email/send-code - Send login code to email
10
+ - POST /api/auth/email/verify - Verify code and create session
8
11
  - GET /api/auth/{provider}/login - Initiate OAuth flow
9
12
  - GET /api/auth/{provider}/callback - OAuth callback
10
13
  - POST /api/auth/logout - Clear session
11
14
  - GET /api/auth/me - Current user info
12
15
 
13
16
  Supported providers:
17
+ - email: Passwordless email login
14
18
  - google: Google OAuth 2.0 / OIDC
15
19
  - microsoft: Microsoft Entra ID OIDC
16
20
 
17
- Design Pattern (OAuth 2.1 + PKCE):
21
+ =============================================================================
22
+ Email Authentication Access Control
23
+ =============================================================================
24
+
25
+ The email auth provider implements a tiered access control system:
26
+
27
+ Access Control Flow (send-code):
28
+ User requests login code
29
+ ├── User exists in database?
30
+ │ ├── Yes → Check user.tier
31
+ │ │ ├── tier == BLOCKED → Reject "Account is blocked"
32
+ │ │ └── tier != BLOCKED → Allow (send code, existing users grandfathered)
33
+ │ └── No (new user) → Check EMAIL__TRUSTED_EMAIL_DOMAINS
34
+ │ ├── Setting configured → domain in trusted list?
35
+ │ │ ├── Yes → Create user & send code
36
+ │ │ └── No → Reject "Email domain not allowed for signup"
37
+ │ └── Not configured (empty) → Create user & send code (no restrictions)
38
+
39
+ Key Behaviors:
40
+ - Existing users: Always allowed to login (unless tier=BLOCKED)
41
+ - New users: Must have email from trusted domain (if EMAIL__TRUSTED_EMAIL_DOMAINS is set)
42
+ - No restrictions: Leave EMAIL__TRUSTED_EMAIL_DOMAINS empty to allow all domains
43
+
44
+ User Tiers (models.entities.UserTier):
45
+ - BLOCKED: Cannot login (rejected at send-code)
46
+ - ANONYMOUS: Rate-limited anonymous access
47
+ - FREE: Standard free tier
48
+ - BASIC/PRO: Paid tiers with additional features
49
+
50
+ Configuration:
51
+ # Allow only specific domains for new signups
52
+ EMAIL__TRUSTED_EMAIL_DOMAINS=siggymd.ai,example.com
53
+
54
+ # Allow all domains (no restrictions)
55
+ EMAIL__TRUSTED_EMAIL_DOMAINS=
56
+
57
+ Example blocking a user:
58
+ user = await user_repo.get_by_id(user_id, tenant_id="default")
59
+ user.tier = UserTier.BLOCKED
60
+ await user_repo.upsert(user)
61
+
62
+ =============================================================================
63
+ OAuth Design Pattern (OAuth 2.1 + PKCE)
64
+ =============================================================================
65
+
18
66
  1. User clicks "Login with Google"
19
67
  2. /login generates state + PKCE code_verifier
20
68
  3. Store code_verifier in session
@@ -37,6 +85,7 @@ Environment variables:
37
85
  AUTH__MICROSOFT__CLIENT_ID=<microsoft-client-id>
38
86
  AUTH__MICROSOFT__CLIENT_SECRET=<microsoft-client-secret>
39
87
  AUTH__MICROSOFT__TENANT=common
88
+ EMAIL__TRUSTED_EMAIL_DOMAINS=example.com # Optional: restrict new signups
40
89
 
41
90
  References:
42
91
  - Authlib: https://docs.authlib.org/en/latest/
@@ -46,11 +95,15 @@ References:
46
95
  from fastapi import APIRouter, HTTPException, Request
47
96
  from fastapi.responses import RedirectResponse
48
97
  from authlib.integrations.starlette_client import OAuth
98
+ from pydantic import BaseModel, EmailStr
49
99
  from loguru import logger
50
100
 
51
101
  from ...settings import settings
52
102
  from ...services.postgres.service import PostgresService
53
103
  from ...services.user_service import UserService
104
+ from ...auth.providers.email import EmailAuthProvider
105
+ from ...auth.jwt import JWTService, get_jwt_service
106
+ from ...utils.user_id import email_to_user_id
54
107
 
55
108
  router = APIRouter(prefix="/api/auth", tags=["auth"])
56
109
 
@@ -87,6 +140,182 @@ if settings.auth.microsoft.client_id:
87
140
  logger.info(f"Microsoft OAuth provider registered (tenant: {tenant})")
88
141
 
89
142
 
143
+ # =============================================================================
144
+ # Email Authentication Endpoints
145
+ # =============================================================================
146
+
147
+
148
+ class EmailSendCodeRequest(BaseModel):
149
+ """Request to send login code."""
150
+ email: EmailStr
151
+
152
+
153
+ class EmailVerifyRequest(BaseModel):
154
+ """Request to verify login code."""
155
+ email: EmailStr
156
+ code: str
157
+
158
+
159
+ @router.post("/email/send-code")
160
+ async def send_email_code(request: Request, body: EmailSendCodeRequest):
161
+ """
162
+ Send a login code to an email address.
163
+
164
+ Creates user if not exists (using deterministic UUID from email).
165
+ Stores code in user metadata with expiry.
166
+
167
+ Args:
168
+ request: FastAPI request
169
+ body: EmailSendCodeRequest with email
170
+
171
+ Returns:
172
+ Success status and message
173
+ """
174
+ if not settings.email.is_configured:
175
+ raise HTTPException(
176
+ status_code=501,
177
+ detail="Email authentication is not configured"
178
+ )
179
+
180
+ # Get database connection
181
+ if not settings.postgres.enabled:
182
+ raise HTTPException(
183
+ status_code=501,
184
+ detail="Database is required for email authentication"
185
+ )
186
+
187
+ db = PostgresService()
188
+ try:
189
+ await db.connect()
190
+
191
+ # Initialize email auth provider
192
+ email_auth = EmailAuthProvider()
193
+
194
+ # Send code
195
+ result = await email_auth.send_code(
196
+ email=body.email,
197
+ db=db,
198
+ )
199
+
200
+ if result.success:
201
+ return {
202
+ "success": True,
203
+ "message": result.message,
204
+ "email": result.email,
205
+ }
206
+ else:
207
+ raise HTTPException(
208
+ status_code=400,
209
+ detail=result.message or result.error
210
+ )
211
+
212
+ except HTTPException:
213
+ raise
214
+ except Exception as e:
215
+ logger.error(f"Error sending login code: {e}")
216
+ raise HTTPException(status_code=500, detail="Failed to send login code")
217
+ finally:
218
+ await db.disconnect()
219
+
220
+
221
+ @router.post("/email/verify")
222
+ async def verify_email_code(request: Request, body: EmailVerifyRequest):
223
+ """
224
+ Verify login code and create session with JWT tokens.
225
+
226
+ Args:
227
+ request: FastAPI request
228
+ body: EmailVerifyRequest with email and code
229
+
230
+ Returns:
231
+ Success status with user info and JWT tokens
232
+ """
233
+ if not settings.email.is_configured:
234
+ raise HTTPException(
235
+ status_code=501,
236
+ detail="Email authentication is not configured"
237
+ )
238
+
239
+ if not settings.postgres.enabled:
240
+ raise HTTPException(
241
+ status_code=501,
242
+ detail="Database is required for email authentication"
243
+ )
244
+
245
+ db = PostgresService()
246
+ try:
247
+ await db.connect()
248
+
249
+ # Initialize email auth provider
250
+ email_auth = EmailAuthProvider()
251
+
252
+ # Verify code
253
+ result = await email_auth.verify_code(
254
+ email=body.email,
255
+ code=body.code,
256
+ db=db,
257
+ )
258
+
259
+ if not result.success:
260
+ raise HTTPException(
261
+ status_code=400,
262
+ detail=result.message or result.error
263
+ )
264
+
265
+ # Create session - compatible with OAuth session format
266
+ user_dict = email_auth.get_user_dict(
267
+ email=result.email,
268
+ user_id=result.user_id,
269
+ )
270
+
271
+ # Fetch actual user data from database to get role/tier
272
+ user_service = UserService(db)
273
+ try:
274
+ user_entity = await user_service.get_user_by_id(result.user_id)
275
+ if user_entity:
276
+ # Override defaults with actual database values
277
+ user_dict["role"] = user_entity.role or "user"
278
+ user_dict["roles"] = [user_entity.role] if user_entity.role else ["user"]
279
+ user_dict["tier"] = user_entity.tier.value if user_entity.tier else "free"
280
+ user_dict["name"] = user_entity.name or user_dict["name"]
281
+ except Exception as e:
282
+ logger.warning(f"Could not fetch user details: {e}")
283
+ # Continue with defaults from get_user_dict
284
+
285
+ # Generate JWT tokens
286
+ jwt_service = get_jwt_service()
287
+ tokens = jwt_service.create_tokens(user_dict)
288
+
289
+ # Store user in session (for backward compatibility)
290
+ request.session["user"] = user_dict
291
+
292
+ logger.info(f"User authenticated via email: {result.email}")
293
+
294
+ return {
295
+ "success": True,
296
+ "message": result.message,
297
+ "user": user_dict,
298
+ # JWT tokens for stateless auth
299
+ "access_token": tokens["access_token"],
300
+ "refresh_token": tokens["refresh_token"],
301
+ "token_type": tokens["token_type"],
302
+ "expires_in": tokens["expires_in"],
303
+ }
304
+
305
+ except HTTPException:
306
+ raise
307
+ except Exception as e:
308
+ logger.error(f"Error verifying login code: {e}")
309
+ raise HTTPException(status_code=500, detail="Failed to verify login code")
310
+ finally:
311
+ await db.disconnect()
312
+
313
+
314
+ # =============================================================================
315
+ # OAuth Authentication Endpoints
316
+ # =============================================================================
317
+
318
+
90
319
  @router.get("/{provider}/login")
91
320
  async def login(provider: str, request: Request):
92
321
  """
@@ -201,8 +430,9 @@ async def callback(provider: str, request: Request):
201
430
  await user_service.link_anonymous_session(user_entity, anon_id)
202
431
 
203
432
  # Enrich session user with DB info
433
+ # user_id = UUID5 hash of email (deterministic, bijection)
204
434
  db_info = {
205
- "id": str(user_entity.id),
435
+ "id": email_to_user_id(user_info.get("email")),
206
436
  "tenant_id": user_entity.tenant_id,
207
437
  "tier": user_entity.tier.value if user_entity.tier else "free",
208
438
  "roles": [user_entity.role] if user_entity.role else [],
@@ -268,7 +498,7 @@ async def logout(request: Request):
268
498
  @router.get("/me")
269
499
  async def me(request: Request):
270
500
  """
271
- Get current user information from session.
501
+ Get current user information from session or JWT.
272
502
 
273
503
  Args:
274
504
  request: FastAPI request
@@ -276,6 +506,16 @@ async def me(request: Request):
276
506
  Returns:
277
507
  User information or 401 if not authenticated
278
508
  """
509
+ # First check for JWT in Authorization header
510
+ auth_header = request.headers.get("Authorization")
511
+ if auth_header and auth_header.startswith("Bearer "):
512
+ token = auth_header[7:]
513
+ jwt_service = get_jwt_service()
514
+ user = jwt_service.verify_token(token)
515
+ if user:
516
+ return user
517
+
518
+ # Fall back to session
279
519
  user = request.session.get("user")
280
520
  if not user:
281
521
  raise HTTPException(status_code=401, detail="Not authenticated")
@@ -283,6 +523,69 @@ async def me(request: Request):
283
523
  return user
284
524
 
285
525
 
526
+ # =============================================================================
527
+ # JWT Token Endpoints
528
+ # =============================================================================
529
+
530
+
531
+ class TokenRefreshRequest(BaseModel):
532
+ """Request to refresh access token."""
533
+ refresh_token: str
534
+
535
+
536
+ @router.post("/token/refresh")
537
+ async def refresh_token(body: TokenRefreshRequest):
538
+ """
539
+ Refresh access token using refresh token.
540
+
541
+ Args:
542
+ body: TokenRefreshRequest with refresh_token
543
+
544
+ Returns:
545
+ New access token or 401 if refresh token is invalid
546
+ """
547
+ jwt_service = get_jwt_service()
548
+ result = jwt_service.refresh_access_token(body.refresh_token)
549
+
550
+ if not result:
551
+ raise HTTPException(
552
+ status_code=401,
553
+ detail="Invalid or expired refresh token"
554
+ )
555
+
556
+ return result
557
+
558
+
559
+ @router.post("/token/verify")
560
+ async def verify_token(request: Request):
561
+ """
562
+ Verify an access token is valid.
563
+
564
+ Pass the token in the Authorization header: Bearer <token>
565
+
566
+ Returns:
567
+ User info if valid, 401 if invalid
568
+ """
569
+ auth_header = request.headers.get("Authorization")
570
+ if not auth_header or not auth_header.startswith("Bearer "):
571
+ raise HTTPException(
572
+ status_code=401,
573
+ detail="Missing Authorization header"
574
+ )
575
+
576
+ token = auth_header[7:]
577
+ jwt_service = get_jwt_service()
578
+ user = jwt_service.verify_token(token)
579
+
580
+ if not user:
581
+ raise HTTPException(
582
+ status_code=401,
583
+ detail="Invalid or expired token"
584
+ )
585
+
586
+ return {"valid": True, "user": user}
587
+
588
+
286
589
  # =============================================================================
287
590
  # Development Token Endpoints (non-production only)
288
591
  # =============================================================================
@@ -351,3 +654,43 @@ async def get_dev_token(request: Request):
351
654
  "usage": f'curl -H "Authorization: Bearer {token}" http://localhost:8000/api/v1/...',
352
655
  "warning": "This token is for development/testing only and will not work in production.",
353
656
  }
657
+
658
+
659
+ @router.get("/dev/mock-code/{email}")
660
+ async def get_mock_code(email: str, request: Request):
661
+ """
662
+ Get the mock login code for testing (non-production only).
663
+
664
+ This endpoint retrieves the code that was "sent" via email in mock mode.
665
+ Use this for automated testing without real email delivery.
666
+
667
+ Usage:
668
+ 1. POST /api/auth/email/send-code with email
669
+ 2. GET /api/auth/dev/mock-code/{email} to retrieve the code
670
+ 3. POST /api/auth/email/verify with email and code
671
+
672
+ Returns:
673
+ 401 if in production environment
674
+ 404 if no code found for the email
675
+ The code and email otherwise
676
+ """
677
+ if settings.environment == "production":
678
+ raise HTTPException(
679
+ status_code=401,
680
+ detail="Mock codes are not available in production"
681
+ )
682
+
683
+ from ...services.email import EmailService
684
+
685
+ code = EmailService.get_mock_code(email)
686
+ if not code:
687
+ raise HTTPException(
688
+ status_code=404,
689
+ detail=f"No mock code found for {email}. Send a code first."
690
+ )
691
+
692
+ return {
693
+ "email": email,
694
+ "code": code,
695
+ "warning": "This endpoint is for testing only and will not work in production.",
696
+ }
@@ -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)")
@@ -76,6 +76,9 @@ async def stream_openai_response(
76
76
  agent_schema: str | None = None,
77
77
  # Mutable container to capture trace context (deterministic, not AI-dependent)
78
78
  trace_context_out: dict | None = None,
79
+ # Mutable container to capture tool calls for persistence
80
+ # Format: list of {"tool_name": str, "tool_id": str, "arguments": dict, "result": any}
81
+ tool_calls_out: list | None = None,
79
82
  ) -> AsyncGenerator[str, None]:
80
83
  """
81
84
  Stream Pydantic AI agent responses with rich SSE events.
@@ -146,6 +149,9 @@ async def stream_openai_response(
146
149
  pending_tool_completions: list[tuple[str, str]] = []
147
150
  # Track if metadata was registered via register_metadata tool
148
151
  metadata_registered = False
152
+ # Track pending tool calls with full data for persistence
153
+ # Maps tool_id -> {"tool_name": str, "tool_id": str, "arguments": dict}
154
+ pending_tool_data: dict[str, dict] = {}
149
155
 
150
156
  try:
151
157
  # Emit initial progress event
@@ -299,6 +305,13 @@ async def stream_openai_response(
299
305
  arguments=args_dict
300
306
  ))
301
307
 
308
+ # Track tool call data for persistence (especially register_metadata)
309
+ pending_tool_data[tool_id] = {
310
+ "tool_name": tool_name,
311
+ "tool_id": tool_id,
312
+ "arguments": args_dict,
313
+ }
314
+
302
315
  # Update progress
303
316
  current_step = 2
304
317
  total_steps = 4 # Added tool execution step
@@ -421,6 +434,15 @@ async def stream_openai_response(
421
434
  hidden=False,
422
435
  ))
423
436
 
437
+ # Capture tool call with result for persistence
438
+ # Special handling for register_metadata - always capture full data
439
+ if tool_calls_out is not None and tool_id in pending_tool_data:
440
+ tool_data = pending_tool_data[tool_id]
441
+ tool_data["result"] = result_content
442
+ tool_data["is_metadata"] = is_metadata_event
443
+ tool_calls_out.append(tool_data)
444
+ del pending_tool_data[tool_id]
445
+
424
446
  if not is_metadata_event:
425
447
  # Normal tool completion - emit ToolCallEvent
426
448
  result_str = str(result_content)
@@ -728,6 +750,9 @@ async def stream_openai_response_with_save(
728
750
  # Accumulate content during streaming
729
751
  accumulated_content = []
730
752
 
753
+ # Capture tool calls for persistence (especially register_metadata)
754
+ tool_calls: list = []
755
+
731
756
  async for chunk in stream_openai_response(
732
757
  agent=agent,
733
758
  prompt=prompt,
@@ -737,6 +762,7 @@ async def stream_openai_response_with_save(
737
762
  session_id=session_id,
738
763
  message_id=message_id,
739
764
  trace_context_out=trace_context, # Pass container to capture trace IDs
765
+ tool_calls_out=tool_calls, # Capture tool calls for persistence
740
766
  ):
741
767
  yield chunk
742
768
 
@@ -755,28 +781,75 @@ async def stream_openai_response_with_save(
755
781
  except (json.JSONDecodeError, KeyError, IndexError):
756
782
  pass # Skip non-JSON or malformed chunks
757
783
 
758
- # After streaming completes, save the assistant response
759
- if settings.postgres.enabled and session_id and accumulated_content:
760
- full_content = "".join(accumulated_content)
784
+ # After streaming completes, save tool calls and assistant response
785
+ # Note: All messages stored UNCOMPRESSED. Compression happens on reload.
786
+ if settings.postgres.enabled and session_id:
761
787
  # Get captured trace context from container (deterministically captured inside agent execution)
762
788
  captured_trace_id = trace_context.get("trace_id")
763
789
  captured_span_id = trace_context.get("span_id")
764
- assistant_message = {
765
- "id": message_id, # Use pre-generated ID for consistency with metadata event
766
- "role": "assistant",
767
- "content": full_content,
768
- "timestamp": to_iso(utc_now()),
769
- "trace_id": captured_trace_id,
770
- "span_id": captured_span_id,
771
- }
772
- try:
773
- store = SessionMessageStore(user_id=user_id or settings.test.effective_user_id)
774
- await store.store_session_messages(
775
- session_id=session_id,
776
- messages=[assistant_message],
777
- user_id=user_id,
778
- compress=True, # Compress long assistant responses
779
- )
780
- logger.debug(f"Saved assistant response {message_id} to session {session_id} ({len(full_content)} chars)")
781
- except Exception as e:
782
- logger.error(f"Failed to save assistant response: {e}", exc_info=True)
790
+ timestamp = to_iso(utc_now())
791
+
792
+ messages_to_store = []
793
+
794
+ # First, store tool call messages (message_type: "tool")
795
+ for tool_call in tool_calls:
796
+ tool_message = {
797
+ "role": "tool",
798
+ "content": json.dumps(tool_call.get("result", {}), default=str),
799
+ "timestamp": timestamp,
800
+ "trace_id": captured_trace_id,
801
+ "span_id": captured_span_id,
802
+ # Store tool call details in a way that can be reconstructed
803
+ "tool_call_id": tool_call.get("tool_id"),
804
+ "tool_name": tool_call.get("tool_name"),
805
+ "tool_arguments": tool_call.get("arguments"),
806
+ }
807
+ messages_to_store.append(tool_message)
808
+
809
+ # Then store assistant text response (if any)
810
+ if accumulated_content:
811
+ full_content = "".join(accumulated_content)
812
+ assistant_message = {
813
+ "id": message_id, # Use pre-generated ID for consistency with metadata event
814
+ "role": "assistant",
815
+ "content": full_content,
816
+ "timestamp": timestamp,
817
+ "trace_id": captured_trace_id,
818
+ "span_id": captured_span_id,
819
+ }
820
+ messages_to_store.append(assistant_message)
821
+
822
+ if messages_to_store:
823
+ try:
824
+ store = SessionMessageStore(user_id=user_id or settings.test.effective_user_id)
825
+ await store.store_session_messages(
826
+ session_id=session_id,
827
+ messages=messages_to_store,
828
+ user_id=user_id,
829
+ compress=False, # Store uncompressed; compression happens on reload
830
+ )
831
+ logger.debug(
832
+ f"Saved {len(tool_calls)} tool calls and "
833
+ f"{'assistant response' if accumulated_content else 'no text'} "
834
+ f"to session {session_id}"
835
+ )
836
+ except Exception as e:
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