remdb 0.3.181__py3-none-any.whl → 0.3.223__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 +173 -0
- rem/agentic/context_builder.py +12 -2
- rem/agentic/mcp/tool_wrapper.py +2 -2
- rem/agentic/providers/pydantic_ai.py +1 -1
- rem/agentic/schema.py +2 -2
- rem/api/main.py +1 -1
- rem/api/mcp_router/server.py +4 -0
- rem/api/mcp_router/tools.py +542 -170
- rem/api/routers/admin.py +30 -4
- rem/api/routers/auth.py +106 -10
- rem/api/routers/chat/completions.py +66 -18
- rem/api/routers/chat/sse_events.py +7 -3
- rem/api/routers/chat/streaming.py +254 -22
- 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 +176 -38
- 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/auth/middleware.py +42 -28
- rem/cli/README.md +62 -0
- rem/cli/commands/db.py +33 -19
- rem/cli/commands/process.py +171 -43
- rem/models/entities/ontology.py +18 -20
- rem/schemas/agents/rem.yaml +1 -1
- rem/services/content/service.py +18 -5
- rem/services/postgres/__init__.py +28 -3
- rem/services/postgres/diff_service.py +57 -5
- rem/services/postgres/programmable_diff_service.py +635 -0
- rem/services/postgres/pydantic_to_sqlalchemy.py +2 -2
- rem/services/postgres/register_type.py +11 -10
- rem/services/postgres/repository.py +14 -4
- rem/services/session/__init__.py +8 -1
- rem/services/session/compression.py +40 -2
- rem/services/session/pydantic_messages.py +276 -0
- rem/settings.py +28 -0
- rem/sql/migrations/001_install.sql +125 -7
- rem/sql/migrations/002_install_models.sql +136 -126
- rem/sql/migrations/004_cache_system.sql +7 -275
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/schema_loader.py +6 -6
- {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/METADATA +1 -1
- {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/RECORD +48 -44
- {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/WHEEL +0 -0
- {remdb-0.3.181.dist-info → remdb-0.3.223.dist-info}/entry_points.txt +0 -0
|
@@ -94,14 +94,14 @@ def generate_table_schema(
|
|
|
94
94
|
# Always add id as primary key
|
|
95
95
|
columns.append("id UUID PRIMARY KEY DEFAULT uuid_generate_v4()")
|
|
96
96
|
|
|
97
|
-
# Add tenant_id if tenant scoped
|
|
97
|
+
# Add tenant_id if tenant scoped (nullable - NULL means public/shared)
|
|
98
98
|
if tenant_scoped:
|
|
99
|
-
columns.append("tenant_id VARCHAR(100)
|
|
100
|
-
indexes.append(f"CREATE INDEX idx_{table_name}_tenant ON {table_name} (tenant_id);")
|
|
99
|
+
columns.append("tenant_id VARCHAR(100)")
|
|
100
|
+
indexes.append(f"CREATE INDEX IF NOT EXISTS idx_{table_name}_tenant ON {table_name} (tenant_id);")
|
|
101
101
|
|
|
102
102
|
# Add user_id (owner field)
|
|
103
103
|
columns.append("user_id VARCHAR(256)")
|
|
104
|
-
indexes.append(f"CREATE INDEX idx_{table_name}_user ON {table_name} (user_id);")
|
|
104
|
+
indexes.append(f"CREATE INDEX IF NOT EXISTS idx_{table_name}_user ON {table_name} (user_id);")
|
|
105
105
|
|
|
106
106
|
# Process Pydantic fields (skip system fields)
|
|
107
107
|
for field_name, field_info in model.model_fields.items():
|
|
@@ -125,19 +125,19 @@ def generate_table_schema(
|
|
|
125
125
|
# Add graph_edges JSONB field
|
|
126
126
|
columns.append("graph_edges JSONB DEFAULT '[]'::jsonb")
|
|
127
127
|
indexes.append(
|
|
128
|
-
f"CREATE INDEX idx_{table_name}_graph_edges ON {table_name} USING GIN (graph_edges);"
|
|
128
|
+
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_graph_edges ON {table_name} USING GIN (graph_edges);"
|
|
129
129
|
)
|
|
130
130
|
|
|
131
131
|
# Add metadata JSONB field
|
|
132
132
|
columns.append("metadata JSONB DEFAULT '{}'::jsonb")
|
|
133
133
|
indexes.append(
|
|
134
|
-
f"CREATE INDEX idx_{table_name}_metadata ON {table_name} USING GIN (metadata);"
|
|
134
|
+
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_metadata ON {table_name} USING GIN (metadata);"
|
|
135
135
|
)
|
|
136
136
|
|
|
137
137
|
# Add tags field (TEXT[] for list[str])
|
|
138
138
|
columns.append("tags TEXT[] DEFAULT ARRAY[]::TEXT[]")
|
|
139
139
|
indexes.append(
|
|
140
|
-
f"CREATE INDEX idx_{table_name}_tags ON {table_name} USING GIN (tags);"
|
|
140
|
+
f"CREATE INDEX IF NOT EXISTS idx_{table_name}_tags ON {table_name} USING GIN (tags);"
|
|
141
141
|
)
|
|
142
142
|
|
|
143
143
|
# Generate CREATE TABLE statement
|
|
@@ -202,10 +202,10 @@ CREATE TABLE IF NOT EXISTS {embeddings_table} (
|
|
|
202
202
|
);
|
|
203
203
|
|
|
204
204
|
-- Index for entity lookup (get all embeddings for entity)
|
|
205
|
-
CREATE INDEX idx_{embeddings_table}_entity ON {embeddings_table} (entity_id);
|
|
205
|
+
CREATE INDEX IF NOT EXISTS idx_{embeddings_table}_entity ON {embeddings_table} (entity_id);
|
|
206
206
|
|
|
207
207
|
-- Index for field + provider lookup
|
|
208
|
-
CREATE INDEX idx_{embeddings_table}_field_provider ON {embeddings_table} (field_name, provider);
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_{embeddings_table}_field_provider ON {embeddings_table} (field_name, provider);
|
|
209
209
|
|
|
210
210
|
-- HNSW index for vector similarity search (created in background)
|
|
211
211
|
-- Note: This will be created by background thread after data load
|
|
@@ -258,6 +258,7 @@ BEGIN
|
|
|
258
258
|
RETURN OLD;
|
|
259
259
|
ELSIF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN
|
|
260
260
|
-- Upsert to KV_STORE (O(1) lookup by entity_key)
|
|
261
|
+
-- tenant_id can be NULL (meaning public/shared data)
|
|
261
262
|
INSERT INTO kv_store (
|
|
262
263
|
entity_key,
|
|
263
264
|
entity_type,
|
|
@@ -277,7 +278,7 @@ BEGIN
|
|
|
277
278
|
COALESCE(NEW.graph_edges, '[]'::jsonb),
|
|
278
279
|
CURRENT_TIMESTAMP
|
|
279
280
|
)
|
|
280
|
-
ON CONFLICT (tenant_id, entity_key)
|
|
281
|
+
ON CONFLICT (COALESCE(tenant_id, ''), entity_key)
|
|
281
282
|
DO UPDATE SET
|
|
282
283
|
entity_id = EXCLUDED.entity_id,
|
|
283
284
|
user_id = EXCLUDED.user_id,
|
|
@@ -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
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
rem/services/session/__init__.py
CHANGED
|
@@ -1,6 +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 audit_session_history, session_to_pydantic_messages
|
|
4
5
|
from .reload import reload_session
|
|
5
6
|
|
|
6
|
-
__all__ = [
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MessageCompressor",
|
|
9
|
+
"SessionMessageStore",
|
|
10
|
+
"audit_session_history",
|
|
11
|
+
"reload_session",
|
|
12
|
+
"session_to_pydantic_messages",
|
|
13
|
+
]
|
|
@@ -65,7 +65,7 @@ def truncate_key(key: str, max_length: int = MAX_ENTITY_KEY_LENGTH) -> str:
|
|
|
65
65
|
logger.warning(f"Truncated key from {len(key)} to {len(truncated)} chars: {key[:50]}...")
|
|
66
66
|
return truncated
|
|
67
67
|
|
|
68
|
-
from rem.models.entities import Message
|
|
68
|
+
from rem.models.entities import Message, Session
|
|
69
69
|
from rem.services.postgres import PostgresService, Repository
|
|
70
70
|
from rem.settings import settings
|
|
71
71
|
|
|
@@ -177,6 +177,39 @@ class SessionMessageStore:
|
|
|
177
177
|
self.user_id = user_id
|
|
178
178
|
self.compressor = compressor or MessageCompressor()
|
|
179
179
|
self.repo = Repository(Message)
|
|
180
|
+
self._session_repo = Repository(Session, table_name="sessions")
|
|
181
|
+
|
|
182
|
+
async def _ensure_session_exists(
|
|
183
|
+
self,
|
|
184
|
+
session_id: str,
|
|
185
|
+
user_id: str | None = None,
|
|
186
|
+
) -> None:
|
|
187
|
+
"""
|
|
188
|
+
Ensure session exists, creating it if necessary.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
session_id: Session UUID from X-Session-Id header
|
|
192
|
+
user_id: Optional user identifier
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
# Check if session already exists by UUID
|
|
196
|
+
existing = await self._session_repo.get_by_id(session_id)
|
|
197
|
+
if existing:
|
|
198
|
+
return # Session already exists
|
|
199
|
+
|
|
200
|
+
# Create new session with the provided UUID as id
|
|
201
|
+
session = Session(
|
|
202
|
+
id=session_id, # Use the provided UUID as session id
|
|
203
|
+
name=session_id, # Default name to UUID, can be updated later
|
|
204
|
+
user_id=user_id or self.user_id,
|
|
205
|
+
tenant_id=self.user_id, # tenant_id set to user_id for scoping
|
|
206
|
+
)
|
|
207
|
+
await self._session_repo.upsert(session)
|
|
208
|
+
logger.info(f"Created session {session_id} for user {user_id or self.user_id}")
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
# Log but don't fail - session creation is best-effort
|
|
212
|
+
logger.warning(f"Failed to ensure session exists: {e}")
|
|
180
213
|
|
|
181
214
|
async def store_message(
|
|
182
215
|
self,
|
|
@@ -283,8 +316,10 @@ class SessionMessageStore:
|
|
|
283
316
|
"""
|
|
284
317
|
Store all session messages and return compressed versions.
|
|
285
318
|
|
|
319
|
+
Ensures session exists before storing messages.
|
|
320
|
+
|
|
286
321
|
Args:
|
|
287
|
-
session_id: Session
|
|
322
|
+
session_id: Session UUID
|
|
288
323
|
messages: List of messages to store
|
|
289
324
|
user_id: Optional user identifier
|
|
290
325
|
compress: Whether to compress messages (default: True)
|
|
@@ -296,6 +331,9 @@ class SessionMessageStore:
|
|
|
296
331
|
logger.debug("Postgres disabled, returning messages uncompressed")
|
|
297
332
|
return messages
|
|
298
333
|
|
|
334
|
+
# Ensure session exists before storing messages
|
|
335
|
+
await self._ensure_session_exists(session_id, user_id)
|
|
336
|
+
|
|
299
337
|
compressed_messages = []
|
|
300
338
|
|
|
301
339
|
for idx, message in enumerate(messages):
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Convert stored session messages to pydantic-ai native message format.
|
|
2
|
+
|
|
3
|
+
This module enables proper conversation history replay by converting our simplified
|
|
4
|
+
storage format into pydantic-ai's native ModelRequest/ModelResponse types.
|
|
5
|
+
|
|
6
|
+
Key insight: When we store tool results, we only store the result (ToolReturnPart).
|
|
7
|
+
But LLM APIs require matching ToolCallPart for each ToolReturnPart. So we synthesize
|
|
8
|
+
the ToolCallPart from stored metadata (tool_name, tool_call_id, tool_arguments).
|
|
9
|
+
|
|
10
|
+
Storage format (our simplified format):
|
|
11
|
+
{"role": "user", "content": "..."}
|
|
12
|
+
{"role": "assistant", "content": "..."}
|
|
13
|
+
{"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}}
|
|
14
|
+
|
|
15
|
+
Pydantic-ai format (what the LLM expects):
|
|
16
|
+
ModelRequest(parts=[UserPromptPart(content="...")])
|
|
17
|
+
ModelResponse(parts=[TextPart(content="..."), ToolCallPart(...)]) # Call
|
|
18
|
+
ModelRequest(parts=[ToolReturnPart(...)]) # Result
|
|
19
|
+
|
|
20
|
+
Example usage:
|
|
21
|
+
from rem.services.session.pydantic_messages import session_to_pydantic_messages
|
|
22
|
+
|
|
23
|
+
# Load session history
|
|
24
|
+
session_history = await store.load_session_messages(session_id)
|
|
25
|
+
|
|
26
|
+
# Convert to pydantic-ai format
|
|
27
|
+
message_history = session_to_pydantic_messages(session_history)
|
|
28
|
+
|
|
29
|
+
# Use with agent.run()
|
|
30
|
+
result = await agent.run(user_prompt, message_history=message_history)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from loguru import logger
|
|
37
|
+
from pydantic_ai.messages import (
|
|
38
|
+
ModelMessage,
|
|
39
|
+
ModelRequest,
|
|
40
|
+
ModelResponse,
|
|
41
|
+
SystemPromptPart,
|
|
42
|
+
TextPart,
|
|
43
|
+
ToolCallPart,
|
|
44
|
+
ToolReturnPart,
|
|
45
|
+
UserPromptPart,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def session_to_pydantic_messages(
|
|
50
|
+
session_history: list[dict[str, Any]],
|
|
51
|
+
system_prompt: str | None = None,
|
|
52
|
+
) -> list[ModelMessage]:
|
|
53
|
+
"""Convert stored session messages to pydantic-ai ModelMessage format.
|
|
54
|
+
|
|
55
|
+
Handles the conversion of our simplified storage format to pydantic-ai's
|
|
56
|
+
native message types, including synthesizing ToolCallPart for tool results.
|
|
57
|
+
|
|
58
|
+
IMPORTANT: pydantic-ai only auto-adds system prompts when message_history is empty.
|
|
59
|
+
When passing message_history to agent.run(), you MUST include the system prompt
|
|
60
|
+
via the system_prompt parameter here.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
session_history: List of message dicts from SessionMessageStore.load_session_messages()
|
|
64
|
+
Each dict has: role, content, and optionally tool_name, tool_call_id, tool_arguments
|
|
65
|
+
system_prompt: The agent's system prompt (from schema description). This is REQUIRED
|
|
66
|
+
for proper agent behavior on subsequent turns, as pydantic-ai won't add it
|
|
67
|
+
automatically when message_history is provided.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
List of ModelMessage (ModelRequest | ModelResponse) ready for agent.run(message_history=...)
|
|
71
|
+
|
|
72
|
+
Note:
|
|
73
|
+
- System prompts ARE included as SystemPromptPart when system_prompt is provided
|
|
74
|
+
- Tool results require synthesized ToolCallPart to satisfy LLM API requirements
|
|
75
|
+
- The first message in session_history should be "user" role (from context builder)
|
|
76
|
+
"""
|
|
77
|
+
messages: list[ModelMessage] = []
|
|
78
|
+
|
|
79
|
+
# CRITICAL: Prepend agent's system prompt if provided
|
|
80
|
+
# This ensures the agent's instructions are present on every turn
|
|
81
|
+
# pydantic-ai only auto-adds system prompts when message_history is empty
|
|
82
|
+
if system_prompt:
|
|
83
|
+
messages.append(ModelRequest(parts=[SystemPromptPart(content=system_prompt)]))
|
|
84
|
+
logger.debug(f"Prepended agent system prompt ({len(system_prompt)} chars) to message history")
|
|
85
|
+
|
|
86
|
+
# Track pending tool results to batch them with assistant responses
|
|
87
|
+
# When we see a tool message, we need to:
|
|
88
|
+
# 1. Add a ModelResponse with ToolCallPart (synthesized)
|
|
89
|
+
# 2. Add a ModelRequest with ToolReturnPart (actual result)
|
|
90
|
+
|
|
91
|
+
i = 0
|
|
92
|
+
while i < len(session_history):
|
|
93
|
+
msg = session_history[i]
|
|
94
|
+
role = msg.get("role", "")
|
|
95
|
+
content = msg.get("content", "")
|
|
96
|
+
|
|
97
|
+
if role == "user":
|
|
98
|
+
# User messages become ModelRequest with UserPromptPart
|
|
99
|
+
messages.append(ModelRequest(parts=[UserPromptPart(content=content)]))
|
|
100
|
+
|
|
101
|
+
elif role == "assistant":
|
|
102
|
+
# Assistant text becomes ModelResponse with TextPart
|
|
103
|
+
# Check if there are following tool messages that should be grouped
|
|
104
|
+
tool_calls = []
|
|
105
|
+
tool_returns = []
|
|
106
|
+
|
|
107
|
+
# Look ahead for tool messages that follow this assistant message
|
|
108
|
+
j = i + 1
|
|
109
|
+
while j < len(session_history) and session_history[j].get("role") == "tool":
|
|
110
|
+
tool_msg = session_history[j]
|
|
111
|
+
tool_name = tool_msg.get("tool_name", "unknown_tool")
|
|
112
|
+
tool_call_id = tool_msg.get("tool_call_id", f"call_{j}")
|
|
113
|
+
tool_arguments = tool_msg.get("tool_arguments", {})
|
|
114
|
+
tool_content = tool_msg.get("content", "{}")
|
|
115
|
+
|
|
116
|
+
# Parse tool content if it's a JSON string
|
|
117
|
+
if isinstance(tool_content, str):
|
|
118
|
+
try:
|
|
119
|
+
tool_result = json.loads(tool_content)
|
|
120
|
+
except json.JSONDecodeError:
|
|
121
|
+
tool_result = {"raw": tool_content}
|
|
122
|
+
else:
|
|
123
|
+
tool_result = tool_content
|
|
124
|
+
|
|
125
|
+
# Synthesize ToolCallPart (what the model "called")
|
|
126
|
+
tool_calls.append(ToolCallPart(
|
|
127
|
+
tool_name=tool_name,
|
|
128
|
+
args=tool_arguments if tool_arguments else {},
|
|
129
|
+
tool_call_id=tool_call_id,
|
|
130
|
+
))
|
|
131
|
+
|
|
132
|
+
# Create ToolReturnPart (the actual result)
|
|
133
|
+
tool_returns.append(ToolReturnPart(
|
|
134
|
+
tool_name=tool_name,
|
|
135
|
+
content=tool_result,
|
|
136
|
+
tool_call_id=tool_call_id,
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
j += 1
|
|
140
|
+
|
|
141
|
+
# Build the assistant's ModelResponse
|
|
142
|
+
response_parts = []
|
|
143
|
+
|
|
144
|
+
# Add tool calls first (if any)
|
|
145
|
+
response_parts.extend(tool_calls)
|
|
146
|
+
|
|
147
|
+
# Add text content (if any)
|
|
148
|
+
if content:
|
|
149
|
+
response_parts.append(TextPart(content=content))
|
|
150
|
+
|
|
151
|
+
# Only add ModelResponse if we have parts
|
|
152
|
+
if response_parts:
|
|
153
|
+
messages.append(ModelResponse(
|
|
154
|
+
parts=response_parts,
|
|
155
|
+
model_name="recovered", # We don't store model name
|
|
156
|
+
))
|
|
157
|
+
|
|
158
|
+
# Add tool returns as ModelRequest (required by LLM API)
|
|
159
|
+
if tool_returns:
|
|
160
|
+
messages.append(ModelRequest(parts=tool_returns))
|
|
161
|
+
|
|
162
|
+
# Skip the tool messages we just processed
|
|
163
|
+
i = j - 1
|
|
164
|
+
|
|
165
|
+
elif role == "tool":
|
|
166
|
+
# Orphan tool message (no preceding assistant) - synthesize both parts
|
|
167
|
+
tool_name = msg.get("tool_name", "unknown_tool")
|
|
168
|
+
tool_call_id = msg.get("tool_call_id", f"call_{i}")
|
|
169
|
+
tool_arguments = msg.get("tool_arguments", {})
|
|
170
|
+
tool_content = msg.get("content", "{}")
|
|
171
|
+
|
|
172
|
+
# Parse tool content
|
|
173
|
+
if isinstance(tool_content, str):
|
|
174
|
+
try:
|
|
175
|
+
tool_result = json.loads(tool_content)
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
tool_result = {"raw": tool_content}
|
|
178
|
+
else:
|
|
179
|
+
tool_result = tool_content
|
|
180
|
+
|
|
181
|
+
# Synthesize the tool call (ModelResponse with ToolCallPart)
|
|
182
|
+
messages.append(ModelResponse(
|
|
183
|
+
parts=[ToolCallPart(
|
|
184
|
+
tool_name=tool_name,
|
|
185
|
+
args=tool_arguments if tool_arguments else {},
|
|
186
|
+
tool_call_id=tool_call_id,
|
|
187
|
+
)],
|
|
188
|
+
model_name="recovered",
|
|
189
|
+
))
|
|
190
|
+
|
|
191
|
+
# Add the tool return (ModelRequest with ToolReturnPart)
|
|
192
|
+
messages.append(ModelRequest(
|
|
193
|
+
parts=[ToolReturnPart(
|
|
194
|
+
tool_name=tool_name,
|
|
195
|
+
content=tool_result,
|
|
196
|
+
tool_call_id=tool_call_id,
|
|
197
|
+
)]
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
elif role == "system":
|
|
201
|
+
# Skip system messages - pydantic-ai handles these via Agent.system_prompt
|
|
202
|
+
logger.debug("Skipping system message in session history (handled by Agent)")
|
|
203
|
+
|
|
204
|
+
else:
|
|
205
|
+
logger.warning(f"Unknown message role in session history: {role}")
|
|
206
|
+
|
|
207
|
+
i += 1
|
|
208
|
+
|
|
209
|
+
logger.debug(f"Converted {len(session_history)} stored messages to {len(messages)} pydantic-ai messages")
|
|
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}")
|
rem/settings.py
CHANGED
|
@@ -1651,6 +1651,33 @@ class EmailSettings(BaseSettings):
|
|
|
1651
1651
|
return kwargs
|
|
1652
1652
|
|
|
1653
1653
|
|
|
1654
|
+
class DebugSettings(BaseSettings):
|
|
1655
|
+
"""
|
|
1656
|
+
Debug settings for development and troubleshooting.
|
|
1657
|
+
|
|
1658
|
+
Environment variables:
|
|
1659
|
+
DEBUG__AUDIT_SESSION - Dump session history to /tmp/{session_id}.yaml
|
|
1660
|
+
DEBUG__AUDIT_DIR - Directory for session audit files (default: /tmp)
|
|
1661
|
+
"""
|
|
1662
|
+
|
|
1663
|
+
model_config = SettingsConfigDict(
|
|
1664
|
+
env_prefix="DEBUG__",
|
|
1665
|
+
env_file=".env",
|
|
1666
|
+
env_file_encoding="utf-8",
|
|
1667
|
+
extra="ignore",
|
|
1668
|
+
)
|
|
1669
|
+
|
|
1670
|
+
audit_session: bool = Field(
|
|
1671
|
+
default=False,
|
|
1672
|
+
description="When true, dump full session history to audit files for debugging",
|
|
1673
|
+
)
|
|
1674
|
+
|
|
1675
|
+
audit_dir: str = Field(
|
|
1676
|
+
default="/tmp",
|
|
1677
|
+
description="Directory for session audit files",
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
|
|
1654
1681
|
class TestSettings(BaseSettings):
|
|
1655
1682
|
"""
|
|
1656
1683
|
Test environment settings.
|
|
@@ -1767,6 +1794,7 @@ class Settings(BaseSettings):
|
|
|
1767
1794
|
schema_search: SchemaSettings = Field(default_factory=SchemaSettings)
|
|
1768
1795
|
email: EmailSettings = Field(default_factory=EmailSettings)
|
|
1769
1796
|
test: TestSettings = Field(default_factory=TestSettings)
|
|
1797
|
+
debug: DebugSettings = Field(default_factory=DebugSettings)
|
|
1770
1798
|
|
|
1771
1799
|
|
|
1772
1800
|
# Auto-load .env file from current directory if it exists
|