remdb 0.3.202__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.

@@ -16,6 +16,7 @@ Endpoints:
16
16
  """
17
17
 
18
18
  from datetime import datetime
19
+ from enum import Enum
19
20
  from typing import Literal
20
21
  from uuid import UUID
21
22
 
@@ -23,6 +24,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
23
24
  from loguru import logger
24
25
  from pydantic import BaseModel, Field
25
26
 
27
+ from .common import ErrorResponse
28
+
26
29
  from ..deps import (
27
30
  get_current_user,
28
31
  get_user_filter,
@@ -38,6 +41,18 @@ from ...utils.date_utils import parse_iso, utc_now
38
41
  router = APIRouter(prefix="/api/v1")
39
42
 
40
43
 
44
+ # =============================================================================
45
+ # Enums
46
+ # =============================================================================
47
+
48
+
49
+ class SortOrder(str, Enum):
50
+ """Sort order for list queries."""
51
+
52
+ ASC = "asc"
53
+ DESC = "desc"
54
+
55
+
41
56
  # =============================================================================
42
57
  # Request/Response Models
43
58
  # =============================================================================
@@ -134,7 +149,14 @@ class SessionsQueryResponse(BaseModel):
134
149
  # =============================================================================
135
150
 
136
151
 
137
- @router.get("/messages", response_model=MessageListResponse, tags=["messages"])
152
+ @router.get(
153
+ "/messages",
154
+ response_model=MessageListResponse,
155
+ tags=["messages"],
156
+ responses={
157
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
158
+ },
159
+ )
138
160
  async def list_messages(
139
161
  request: Request,
140
162
  mine: bool = Query(default=False, description="Only show my messages (uses JWT identity)"),
@@ -151,6 +173,7 @@ async def list_messages(
151
173
  ),
152
174
  limit: int = Query(default=50, ge=1, le=100, description="Max results to return"),
153
175
  offset: int = Query(default=0, ge=0, description="Offset for pagination"),
176
+ sort: SortOrder = Query(default=SortOrder.DESC, description="Sort order by created_at (asc or desc)"),
154
177
  ) -> MessageListResponse:
155
178
  """
156
179
  List messages with optional filters.
@@ -166,8 +189,9 @@ async def list_messages(
166
189
  - session_id: Filter by conversation session
167
190
  - start_date/end_date: Filter by creation time range (ISO 8601 format)
168
191
  - message_type: Filter by role (user, assistant, system, tool)
192
+ - sort: Sort order by created_at (asc or desc, default: desc)
169
193
 
170
- Returns paginated results ordered by created_at descending.
194
+ Returns paginated results ordered by created_at.
171
195
  """
172
196
  if not settings.postgres.enabled:
173
197
  raise HTTPException(status_code=503, detail="Database not enabled")
@@ -189,6 +213,7 @@ async def list_messages(
189
213
 
190
214
  # Apply optional filters
191
215
  if session_id:
216
+ # session_id is the session UUID - use directly
192
217
  filters["session_id"] = session_id
193
218
  if message_type:
194
219
  filters["message_type"] = message_type
@@ -200,12 +225,15 @@ async def list_messages(
200
225
  f"filters={filters}"
201
226
  )
202
227
 
228
+ # Build order_by clause based on sort parameter
229
+ order_by = f"created_at {sort.value.upper()}"
230
+
203
231
  # For date filtering, we need custom SQL (not supported by basic Repository)
204
232
  # For now, fetch all matching base filters and filter in Python
205
233
  # TODO: Extend Repository to support date range filters
206
234
  messages = await repo.find(
207
235
  filters,
208
- order_by="created_at DESC",
236
+ order_by=order_by,
209
237
  limit=limit + 1, # Fetch one extra to determine has_more
210
238
  offset=offset,
211
239
  )
@@ -241,7 +269,16 @@ async def list_messages(
241
269
  return MessageListResponse(data=messages, total=total, has_more=has_more)
242
270
 
243
271
 
244
- @router.get("/messages/{message_id}", response_model=Message, tags=["messages"])
272
+ @router.get(
273
+ "/messages/{message_id}",
274
+ response_model=Message,
275
+ tags=["messages"],
276
+ responses={
277
+ 403: {"model": ErrorResponse, "description": "Access denied: not owner"},
278
+ 404: {"model": ErrorResponse, "description": "Message not found"},
279
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
280
+ },
281
+ )
245
282
  async def get_message(
246
283
  request: Request,
247
284
  message_id: str,
@@ -287,7 +324,14 @@ async def get_message(
287
324
  # =============================================================================
288
325
 
289
326
 
290
- @router.get("/sessions", response_model=SessionsQueryResponse, tags=["sessions"])
327
+ @router.get(
328
+ "/sessions",
329
+ response_model=SessionsQueryResponse,
330
+ tags=["sessions"],
331
+ responses={
332
+ 503: {"model": ErrorResponse, "description": "Database not enabled or connection failed"},
333
+ },
334
+ )
291
335
  async def list_sessions(
292
336
  request: Request,
293
337
  user_id: str | None = Query(default=None, description="Filter by user ID (admin only for cross-user)"),
@@ -400,7 +444,15 @@ async def list_sessions(
400
444
  )
401
445
 
402
446
 
403
- @router.post("/sessions", response_model=Session, status_code=201, tags=["sessions"])
447
+ @router.post(
448
+ "/sessions",
449
+ response_model=Session,
450
+ status_code=201,
451
+ tags=["sessions"],
452
+ responses={
453
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
454
+ },
455
+ )
404
456
  async def create_session(
405
457
  request_body: SessionCreateRequest,
406
458
  user: dict = Depends(require_admin),
@@ -452,7 +504,16 @@ async def create_session(
452
504
  return result # type: ignore
453
505
 
454
506
 
455
- @router.get("/sessions/{session_id}", response_model=Session, tags=["sessions"])
507
+ @router.get(
508
+ "/sessions/{session_id}",
509
+ response_model=Session,
510
+ tags=["sessions"],
511
+ responses={
512
+ 403: {"model": ErrorResponse, "description": "Access denied: not owner"},
513
+ 404: {"model": ErrorResponse, "description": "Session not found"},
514
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
515
+ },
516
+ )
456
517
  async def get_session(
457
518
  request: Request,
458
519
  session_id: str,
@@ -465,7 +526,7 @@ async def get_session(
465
526
  - Admin users: Can access any session
466
527
 
467
528
  Args:
468
- session_id: UUID or name of the session
529
+ session_id: UUID of the session
469
530
 
470
531
  Returns:
471
532
  Session object if found
@@ -481,12 +542,7 @@ async def get_session(
481
542
  session = await repo.get_by_id(session_id)
482
543
 
483
544
  if not session:
484
- # Try finding by name
485
- sessions = await repo.find({"name": session_id}, limit=1)
486
- if sessions:
487
- session = sessions[0]
488
- else:
489
- raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
545
+ raise HTTPException(status_code=404, detail=f"Session '{session_id}' not found")
490
546
 
491
547
  # Check access: admin or owner
492
548
  current_user = get_current_user(request)
@@ -498,7 +554,16 @@ async def get_session(
498
554
  return session
499
555
 
500
556
 
501
- @router.put("/sessions/{session_id}", response_model=Session, tags=["sessions"])
557
+ @router.put(
558
+ "/sessions/{session_id}",
559
+ response_model=Session,
560
+ tags=["sessions"],
561
+ responses={
562
+ 403: {"model": ErrorResponse, "description": "Access denied: not owner"},
563
+ 404: {"model": ErrorResponse, "description": "Session not found"},
564
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
565
+ },
566
+ )
502
567
  async def update_session(
503
568
  request: Request,
504
569
  session_id: str,
rem/api/routers/models.py CHANGED
@@ -15,6 +15,8 @@ from typing import Literal
15
15
  from fastapi import APIRouter, HTTPException
16
16
  from pydantic import BaseModel, Field
17
17
 
18
+ from .common import ErrorResponse
19
+
18
20
  from rem.agentic.llm_provider_models import (
19
21
  ModelInfo,
20
22
  AVAILABLE_MODELS,
@@ -57,7 +59,13 @@ async def list_models() -> ModelsResponse:
57
59
  return ModelsResponse(data=AVAILABLE_MODELS)
58
60
 
59
61
 
60
- @router.get("/models/{model_id:path}", response_model=ModelInfo)
62
+ @router.get(
63
+ "/models/{model_id:path}",
64
+ response_model=ModelInfo,
65
+ responses={
66
+ 404: {"model": ErrorResponse, "description": "Model not found"},
67
+ },
68
+ )
61
69
  async def get_model(model_id: str) -> ModelInfo:
62
70
  """
63
71
  Get information about a specific model.
rem/api/routers/query.py CHANGED
@@ -86,6 +86,8 @@ from fastapi import APIRouter, Header, HTTPException
86
86
  from loguru import logger
87
87
  from pydantic import BaseModel, Field
88
88
 
89
+ from .common import ErrorResponse
90
+
89
91
  from ...services.postgres import get_postgres_service
90
92
  from ...services.rem.service import RemService
91
93
  from ...services.rem.parser import RemQueryParser
@@ -213,7 +215,16 @@ class QueryResponse(BaseModel):
213
215
  )
214
216
 
215
217
 
216
- @router.post("/query", response_model=QueryResponse)
218
+ @router.post(
219
+ "/query",
220
+ response_model=QueryResponse,
221
+ responses={
222
+ 400: {"model": ErrorResponse, "description": "Invalid query or missing required fields"},
223
+ 500: {"model": ErrorResponse, "description": "Query execution failed"},
224
+ 501: {"model": ErrorResponse, "description": "Feature not yet implemented"},
225
+ 503: {"model": ErrorResponse, "description": "Database not configured or unavailable"},
226
+ },
227
+ )
217
228
  async def execute_query(
218
229
  request: QueryRequest,
219
230
  x_user_id: str | None = Header(default=None, description="User ID for query isolation (optional, uses default if not provided)"),
@@ -18,6 +18,8 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request
18
18
  from loguru import logger
19
19
  from pydantic import BaseModel, Field
20
20
 
21
+ from .common import ErrorResponse
22
+
21
23
  from ..deps import get_current_user, require_auth
22
24
  from ...models.entities import (
23
25
  Message,
@@ -83,6 +85,10 @@ class ShareSessionResponse(BaseModel):
83
85
  response_model=ShareSessionResponse,
84
86
  status_code=201,
85
87
  tags=["sessions"],
88
+ responses={
89
+ 400: {"model": ErrorResponse, "description": "Session already shared with this user"},
90
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
91
+ },
86
92
  )
87
93
  async def share_session(
88
94
  request: Request,
@@ -175,6 +181,10 @@ async def share_session(
175
181
  "/sessions/{session_id}/share/{shared_with_user_id}",
176
182
  status_code=200,
177
183
  tags=["sessions"],
184
+ responses={
185
+ 404: {"model": ErrorResponse, "description": "Share not found"},
186
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
187
+ },
178
188
  )
179
189
  async def remove_session_share(
180
190
  request: Request,
@@ -250,6 +260,9 @@ async def remove_session_share(
250
260
  "/sessions/shared-with-me",
251
261
  response_model=SharedWithMeResponse,
252
262
  tags=["sessions"],
263
+ responses={
264
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
265
+ },
253
266
  )
254
267
  async def get_shared_with_me(
255
268
  request: Request,
@@ -328,6 +341,9 @@ async def get_shared_with_me(
328
341
  "/sessions/shared-with-me/{owner_user_id}/messages",
329
342
  response_model=SharedMessagesResponse,
330
343
  tags=["sessions"],
344
+ responses={
345
+ 503: {"model": ErrorResponse, "description": "Database not enabled"},
346
+ },
331
347
  )
332
348
  async def get_shared_messages(
333
349
  request: Request,
rem/cli/commands/ask.py CHANGED
@@ -71,16 +71,18 @@ async def run_agent_streaming(
71
71
  max_turns: int = 10,
72
72
  context: AgentContext | None = None,
73
73
  max_iterations: int | None = None,
74
+ user_message: str | None = None,
74
75
  ) -> None:
75
76
  """
76
- Run agent in streaming mode using agent.iter() with usage limits.
77
+ Run agent in streaming mode using the SAME code path as the API.
77
78
 
78
- Design Pattern:
79
- - Use agent.iter() for complete execution with tool call visibility
80
- - run_stream() stops after first output, missing tool calls
81
- - Stream tool call markers: [Calling: tool_name]
82
- - Stream text content deltas as they arrive
83
- - Show final structured result
79
+ This uses stream_openai_response_with_save from the API to ensure:
80
+ 1. Tool calls are saved as separate "tool" messages (not embedded in content)
81
+ 2. Assistant response is clean text only (no [Calling: ...] markers)
82
+ 3. CLI testing is equivalent to API testing
83
+
84
+ The CLI displays tool calls as [Calling: tool_name] for visibility,
85
+ but these are NOT saved to the database.
84
86
 
85
87
  Args:
86
88
  agent: Pydantic AI agent
@@ -88,88 +90,66 @@ async def run_agent_streaming(
88
90
  max_turns: Maximum turns for agent execution (not used in current API)
89
91
  context: Optional AgentContext for session persistence
90
92
  max_iterations: Maximum iterations/requests (from agent schema or settings)
93
+ user_message: The user's original message (for database storage)
91
94
  """
92
- from pydantic_ai import UsageLimits
93
- from rem.utils.date_utils import to_iso_with_z, utc_now
95
+ import json
96
+ from rem.api.routers.chat.streaming import stream_openai_response_with_save, save_user_message
94
97
 
95
98
  logger.info("Running agent in streaming mode...")
96
99
 
97
100
  try:
98
- # Import event types for streaming
99
- from pydantic_ai import Agent as PydanticAgent
100
- from pydantic_ai.messages import PartStartEvent, PartDeltaEvent, TextPartDelta, ToolCallPart
101
-
102
- # Accumulate assistant response for session persistence
103
- assistant_response_parts = []
104
-
105
- # Use agent.iter() to get complete execution with tool calls
106
- usage_limits = UsageLimits(request_limit=max_iterations) if max_iterations else None
107
- async with agent.iter(prompt, usage_limits=usage_limits) as agent_run:
108
- async for node in agent_run:
109
- # Check if this is a model request node (includes tool calls and text)
110
- if PydanticAgent.is_model_request_node(node):
111
- # Stream events from model request
112
- request_stream: Any
113
- async with node.stream(agent_run.ctx) as request_stream:
114
- async for event in request_stream:
115
- # Tool call start event
116
- if isinstance(event, PartStartEvent) and isinstance(
117
- event.part, ToolCallPart
118
- ):
119
- tool_marker = f"\n[Calling: {event.part.tool_name}]"
120
- print(tool_marker, flush=True)
121
- assistant_response_parts.append(tool_marker)
122
-
123
- # Text content delta
124
- elif isinstance(event, PartDeltaEvent) and isinstance(
125
- event.delta, TextPartDelta
126
- ):
127
- print(event.delta.content_delta, end="", flush=True)
128
- assistant_response_parts.append(event.delta.content_delta)
129
-
130
- print("\n") # Final newline after streaming
131
-
132
- # Get final result from agent_run
133
- result = agent_run.result
134
- if hasattr(result, "output"):
135
- logger.info("Final structured result:")
136
- output = result.output
137
- from rem.agentic.serialization import serialize_agent_result
138
- output_json = json.dumps(serialize_agent_result(output), indent=2)
139
- print(output_json)
140
- assistant_response_parts.append(f"\n{output_json}")
141
-
142
- # Save session messages (if session_id provided and postgres enabled)
143
- if context and context.session_id and settings.postgres.enabled:
144
- from ...services.session.compression import SessionMessageStore
145
-
146
- # Extract just the user query from prompt
147
- # Prompt format from ContextBuilder: system + history + user message
148
- # We need to extract the last user message
149
- user_message_content = prompt.split("\n\n")[-1] if "\n\n" in prompt else prompt
150
-
151
- user_message = {
152
- "role": "user",
153
- "content": user_message_content,
154
- "timestamp": to_iso_with_z(utc_now()),
155
- }
156
-
157
- assistant_message = {
158
- "role": "assistant",
159
- "content": "".join(assistant_response_parts),
160
- "timestamp": to_iso_with_z(utc_now()),
161
- }
162
-
163
- # Store messages with compression
164
- store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
165
- await store.store_session_messages(
101
+ # Save user message BEFORE streaming (same as API, using shared utility)
102
+ if context and context.session_id and user_message:
103
+ await save_user_message(
166
104
  session_id=context.session_id,
167
- messages=[user_message, assistant_message],
168
105
  user_id=context.user_id,
169
- compress=True,
106
+ content=user_message,
170
107
  )
171
108
 
172
- logger.debug(f"Saved conversation to session {context.session_id}")
109
+ # Use the API streaming code path for consistency
110
+ # This properly handles tool calls and message persistence
111
+ model_name = getattr(agent, 'model', 'unknown')
112
+ if hasattr(model_name, 'model_name'):
113
+ model_name = model_name.model_name
114
+ elif hasattr(model_name, 'name'):
115
+ model_name = model_name.name
116
+ else:
117
+ model_name = str(model_name)
118
+
119
+ async for chunk in stream_openai_response_with_save(
120
+ agent=agent.agent if hasattr(agent, 'agent') else agent,
121
+ prompt=prompt,
122
+ model=model_name,
123
+ session_id=context.session_id if context else None,
124
+ user_id=context.user_id if context else None,
125
+ agent_context=context,
126
+ ):
127
+ # Parse SSE chunks for CLI display
128
+ if chunk.startswith("event: tool_call"):
129
+ # Extract tool call info from next data line
130
+ continue
131
+ elif chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
132
+ try:
133
+ data_str = chunk[6:].strip()
134
+ if data_str:
135
+ data = json.loads(data_str)
136
+ # Check for tool_call event
137
+ if data.get("type") == "tool_call":
138
+ tool_name = data.get("tool_name", "tool")
139
+ status = data.get("status", "")
140
+ if status == "started":
141
+ print(f"\n[Calling: {tool_name}]", flush=True)
142
+ # Check for text content (OpenAI format)
143
+ elif "choices" in data and data["choices"]:
144
+ delta = data["choices"][0].get("delta", {})
145
+ content = delta.get("content")
146
+ if content:
147
+ print(content, end="", flush=True)
148
+ except (json.JSONDecodeError, KeyError, IndexError):
149
+ pass
150
+
151
+ print("\n") # Final newline after streaming
152
+ logger.info("Final structured result:")
173
153
 
174
154
  except Exception as e:
175
155
  logger.error(f"Agent execution failed: {e}")
@@ -549,7 +529,7 @@ async def _ask_async(
549
529
 
550
530
  # Run agent with session persistence
551
531
  if stream:
552
- await run_agent_streaming(agent, prompt, max_turns=max_turns, context=context)
532
+ await run_agent_streaming(agent, prompt, max_turns=max_turns, context=context, user_message=query)
553
533
  else:
554
534
  await run_agent_non_streaming(
555
535
  agent,
@@ -206,9 +206,9 @@ def process_ingest(
206
206
  if category:
207
207
  entity_data["category"] = category
208
208
 
209
- # Scoping: user_id for private data, None for public/shared
210
- # tenant_id=None and user_id=None means PUBLIC data (visible to all)
211
- entity_data["tenant_id"] = user_id # None = public/shared
209
+ # Scoping: user_id for private data, "public" for shared
210
+ # tenant_id="public" is the default for shared knowledge bases
211
+ entity_data["tenant_id"] = user_id or "public"
212
212
  entity_data["user_id"] = user_id # None = public/shared
213
213
 
214
214
  # For ontologies, add URI
@@ -124,7 +124,7 @@ json_schema_extra:
124
124
 
125
125
  # Explicit resource declarations for reference data
126
126
  resources:
127
- - uri: rem://schemas
127
+ - uri: rem://agents
128
128
  name: Agent Schemas List
129
129
  description: List all available agent schemas in the system
130
130
  - uri: rem://status
@@ -31,17 +31,27 @@ if TYPE_CHECKING:
31
31
  from .service import PostgresService
32
32
 
33
33
 
34
+ # Singleton instance for connection pool reuse
35
+ _postgres_instance: "PostgresService | None" = None
36
+
37
+
34
38
  def get_postgres_service() -> "PostgresService | None":
35
39
  """
36
- Get PostgresService instance with connection string from settings.
40
+ Get PostgresService singleton instance.
37
41
 
38
42
  Returns None if Postgres is disabled.
43
+ Uses singleton pattern to prevent connection pool exhaustion.
39
44
  """
45
+ global _postgres_instance
46
+
40
47
  if not settings.postgres.enabled:
41
48
  return None
42
-
43
- from .service import PostgresService
44
- return PostgresService()
49
+
50
+ if _postgres_instance is None:
51
+ from .service import PostgresService
52
+ _postgres_instance = PostgresService()
53
+
54
+ return _postgres_instance
45
55
 
46
56
  T = TypeVar("T", bound=BaseModel)
47
57
 
@@ -1,12 +1,13 @@
1
1
  """Session management services for conversation persistence and compression."""
2
2
 
3
3
  from .compression import MessageCompressor, SessionMessageStore
4
- from .pydantic_messages import session_to_pydantic_messages
4
+ from .pydantic_messages import audit_session_history, session_to_pydantic_messages
5
5
  from .reload import reload_session
6
6
 
7
7
  __all__ = [
8
8
  "MessageCompressor",
9
9
  "SessionMessageStore",
10
+ "audit_session_history",
10
11
  "reload_session",
11
12
  "session_to_pydantic_messages",
12
13
  ]
@@ -188,21 +188,19 @@ class SessionMessageStore:
188
188
  Ensure session exists, creating it if necessary.
189
189
 
190
190
  Args:
191
- session_id: Session identifier (maps to Session.name)
191
+ session_id: Session UUID from X-Session-Id header
192
192
  user_id: Optional user identifier
193
193
  """
194
194
  try:
195
- # Check if session already exists by name
196
- existing = await self._session_repo.find(
197
- filters={"name": session_id},
198
- limit=1,
199
- )
195
+ # Check if session already exists by UUID
196
+ existing = await self._session_repo.get_by_id(session_id)
200
197
  if existing:
201
198
  return # Session already exists
202
199
 
203
- # Create new session
200
+ # Create new session with the provided UUID as id
204
201
  session = Session(
205
- name=session_id,
202
+ id=session_id, # Use the provided UUID as session id
203
+ name=session_id, # Default name to UUID, can be updated later
206
204
  user_id=user_id or self.user_id,
207
205
  tenant_id=self.user_id, # tenant_id set to user_id for scoping
208
206
  )
@@ -321,7 +319,7 @@ class SessionMessageStore:
321
319
  Ensures session exists before storing messages.
322
320
 
323
321
  Args:
324
- session_id: Session identifier (maps to Session.name)
322
+ session_id: Session UUID
325
323
  messages: List of messages to store
326
324
  user_id: Optional user identifier
327
325
  compress: Whether to compress messages (default: True)
@@ -208,3 +208,69 @@ def session_to_pydantic_messages(
208
208
 
209
209
  logger.debug(f"Converted {len(session_history)} stored messages to {len(messages)} pydantic-ai messages")
210
210
  return messages
211
+
212
+
213
+ def audit_session_history(
214
+ session_id: str,
215
+ agent_name: str,
216
+ prompt: str,
217
+ raw_session_history: list[dict[str, Any]],
218
+ pydantic_messages_count: int,
219
+ ) -> None:
220
+ """
221
+ Dump session history to a YAML file for debugging.
222
+
223
+ Only runs when DEBUG__AUDIT_SESSION=true. Writes to DEBUG__AUDIT_DIR (default /tmp).
224
+ Appends to the same file for a session, so all agent invocations are in one place.
225
+
226
+ Args:
227
+ session_id: The session identifier
228
+ agent_name: Name of the agent being invoked
229
+ prompt: The prompt being sent to the agent
230
+ raw_session_history: The raw session messages from the database
231
+ pydantic_messages_count: Count of converted pydantic-ai messages
232
+ """
233
+ from ...settings import settings
234
+
235
+ if not settings.debug.audit_session:
236
+ return
237
+
238
+ try:
239
+ import yaml
240
+ from pathlib import Path
241
+ from ...utils.date_utils import utc_now, to_iso
242
+
243
+ audit_dir = Path(settings.debug.audit_dir)
244
+ audit_dir.mkdir(parents=True, exist_ok=True)
245
+ audit_file = audit_dir / f"{session_id}.yaml"
246
+
247
+ # Create entry for this agent invocation
248
+ entry = {
249
+ "timestamp": to_iso(utc_now()),
250
+ "agent_name": agent_name,
251
+ "prompt": prompt,
252
+ "raw_history_count": len(raw_session_history),
253
+ "pydantic_messages_count": pydantic_messages_count,
254
+ "raw_session_history": raw_session_history,
255
+ }
256
+
257
+ # Load existing data or create new
258
+ existing_data: dict[str, Any] = {"session_id": session_id, "invocations": []}
259
+ if audit_file.exists():
260
+ with open(audit_file) as f:
261
+ loaded = yaml.safe_load(f)
262
+ if loaded:
263
+ # Ensure session_id is always present (backfill if missing)
264
+ existing_data = {
265
+ "session_id": loaded.get("session_id", session_id),
266
+ "invocations": loaded.get("invocations", []),
267
+ }
268
+
269
+ # Append this invocation
270
+ existing_data["invocations"].append(entry)
271
+
272
+ with open(audit_file, "w") as f:
273
+ yaml.dump(existing_data, f, default_flow_style=False, allow_unicode=True)
274
+ logger.info(f"DEBUG: Session audit updated: {audit_file}")
275
+ except Exception as e:
276
+ logger.warning(f"DEBUG: Failed to dump session audit: {e}")