remdb 0.3.226__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 +22 -248
- rem/agentic/context.py +13 -2
- rem/agentic/context_builder.py +39 -33
- rem/agentic/providers/pydantic_ai.py +67 -50
- rem/api/mcp_router/resources.py +223 -0
- rem/api/mcp_router/tools.py +25 -9
- rem/api/routers/auth.py +112 -9
- rem/api/routers/chat/child_streaming.py +394 -0
- rem/api/routers/chat/streaming.py +166 -357
- rem/api/routers/chat/streaming_utils.py +327 -0
- rem/api/routers/query.py +5 -14
- rem/cli/commands/ask.py +144 -33
- rem/cli/commands/process.py +9 -1
- 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/services/postgres/repository.py +7 -17
- rem/services/rem/service.py +47 -0
- rem/services/session/compression.py +7 -3
- rem/services/session/pydantic_messages.py +45 -11
- rem/services/session/reload.py +2 -1
- rem/settings.py +43 -0
- rem/sql/migrations/004_cache_system.sql +3 -1
- rem/utils/schema_loader.py +99 -99
- {remdb-0.3.226.dist-info → remdb-0.3.245.dist-info}/METADATA +2 -2
- {remdb-0.3.226.dist-info → remdb-0.3.245.dist-info}/RECORD +29 -26
- {remdb-0.3.226.dist-info → remdb-0.3.245.dist-info}/WHEEL +0 -0
- {remdb-0.3.226.dist-info → remdb-0.3.245.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REM query command.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
rem query --sql 'LOOKUP "Sarah Chen"'
|
|
6
|
+
rem query --sql 'SEARCH resources "API design" LIMIT 10'
|
|
7
|
+
rem query --sql "SELECT * FROM resources LIMIT 5"
|
|
8
|
+
rem query --file queries/my_query.sql
|
|
9
|
+
|
|
10
|
+
This tool connects to the configured PostgreSQL instance and executes the
|
|
11
|
+
provided REM dialect query, printing results as JSON (default) or plain dicts.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List
|
|
20
|
+
|
|
21
|
+
import click
|
|
22
|
+
from loguru import logger
|
|
23
|
+
|
|
24
|
+
from ...services.rem import QueryExecutionError
|
|
25
|
+
from ...services.rem.service import RemService
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.command("query")
|
|
29
|
+
@click.option("--sql", "-s", default=None, help="REM query string (LOOKUP, SEARCH, FUZZY, TRAVERSE, or SQL)")
|
|
30
|
+
@click.option(
|
|
31
|
+
"--file",
|
|
32
|
+
"-f",
|
|
33
|
+
"sql_file",
|
|
34
|
+
type=click.Path(exists=True, path_type=Path),
|
|
35
|
+
default=None,
|
|
36
|
+
help="Path to file containing REM query",
|
|
37
|
+
)
|
|
38
|
+
@click.option("--no-json", is_flag=True, default=False, help="Print rows as Python dicts instead of JSON")
|
|
39
|
+
@click.option("--user-id", "-u", default=None, help="Scope query to a specific user")
|
|
40
|
+
def query_command(sql: str | None, sql_file: Path | None, no_json: bool, user_id: str | None):
|
|
41
|
+
"""
|
|
42
|
+
Execute a REM query against the database.
|
|
43
|
+
|
|
44
|
+
Supports REM dialect queries (LOOKUP, SEARCH, FUZZY, TRAVERSE) and raw SQL.
|
|
45
|
+
Either --sql or --file must be provided.
|
|
46
|
+
"""
|
|
47
|
+
if not sql and not sql_file:
|
|
48
|
+
click.secho("Error: either --sql or --file is required", fg="red")
|
|
49
|
+
raise click.Abort()
|
|
50
|
+
|
|
51
|
+
# Read query from file if provided
|
|
52
|
+
if sql_file:
|
|
53
|
+
query_text = sql_file.read_text(encoding="utf-8")
|
|
54
|
+
else:
|
|
55
|
+
query_text = sql # type: ignore[assignment]
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
asyncio.run(_run_query_async(query_text, not no_json, user_id))
|
|
59
|
+
except Exception as exc: # pragma: no cover - CLI error path
|
|
60
|
+
logger.exception("Query failed")
|
|
61
|
+
click.secho(f"✗ Query failed: {exc}", fg="red")
|
|
62
|
+
raise click.Abort()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _run_query_async(query_text: str, as_json: bool, user_id: str | None) -> None:
|
|
66
|
+
"""
|
|
67
|
+
Execute the query using RemService.execute_query_string().
|
|
68
|
+
"""
|
|
69
|
+
from ...services.postgres import get_postgres_service
|
|
70
|
+
|
|
71
|
+
db = get_postgres_service()
|
|
72
|
+
if not db:
|
|
73
|
+
click.secho("✗ PostgreSQL is disabled in settings. Enable with POSTGRES__ENABLED=true", fg="red")
|
|
74
|
+
raise click.Abort()
|
|
75
|
+
|
|
76
|
+
if db.pool is None:
|
|
77
|
+
await db.connect()
|
|
78
|
+
|
|
79
|
+
rem_service = RemService(db)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
# Use the unified execute_query_string method
|
|
83
|
+
result = await rem_service.execute_query_string(query_text, user_id=user_id)
|
|
84
|
+
output_rows = result.get("results", [])
|
|
85
|
+
except QueryExecutionError as qe:
|
|
86
|
+
logger.exception("Query execution failed")
|
|
87
|
+
click.secho(f"✗ Query execution failed: {qe}. Please check the query you provided and try again.", fg="red")
|
|
88
|
+
raise click.Abort()
|
|
89
|
+
except ValueError as ve:
|
|
90
|
+
# Parse errors from the query parser
|
|
91
|
+
click.secho(f"✗ Invalid query: {ve}", fg="red")
|
|
92
|
+
raise click.Abort()
|
|
93
|
+
except Exception as exc: # pragma: no cover - CLI error path
|
|
94
|
+
logger.exception("Unexpected error during query execution")
|
|
95
|
+
click.secho("✗ An unexpected error occurred while executing the query. Please check the query you provided and try again.", fg="red")
|
|
96
|
+
raise click.Abort()
|
|
97
|
+
|
|
98
|
+
if as_json:
|
|
99
|
+
click.echo(json.dumps(output_rows, default=str, indent=2))
|
|
100
|
+
else:
|
|
101
|
+
for r in output_rows:
|
|
102
|
+
click.echo(str(r))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def register_command(cli_group):
|
|
106
|
+
"""Register the query command on the given CLI group (top-level)."""
|
|
107
|
+
cli_group.add_command(query_command)
|
|
108
|
+
|
|
109
|
+
|
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
|
@@ -31,27 +31,17 @@ 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
|
-
|
|
38
34
|
def get_postgres_service() -> "PostgresService | None":
|
|
39
35
|
"""
|
|
40
|
-
Get PostgresService singleton
|
|
36
|
+
Get PostgresService singleton from parent module.
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
44
41
|
"""
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return None
|
|
49
|
-
|
|
50
|
-
if _postgres_instance is None:
|
|
51
|
-
from .service import PostgresService
|
|
52
|
-
_postgres_instance = PostgresService()
|
|
53
|
-
|
|
54
|
-
return _postgres_instance
|
|
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()
|
|
55
45
|
|
|
56
46
|
T = TypeVar("T", bound=BaseModel)
|
|
57
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]:
|
|
@@ -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 (
|
|
@@ -242,7 +242,7 @@ class SessionMessageStore:
|
|
|
242
242
|
# Use pre-generated id from message dict if available (for frontend feedback)
|
|
243
243
|
msg = Message(
|
|
244
244
|
id=message.get("id"), # Use pre-generated ID if provided
|
|
245
|
-
content=message.get("content"
|
|
245
|
+
content=message.get("content") or "",
|
|
246
246
|
message_type=message.get("role", "assistant"),
|
|
247
247
|
session_id=session_id,
|
|
248
248
|
tenant_id=self.user_id, # Set tenant_id to user_id (application scoped to user)
|
|
@@ -337,7 +337,7 @@ class SessionMessageStore:
|
|
|
337
337
|
compressed_messages = []
|
|
338
338
|
|
|
339
339
|
for idx, message in enumerate(messages):
|
|
340
|
-
content = message.get("content"
|
|
340
|
+
content = message.get("content") or ""
|
|
341
341
|
|
|
342
342
|
# Only store and compress long assistant responses
|
|
343
343
|
if (
|
|
@@ -368,6 +368,8 @@ class SessionMessageStore:
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
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
|
|
371
373
|
if message.get("role") == "tool":
|
|
372
374
|
if message.get("tool_call_id"):
|
|
373
375
|
msg_metadata["tool_call_id"] = message.get("tool_call_id")
|
|
@@ -436,6 +438,8 @@ class SessionMessageStore:
|
|
|
436
438
|
}
|
|
437
439
|
|
|
438
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
|
|
439
443
|
if role == "tool" and msg.metadata:
|
|
440
444
|
if msg.metadata.get("tool_call_id"):
|
|
441
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
|
)]
|
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:
|
|
@@ -64,9 +64,11 @@ CREATE OR REPLACE FUNCTION rem_kv_store_empty(p_user_id TEXT)
|
|
|
64
64
|
RETURNS BOOLEAN AS $$
|
|
65
65
|
BEGIN
|
|
66
66
|
-- Quick existence check - very fast with index
|
|
67
|
+
-- Check for user-specific OR public (NULL user_id) entries
|
|
68
|
+
-- This ensures self-healing triggers correctly for public ontologies
|
|
67
69
|
RETURN NOT EXISTS (
|
|
68
70
|
SELECT 1 FROM kv_store
|
|
69
|
-
WHERE user_id = p_user_id
|
|
71
|
+
WHERE user_id = p_user_id OR user_id IS NULL
|
|
70
72
|
LIMIT 1
|
|
71
73
|
);
|
|
72
74
|
END;
|