remdb 0.3.202__py3-none-any.whl → 0.3.245__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 +36 -2
- rem/agentic/context.py +86 -3
- rem/agentic/context_builder.py +39 -33
- rem/agentic/mcp/tool_wrapper.py +2 -2
- rem/agentic/providers/pydantic_ai.py +68 -51
- rem/agentic/schema.py +2 -2
- rem/api/mcp_router/resources.py +223 -0
- rem/api/mcp_router/tools.py +170 -18
- rem/api/routers/admin.py +30 -4
- rem/api/routers/auth.py +175 -18
- rem/api/routers/chat/child_streaming.py +394 -0
- rem/api/routers/chat/completions.py +24 -29
- rem/api/routers/chat/sse_events.py +5 -1
- rem/api/routers/chat/streaming.py +242 -272
- rem/api/routers/chat/streaming_utils.py +327 -0
- 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 +17 -15
- rem/api/routers/shared_sessions.py +16 -0
- rem/cli/commands/ask.py +205 -114
- rem/cli/commands/process.py +12 -4
- rem/cli/commands/query.py +109 -0
- rem/cli/commands/session.py +117 -0
- rem/cli/main.py +2 -0
- rem/models/entities/session.py +1 -0
- rem/schemas/agents/rem.yaml +1 -1
- rem/services/postgres/repository.py +7 -7
- rem/services/rem/service.py +47 -0
- rem/services/session/__init__.py +2 -1
- rem/services/session/compression.py +14 -12
- rem/services/session/pydantic_messages.py +111 -11
- rem/services/session/reload.py +2 -1
- rem/settings.py +71 -0
- rem/sql/migrations/001_install.sql +4 -4
- rem/sql/migrations/004_cache_system.sql +3 -1
- rem/sql/migrations/migrate_session_id_to_uuid.sql +45 -0
- rem/utils/schema_loader.py +139 -111
- {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/METADATA +2 -2
- {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/RECORD +44 -39
- {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/WHEEL +0 -0
- {remdb-0.3.202.dist-info → remdb-0.3.245.dist-info}/entry_points.txt +0 -0
rem/cli/commands/session.py
CHANGED
|
@@ -331,6 +331,123 @@ async def _show_async(
|
|
|
331
331
|
raise
|
|
332
332
|
|
|
333
333
|
|
|
334
|
+
@session.command("clone")
|
|
335
|
+
@click.argument("session_id")
|
|
336
|
+
@click.option("--to-turn", "-t", type=int, help="Clone up to turn N (counting user messages only)")
|
|
337
|
+
@click.option("--name", "-n", help="Name/description for the cloned session")
|
|
338
|
+
def clone(session_id: str, to_turn: int | None, name: str | None):
|
|
339
|
+
"""
|
|
340
|
+
Clone a session for exploring alternate conversation paths.
|
|
341
|
+
|
|
342
|
+
SESSION_ID: The session ID to clone.
|
|
343
|
+
|
|
344
|
+
Examples:
|
|
345
|
+
|
|
346
|
+
# Clone entire session
|
|
347
|
+
rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0
|
|
348
|
+
|
|
349
|
+
# Clone up to turn 3 (first 3 user messages and their responses)
|
|
350
|
+
rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0 --to-turn 3
|
|
351
|
+
|
|
352
|
+
# Clone with a descriptive name
|
|
353
|
+
rem session clone 810f1f2d-d5a1-4c02-83b6-67040b47f7c0 -n "Alternate anxiety path"
|
|
354
|
+
"""
|
|
355
|
+
asyncio.run(_clone_async(session_id, to_turn, name))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
async def _clone_async(
|
|
359
|
+
session_id: str,
|
|
360
|
+
to_turn: int | None,
|
|
361
|
+
name: str | None,
|
|
362
|
+
):
|
|
363
|
+
"""Async implementation of clone command."""
|
|
364
|
+
from uuid import uuid4
|
|
365
|
+
from ...models.entities.session import Session, SessionMode
|
|
366
|
+
|
|
367
|
+
pg = get_postgres_service()
|
|
368
|
+
if not pg:
|
|
369
|
+
logger.error("PostgreSQL not available")
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
await pg.connect()
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
# Load original session messages
|
|
376
|
+
message_repo = Repository(Message, "messages", db=pg)
|
|
377
|
+
messages = await message_repo.find(
|
|
378
|
+
filters={"session_id": session_id},
|
|
379
|
+
order_by="created_at ASC",
|
|
380
|
+
limit=1000,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if not messages:
|
|
384
|
+
logger.error(f"No messages found for session {session_id}")
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
# If --to-turn specified, filter messages up to that turn (user messages)
|
|
388
|
+
if to_turn is not None:
|
|
389
|
+
user_count = 0
|
|
390
|
+
cutoff_idx = len(messages)
|
|
391
|
+
for idx, msg in enumerate(messages):
|
|
392
|
+
if msg.message_type == "user":
|
|
393
|
+
user_count += 1
|
|
394
|
+
if user_count > to_turn:
|
|
395
|
+
cutoff_idx = idx
|
|
396
|
+
break
|
|
397
|
+
messages = messages[:cutoff_idx]
|
|
398
|
+
logger.info(f"Cloning {len(messages)} messages (up to turn {to_turn})")
|
|
399
|
+
else:
|
|
400
|
+
logger.info(f"Cloning all {len(messages)} messages")
|
|
401
|
+
|
|
402
|
+
# Generate new session ID
|
|
403
|
+
new_session_id = str(uuid4())
|
|
404
|
+
|
|
405
|
+
# Get user_id and tenant_id from first message
|
|
406
|
+
first_msg = messages[0]
|
|
407
|
+
user_id = first_msg.user_id
|
|
408
|
+
tenant_id = first_msg.tenant_id or "default"
|
|
409
|
+
|
|
410
|
+
# Create Session record with CLONE mode and lineage
|
|
411
|
+
session_repo = Repository(Session, "sessions", db=pg)
|
|
412
|
+
new_session = Session(
|
|
413
|
+
id=uuid4(),
|
|
414
|
+
name=name or f"Clone of {session_id[:8]}",
|
|
415
|
+
mode=SessionMode.CLONE,
|
|
416
|
+
original_trace_id=session_id,
|
|
417
|
+
description=f"Cloned from session {session_id}" + (f" at turn {to_turn}" if to_turn else ""),
|
|
418
|
+
user_id=user_id,
|
|
419
|
+
tenant_id=tenant_id,
|
|
420
|
+
message_count=len(messages),
|
|
421
|
+
)
|
|
422
|
+
await session_repo.upsert(new_session)
|
|
423
|
+
logger.info(f"Created session record: {new_session.id}")
|
|
424
|
+
|
|
425
|
+
# Copy messages with new session_id
|
|
426
|
+
for msg in messages:
|
|
427
|
+
new_msg = Message(
|
|
428
|
+
id=uuid4(),
|
|
429
|
+
user_id=msg.user_id,
|
|
430
|
+
tenant_id=msg.tenant_id,
|
|
431
|
+
session_id=str(new_session.id),
|
|
432
|
+
content=msg.content,
|
|
433
|
+
message_type=msg.message_type,
|
|
434
|
+
metadata=msg.metadata,
|
|
435
|
+
)
|
|
436
|
+
await message_repo.upsert(new_msg)
|
|
437
|
+
|
|
438
|
+
click.echo(f"\n✅ Cloned session successfully!")
|
|
439
|
+
click.echo(f" Original: {session_id}")
|
|
440
|
+
click.echo(f" New: {new_session.id}")
|
|
441
|
+
click.echo(f" Messages: {len(messages)}")
|
|
442
|
+
if to_turn:
|
|
443
|
+
click.echo(f" Turns: {to_turn}")
|
|
444
|
+
click.echo(f"\nContinue this session with:")
|
|
445
|
+
click.echo(f" rem ask <agent> \"your message\" --session-id {new_session.id}")
|
|
446
|
+
|
|
447
|
+
finally:
|
|
448
|
+
await pg.disconnect()
|
|
449
|
+
|
|
450
|
+
|
|
334
451
|
def register_command(cli_group):
|
|
335
452
|
"""Register the session command group."""
|
|
336
453
|
cli_group.add_command(session)
|
rem/cli/main.py
CHANGED
|
@@ -97,6 +97,7 @@ from .commands.mcp import register_command as register_mcp_command
|
|
|
97
97
|
from .commands.scaffold import scaffold as scaffold_command
|
|
98
98
|
from .commands.cluster import register_commands as register_cluster_commands
|
|
99
99
|
from .commands.session import register_command as register_session_command
|
|
100
|
+
from .commands.query import register_command as register_query_command
|
|
100
101
|
|
|
101
102
|
register_schema_commands(schema)
|
|
102
103
|
register_db_commands(db)
|
|
@@ -107,6 +108,7 @@ register_ask_command(cli)
|
|
|
107
108
|
register_configure_command(cli)
|
|
108
109
|
register_serve_command(cli)
|
|
109
110
|
register_mcp_command(cli)
|
|
111
|
+
register_query_command(cli)
|
|
110
112
|
cli.add_command(experiments_group)
|
|
111
113
|
cli.add_command(scaffold_command)
|
|
112
114
|
register_session_command(cli)
|
rem/models/entities/session.py
CHANGED
rem/schemas/agents/rem.yaml
CHANGED
|
@@ -124,7 +124,7 @@ json_schema_extra:
|
|
|
124
124
|
|
|
125
125
|
# Explicit resource declarations for reference data
|
|
126
126
|
resources:
|
|
127
|
-
- uri: rem://
|
|
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
|
|
@@ -33,15 +33,15 @@ if TYPE_CHECKING:
|
|
|
33
33
|
|
|
34
34
|
def get_postgres_service() -> "PostgresService | None":
|
|
35
35
|
"""
|
|
36
|
-
Get PostgresService
|
|
36
|
+
Get PostgresService singleton from parent module.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Uses late import to avoid circular import issues.
|
|
39
|
+
Previously had a separate _postgres_instance here which caused
|
|
40
|
+
"pool not connected" errors due to duplicate connection pools.
|
|
39
41
|
"""
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
from .service import PostgresService
|
|
44
|
-
return PostgresService()
|
|
42
|
+
# Late import to avoid circular import (repository.py imported by __init__.py)
|
|
43
|
+
from rem.services.postgres import get_postgres_service as _get_singleton
|
|
44
|
+
return _get_singleton()
|
|
45
45
|
|
|
46
46
|
T = TypeVar("T", bound=BaseModel)
|
|
47
47
|
|
rem/services/rem/service.py
CHANGED
|
@@ -478,6 +478,53 @@ class RemService:
|
|
|
478
478
|
parser = RemQueryParser()
|
|
479
479
|
return parser.parse(query_string)
|
|
480
480
|
|
|
481
|
+
async def execute_query_string(
|
|
482
|
+
self, query_string: str, user_id: str | None = None
|
|
483
|
+
) -> dict[str, Any]:
|
|
484
|
+
"""
|
|
485
|
+
Execute a REM dialect query string directly.
|
|
486
|
+
|
|
487
|
+
This is the unified entry point for executing REM queries from both
|
|
488
|
+
the CLI and API. It handles parsing the query string, creating the
|
|
489
|
+
RemQuery model, and executing it.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
query_string: REM dialect query (e.g., 'LOOKUP "Sarah Chen"',
|
|
493
|
+
'SEARCH resources "API design"', 'SELECT * FROM users')
|
|
494
|
+
user_id: Optional user ID for query isolation
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Dict with query results and metadata:
|
|
498
|
+
- query_type: The type of query executed
|
|
499
|
+
- results: List of result rows
|
|
500
|
+
- count: Number of results
|
|
501
|
+
- Additional fields depending on query type
|
|
502
|
+
|
|
503
|
+
Raises:
|
|
504
|
+
ValueError: If the query string is invalid
|
|
505
|
+
QueryExecutionError: If query execution fails
|
|
506
|
+
|
|
507
|
+
Example:
|
|
508
|
+
>>> result = await rem_service.execute_query_string(
|
|
509
|
+
... 'LOOKUP "Sarah Chen"',
|
|
510
|
+
... user_id="user-123"
|
|
511
|
+
... )
|
|
512
|
+
>>> print(result["count"])
|
|
513
|
+
1
|
|
514
|
+
"""
|
|
515
|
+
# Parse the query string into type and parameters
|
|
516
|
+
query_type, parameters = self._parse_query_string(query_string)
|
|
517
|
+
|
|
518
|
+
# Create and validate the RemQuery model
|
|
519
|
+
rem_query = RemQuery.model_validate({
|
|
520
|
+
"query_type": query_type,
|
|
521
|
+
"parameters": parameters,
|
|
522
|
+
"user_id": user_id,
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
# Execute and return results
|
|
526
|
+
return await self.execute_query(rem_query)
|
|
527
|
+
|
|
481
528
|
async def ask_rem(
|
|
482
529
|
self, natural_query: str, tenant_id: str, llm_model: str | None = None, plan_mode: bool = False
|
|
483
530
|
) -> dict[str, Any]:
|
rem/services/session/__init__.py
CHANGED
|
@@ -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
|
]
|
|
@@ -96,7 +96,7 @@ class MessageCompressor:
|
|
|
96
96
|
Returns:
|
|
97
97
|
Compressed message dict
|
|
98
98
|
"""
|
|
99
|
-
content = message.get("content"
|
|
99
|
+
content = message.get("content") or ""
|
|
100
100
|
|
|
101
101
|
# Don't compress short messages or system messages
|
|
102
102
|
if (
|
|
@@ -188,21 +188,19 @@ class SessionMessageStore:
|
|
|
188
188
|
Ensure session exists, creating it if necessary.
|
|
189
189
|
|
|
190
190
|
Args:
|
|
191
|
-
session_id: Session
|
|
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
|
|
196
|
-
existing = await self._session_repo.
|
|
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
|
-
|
|
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
|
)
|
|
@@ -244,7 +242,7 @@ class SessionMessageStore:
|
|
|
244
242
|
# Use pre-generated id from message dict if available (for frontend feedback)
|
|
245
243
|
msg = Message(
|
|
246
244
|
id=message.get("id"), # Use pre-generated ID if provided
|
|
247
|
-
content=message.get("content"
|
|
245
|
+
content=message.get("content") or "",
|
|
248
246
|
message_type=message.get("role", "assistant"),
|
|
249
247
|
session_id=session_id,
|
|
250
248
|
tenant_id=self.user_id, # Set tenant_id to user_id (application scoped to user)
|
|
@@ -321,7 +319,7 @@ class SessionMessageStore:
|
|
|
321
319
|
Ensures session exists before storing messages.
|
|
322
320
|
|
|
323
321
|
Args:
|
|
324
|
-
session_id: Session
|
|
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)
|
|
@@ -339,7 +337,7 @@ class SessionMessageStore:
|
|
|
339
337
|
compressed_messages = []
|
|
340
338
|
|
|
341
339
|
for idx, message in enumerate(messages):
|
|
342
|
-
content = message.get("content"
|
|
340
|
+
content = message.get("content") or ""
|
|
343
341
|
|
|
344
342
|
# Only store and compress long assistant responses
|
|
345
343
|
if (
|
|
@@ -370,6 +368,8 @@ class SessionMessageStore:
|
|
|
370
368
|
}
|
|
371
369
|
|
|
372
370
|
# For tool messages, include tool call details in metadata
|
|
371
|
+
# Note: tool_arguments is stored only when provided (parent tool calls)
|
|
372
|
+
# For child tool calls (e.g., register_metadata), args are in content as JSON
|
|
373
373
|
if message.get("role") == "tool":
|
|
374
374
|
if message.get("tool_call_id"):
|
|
375
375
|
msg_metadata["tool_call_id"] = message.get("tool_call_id")
|
|
@@ -438,6 +438,8 @@ class SessionMessageStore:
|
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
# For tool messages, reconstruct tool call metadata
|
|
441
|
+
# Note: tool_arguments may be in metadata (parent calls) or parsed from
|
|
442
|
+
# content (child calls like register_metadata) by pydantic_messages.py
|
|
441
443
|
if role == "tool" and msg.metadata:
|
|
442
444
|
if msg.metadata.get("tool_call_id"):
|
|
443
445
|
msg_dict["tool_call_id"] = msg.metadata["tool_call_id"]
|
|
@@ -5,12 +5,16 @@ storage format into pydantic-ai's native ModelRequest/ModelResponse types.
|
|
|
5
5
|
|
|
6
6
|
Key insight: When we store tool results, we only store the result (ToolReturnPart).
|
|
7
7
|
But LLM APIs require matching ToolCallPart for each ToolReturnPart. So we synthesize
|
|
8
|
-
the ToolCallPart from stored metadata (tool_name, tool_call_id
|
|
8
|
+
the ToolCallPart from stored metadata (tool_name, tool_call_id) and arguments.
|
|
9
|
+
|
|
10
|
+
Tool arguments can come from two places:
|
|
11
|
+
- Parent tool calls (ask_agent): tool_arguments stored in metadata (content = result)
|
|
12
|
+
- Child tool calls (register_metadata): arguments parsed from content (content = args as JSON)
|
|
9
13
|
|
|
10
14
|
Storage format (our simplified format):
|
|
11
15
|
{"role": "user", "content": "..."}
|
|
12
16
|
{"role": "assistant", "content": "..."}
|
|
13
|
-
{"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}}
|
|
17
|
+
{"role": "tool", "content": "{...}", "tool_name": "...", "tool_call_id": "...", "tool_arguments": {...}} # optional
|
|
14
18
|
|
|
15
19
|
Pydantic-ai format (what the LLM expects):
|
|
16
20
|
ModelRequest(parts=[UserPromptPart(content="...")])
|
|
@@ -31,6 +35,7 @@ Example usage:
|
|
|
31
35
|
"""
|
|
32
36
|
|
|
33
37
|
import json
|
|
38
|
+
import re
|
|
34
39
|
from typing import Any
|
|
35
40
|
|
|
36
41
|
from loguru import logger
|
|
@@ -46,6 +51,15 @@ from pydantic_ai.messages import (
|
|
|
46
51
|
)
|
|
47
52
|
|
|
48
53
|
|
|
54
|
+
def _sanitize_tool_name(tool_name: str) -> str:
|
|
55
|
+
"""Sanitize tool name for OpenAI API compatibility.
|
|
56
|
+
|
|
57
|
+
OpenAI requires tool names to match pattern: ^[a-zA-Z0-9_-]+$
|
|
58
|
+
This replaces invalid characters (like colons) with underscores.
|
|
59
|
+
"""
|
|
60
|
+
return re.sub(r'[^a-zA-Z0-9_-]', '_', tool_name)
|
|
61
|
+
|
|
62
|
+
|
|
49
63
|
def session_to_pydantic_messages(
|
|
50
64
|
session_history: list[dict[str, Any]],
|
|
51
65
|
system_prompt: str | None = None,
|
|
@@ -92,7 +106,7 @@ def session_to_pydantic_messages(
|
|
|
92
106
|
while i < len(session_history):
|
|
93
107
|
msg = session_history[i]
|
|
94
108
|
role = msg.get("role", "")
|
|
95
|
-
content = msg.get("content"
|
|
109
|
+
content = msg.get("content") or ""
|
|
96
110
|
|
|
97
111
|
if role == "user":
|
|
98
112
|
# User messages become ModelRequest with UserPromptPart
|
|
@@ -110,8 +124,15 @@ def session_to_pydantic_messages(
|
|
|
110
124
|
tool_msg = session_history[j]
|
|
111
125
|
tool_name = tool_msg.get("tool_name", "unknown_tool")
|
|
112
126
|
tool_call_id = tool_msg.get("tool_call_id", f"call_{j}")
|
|
113
|
-
|
|
114
|
-
|
|
127
|
+
tool_content = tool_msg.get("content") or "{}"
|
|
128
|
+
|
|
129
|
+
# tool_arguments: prefer explicit field, fallback to parsing content
|
|
130
|
+
tool_arguments = tool_msg.get("tool_arguments")
|
|
131
|
+
if tool_arguments is None and isinstance(tool_content, str) and tool_content:
|
|
132
|
+
try:
|
|
133
|
+
tool_arguments = json.loads(tool_content)
|
|
134
|
+
except json.JSONDecodeError:
|
|
135
|
+
tool_arguments = {}
|
|
115
136
|
|
|
116
137
|
# Parse tool content if it's a JSON string
|
|
117
138
|
if isinstance(tool_content, str):
|
|
@@ -122,16 +143,19 @@ def session_to_pydantic_messages(
|
|
|
122
143
|
else:
|
|
123
144
|
tool_result = tool_content
|
|
124
145
|
|
|
146
|
+
# Sanitize tool name for OpenAI API compatibility
|
|
147
|
+
safe_tool_name = _sanitize_tool_name(tool_name)
|
|
148
|
+
|
|
125
149
|
# Synthesize ToolCallPart (what the model "called")
|
|
126
150
|
tool_calls.append(ToolCallPart(
|
|
127
|
-
tool_name=
|
|
151
|
+
tool_name=safe_tool_name,
|
|
128
152
|
args=tool_arguments if tool_arguments else {},
|
|
129
153
|
tool_call_id=tool_call_id,
|
|
130
154
|
))
|
|
131
155
|
|
|
132
156
|
# Create ToolReturnPart (the actual result)
|
|
133
157
|
tool_returns.append(ToolReturnPart(
|
|
134
|
-
tool_name=
|
|
158
|
+
tool_name=safe_tool_name,
|
|
135
159
|
content=tool_result,
|
|
136
160
|
tool_call_id=tool_call_id,
|
|
137
161
|
))
|
|
@@ -166,8 +190,15 @@ def session_to_pydantic_messages(
|
|
|
166
190
|
# Orphan tool message (no preceding assistant) - synthesize both parts
|
|
167
191
|
tool_name = msg.get("tool_name", "unknown_tool")
|
|
168
192
|
tool_call_id = msg.get("tool_call_id", f"call_{i}")
|
|
169
|
-
|
|
170
|
-
|
|
193
|
+
tool_content = msg.get("content") or "{}"
|
|
194
|
+
|
|
195
|
+
# tool_arguments: prefer explicit field, fallback to parsing content
|
|
196
|
+
tool_arguments = msg.get("tool_arguments")
|
|
197
|
+
if tool_arguments is None and isinstance(tool_content, str) and tool_content:
|
|
198
|
+
try:
|
|
199
|
+
tool_arguments = json.loads(tool_content)
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
tool_arguments = {}
|
|
171
202
|
|
|
172
203
|
# Parse tool content
|
|
173
204
|
if isinstance(tool_content, str):
|
|
@@ -178,10 +209,13 @@ def session_to_pydantic_messages(
|
|
|
178
209
|
else:
|
|
179
210
|
tool_result = tool_content
|
|
180
211
|
|
|
212
|
+
# Sanitize tool name for OpenAI API compatibility
|
|
213
|
+
safe_tool_name = _sanitize_tool_name(tool_name)
|
|
214
|
+
|
|
181
215
|
# Synthesize the tool call (ModelResponse with ToolCallPart)
|
|
182
216
|
messages.append(ModelResponse(
|
|
183
217
|
parts=[ToolCallPart(
|
|
184
|
-
tool_name=
|
|
218
|
+
tool_name=safe_tool_name,
|
|
185
219
|
args=tool_arguments if tool_arguments else {},
|
|
186
220
|
tool_call_id=tool_call_id,
|
|
187
221
|
)],
|
|
@@ -191,7 +225,7 @@ def session_to_pydantic_messages(
|
|
|
191
225
|
# Add the tool return (ModelRequest with ToolReturnPart)
|
|
192
226
|
messages.append(ModelRequest(
|
|
193
227
|
parts=[ToolReturnPart(
|
|
194
|
-
tool_name=
|
|
228
|
+
tool_name=safe_tool_name,
|
|
195
229
|
content=tool_result,
|
|
196
230
|
tool_call_id=tool_call_id,
|
|
197
231
|
)]
|
|
@@ -208,3 +242,69 @@ def session_to_pydantic_messages(
|
|
|
208
242
|
|
|
209
243
|
logger.debug(f"Converted {len(session_history)} stored messages to {len(messages)} pydantic-ai messages")
|
|
210
244
|
return messages
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def audit_session_history(
|
|
248
|
+
session_id: str,
|
|
249
|
+
agent_name: str,
|
|
250
|
+
prompt: str,
|
|
251
|
+
raw_session_history: list[dict[str, Any]],
|
|
252
|
+
pydantic_messages_count: int,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""
|
|
255
|
+
Dump session history to a YAML file for debugging.
|
|
256
|
+
|
|
257
|
+
Only runs when DEBUG__AUDIT_SESSION=true. Writes to DEBUG__AUDIT_DIR (default /tmp).
|
|
258
|
+
Appends to the same file for a session, so all agent invocations are in one place.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
session_id: The session identifier
|
|
262
|
+
agent_name: Name of the agent being invoked
|
|
263
|
+
prompt: The prompt being sent to the agent
|
|
264
|
+
raw_session_history: The raw session messages from the database
|
|
265
|
+
pydantic_messages_count: Count of converted pydantic-ai messages
|
|
266
|
+
"""
|
|
267
|
+
from ...settings import settings
|
|
268
|
+
|
|
269
|
+
if not settings.debug.audit_session:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
import yaml
|
|
274
|
+
from pathlib import Path
|
|
275
|
+
from ...utils.date_utils import utc_now, to_iso
|
|
276
|
+
|
|
277
|
+
audit_dir = Path(settings.debug.audit_dir)
|
|
278
|
+
audit_dir.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
audit_file = audit_dir / f"{session_id}.yaml"
|
|
280
|
+
|
|
281
|
+
# Create entry for this agent invocation
|
|
282
|
+
entry = {
|
|
283
|
+
"timestamp": to_iso(utc_now()),
|
|
284
|
+
"agent_name": agent_name,
|
|
285
|
+
"prompt": prompt,
|
|
286
|
+
"raw_history_count": len(raw_session_history),
|
|
287
|
+
"pydantic_messages_count": pydantic_messages_count,
|
|
288
|
+
"raw_session_history": raw_session_history,
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
# Load existing data or create new
|
|
292
|
+
existing_data: dict[str, Any] = {"session_id": session_id, "invocations": []}
|
|
293
|
+
if audit_file.exists():
|
|
294
|
+
with open(audit_file) as f:
|
|
295
|
+
loaded = yaml.safe_load(f)
|
|
296
|
+
if loaded:
|
|
297
|
+
# Ensure session_id is always present (backfill if missing)
|
|
298
|
+
existing_data = {
|
|
299
|
+
"session_id": loaded.get("session_id", session_id),
|
|
300
|
+
"invocations": loaded.get("invocations", []),
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Append this invocation
|
|
304
|
+
existing_data["invocations"].append(entry)
|
|
305
|
+
|
|
306
|
+
with open(audit_file, "w") as f:
|
|
307
|
+
yaml.dump(existing_data, f, default_flow_style=False, allow_unicode=True)
|
|
308
|
+
logger.info(f"DEBUG: Session audit updated: {audit_file}")
|
|
309
|
+
except Exception as e:
|
|
310
|
+
logger.warning(f"DEBUG: Failed to dump session audit: {e}")
|
rem/services/session/reload.py
CHANGED
|
@@ -12,7 +12,8 @@ Design Pattern:
|
|
|
12
12
|
|
|
13
13
|
Message Types on Reload:
|
|
14
14
|
- user: Returned as-is
|
|
15
|
-
- tool: Returned
|
|
15
|
+
- tool: Returned with metadata (tool_call_id, tool_name). tool_arguments may be in
|
|
16
|
+
metadata (parent calls) or parsed from content (child calls) by pydantic_messages.py
|
|
16
17
|
- assistant: Compressed on load if long (>400 chars), with REM LOOKUP for recovery
|
|
17
18
|
"""
|
|
18
19
|
|
rem/settings.py
CHANGED
|
@@ -424,6 +424,49 @@ class AuthSettings(BaseSettings):
|
|
|
424
424
|
google: GoogleOAuthSettings = Field(default_factory=GoogleOAuthSettings)
|
|
425
425
|
microsoft: MicrosoftOAuthSettings = Field(default_factory=MicrosoftOAuthSettings)
|
|
426
426
|
|
|
427
|
+
# Pre-approved login codes (bypass email verification)
|
|
428
|
+
# Format: comma-separated codes with prefix A=admin, B=normal user
|
|
429
|
+
# Example: "A12345,A67890,B11111,B22222"
|
|
430
|
+
preapproved_codes: str = Field(
|
|
431
|
+
default="",
|
|
432
|
+
description=(
|
|
433
|
+
"Comma-separated list of pre-approved login codes. "
|
|
434
|
+
"Prefix A = admin user, B = normal user. "
|
|
435
|
+
"Example: 'A12345,A67890,B11111'. "
|
|
436
|
+
"Users can login with these codes without email verification."
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def check_preapproved_code(self, code: str) -> dict | None:
|
|
441
|
+
"""
|
|
442
|
+
Check if a code is in the pre-approved list.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
code: The code to check (including prefix)
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Dict with 'role' key if valid, None if not found.
|
|
449
|
+
- A prefix -> role='admin'
|
|
450
|
+
- B prefix -> role='user'
|
|
451
|
+
"""
|
|
452
|
+
if not self.preapproved_codes:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
codes = [c.strip().upper() for c in self.preapproved_codes.split(",") if c.strip()]
|
|
456
|
+
code_upper = code.strip().upper()
|
|
457
|
+
|
|
458
|
+
if code_upper not in codes:
|
|
459
|
+
return None
|
|
460
|
+
|
|
461
|
+
# Parse prefix to determine role
|
|
462
|
+
if code_upper.startswith("A"):
|
|
463
|
+
return {"role": "admin", "code": code_upper}
|
|
464
|
+
elif code_upper.startswith("B"):
|
|
465
|
+
return {"role": "user", "code": code_upper}
|
|
466
|
+
else:
|
|
467
|
+
# Unknown prefix, treat as user
|
|
468
|
+
return {"role": "user", "code": code_upper}
|
|
469
|
+
|
|
427
470
|
@field_validator("session_secret", mode="before")
|
|
428
471
|
@classmethod
|
|
429
472
|
def generate_dev_secret(cls, v: str | None, info: ValidationInfo) -> str:
|
|
@@ -1651,6 +1694,33 @@ class EmailSettings(BaseSettings):
|
|
|
1651
1694
|
return kwargs
|
|
1652
1695
|
|
|
1653
1696
|
|
|
1697
|
+
class DebugSettings(BaseSettings):
|
|
1698
|
+
"""
|
|
1699
|
+
Debug settings for development and troubleshooting.
|
|
1700
|
+
|
|
1701
|
+
Environment variables:
|
|
1702
|
+
DEBUG__AUDIT_SESSION - Dump session history to /tmp/{session_id}.yaml
|
|
1703
|
+
DEBUG__AUDIT_DIR - Directory for session audit files (default: /tmp)
|
|
1704
|
+
"""
|
|
1705
|
+
|
|
1706
|
+
model_config = SettingsConfigDict(
|
|
1707
|
+
env_prefix="DEBUG__",
|
|
1708
|
+
env_file=".env",
|
|
1709
|
+
env_file_encoding="utf-8",
|
|
1710
|
+
extra="ignore",
|
|
1711
|
+
)
|
|
1712
|
+
|
|
1713
|
+
audit_session: bool = Field(
|
|
1714
|
+
default=False,
|
|
1715
|
+
description="When true, dump full session history to audit files for debugging",
|
|
1716
|
+
)
|
|
1717
|
+
|
|
1718
|
+
audit_dir: str = Field(
|
|
1719
|
+
default="/tmp",
|
|
1720
|
+
description="Directory for session audit files",
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
|
|
1654
1724
|
class TestSettings(BaseSettings):
|
|
1655
1725
|
"""
|
|
1656
1726
|
Test environment settings.
|
|
@@ -1767,6 +1837,7 @@ class Settings(BaseSettings):
|
|
|
1767
1837
|
schema_search: SchemaSettings = Field(default_factory=SchemaSettings)
|
|
1768
1838
|
email: EmailSettings = Field(default_factory=EmailSettings)
|
|
1769
1839
|
test: TestSettings = Field(default_factory=TestSettings)
|
|
1840
|
+
debug: DebugSettings = Field(default_factory=DebugSettings)
|
|
1770
1841
|
|
|
1771
1842
|
|
|
1772
1843
|
# Auto-load .env file from current directory if it exists
|
|
@@ -822,7 +822,7 @@ COMMENT ON FUNCTION fn_get_shared_messages IS
|
|
|
822
822
|
-- Function to list sessions with user details (name, email) for admin views
|
|
823
823
|
|
|
824
824
|
-- List sessions with user info, CTE pagination
|
|
825
|
-
-- Note: messages.session_id stores the session
|
|
825
|
+
-- Note: messages.session_id stores the session UUID (sessions.id)
|
|
826
826
|
CREATE OR REPLACE FUNCTION fn_list_sessions_with_user(
|
|
827
827
|
p_user_id VARCHAR(256) DEFAULT NULL, -- Filter by user_id (NULL = all users, admin only)
|
|
828
828
|
p_user_name VARCHAR(256) DEFAULT NULL, -- Filter by user name (partial match, admin only)
|
|
@@ -849,9 +849,9 @@ RETURNS TABLE(
|
|
|
849
849
|
BEGIN
|
|
850
850
|
RETURN QUERY
|
|
851
851
|
WITH session_msg_counts AS (
|
|
852
|
-
-- Count messages per session (joining on session
|
|
852
|
+
-- Count messages per session (joining on session UUID)
|
|
853
853
|
SELECT
|
|
854
|
-
m.session_id
|
|
854
|
+
m.session_id,
|
|
855
855
|
COUNT(*)::INTEGER as actual_message_count
|
|
856
856
|
FROM messages m
|
|
857
857
|
GROUP BY m.session_id
|
|
@@ -872,7 +872,7 @@ BEGIN
|
|
|
872
872
|
s.metadata
|
|
873
873
|
FROM sessions s
|
|
874
874
|
LEFT JOIN users u ON u.id::text = s.user_id
|
|
875
|
-
LEFT JOIN session_msg_counts mc ON mc.
|
|
875
|
+
LEFT JOIN session_msg_counts mc ON mc.session_id = s.id::text
|
|
876
876
|
WHERE s.deleted_at IS NULL
|
|
877
877
|
AND (p_user_id IS NULL OR s.user_id = p_user_id)
|
|
878
878
|
AND (p_user_name IS NULL OR u.name ILIKE '%' || p_user_name || '%')
|