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
|
@@ -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
|
|
77
|
+
Run agent in streaming mode using the SAME code path as the API.
|
|
77
78
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
93
|
-
from rem.
|
|
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
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
106
|
+
content=user_message,
|
|
170
107
|
)
|
|
171
108
|
|
|
172
|
-
|
|
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}")
|
|
@@ -184,9 +164,13 @@ async def run_agent_non_streaming(
|
|
|
184
164
|
context: AgentContext | None = None,
|
|
185
165
|
plan: bool = False,
|
|
186
166
|
max_iterations: int | None = None,
|
|
167
|
+
user_message: str | None = None,
|
|
187
168
|
) -> dict[str, Any] | None:
|
|
188
169
|
"""
|
|
189
|
-
Run agent in non-streaming mode using agent.
|
|
170
|
+
Run agent in non-streaming mode using agent.iter() to capture tool calls.
|
|
171
|
+
|
|
172
|
+
This mirrors the streaming code path to ensure tool messages are properly
|
|
173
|
+
persisted to the database for state tracking across turns.
|
|
190
174
|
|
|
191
175
|
Args:
|
|
192
176
|
agent: Pydantic AI agent
|
|
@@ -196,77 +180,183 @@ async def run_agent_non_streaming(
|
|
|
196
180
|
context: Optional AgentContext for session persistence
|
|
197
181
|
plan: If True, output only the generated query (for query-agent)
|
|
198
182
|
max_iterations: Maximum iterations/requests (from agent schema or settings)
|
|
183
|
+
user_message: The user's original message (for database storage)
|
|
199
184
|
|
|
200
185
|
Returns:
|
|
201
186
|
Output data if successful, None otherwise
|
|
202
187
|
"""
|
|
203
188
|
from pydantic_ai import UsageLimits
|
|
189
|
+
from pydantic_ai.agent import Agent
|
|
190
|
+
from pydantic_ai.messages import (
|
|
191
|
+
FunctionToolResultEvent,
|
|
192
|
+
PartStartEvent,
|
|
193
|
+
PartEndEvent,
|
|
194
|
+
TextPart,
|
|
195
|
+
ToolCallPart,
|
|
196
|
+
)
|
|
204
197
|
from rem.utils.date_utils import to_iso_with_z, utc_now
|
|
205
198
|
|
|
206
199
|
logger.info("Running agent in non-streaming mode...")
|
|
207
200
|
|
|
208
201
|
try:
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
202
|
+
# Track tool calls for persistence (same as streaming code path)
|
|
203
|
+
tool_calls: list = []
|
|
204
|
+
pending_tool_data: dict = {}
|
|
205
|
+
pending_tool_completions: list = []
|
|
206
|
+
accumulated_content: list = []
|
|
207
|
+
|
|
208
|
+
# Get the underlying pydantic-ai agent
|
|
209
|
+
pydantic_agent = agent.agent if hasattr(agent, 'agent') else agent
|
|
210
|
+
|
|
211
|
+
# Use agent.iter() to capture tool calls (same as streaming)
|
|
212
|
+
async with pydantic_agent.iter(prompt) as agent_run:
|
|
213
|
+
async for node in agent_run:
|
|
214
|
+
# Handle model request nodes (text + tool call starts)
|
|
215
|
+
if Agent.is_model_request_node(node):
|
|
216
|
+
async with node.stream(agent_run.ctx) as request_stream:
|
|
217
|
+
async for event in request_stream:
|
|
218
|
+
# Capture text content
|
|
219
|
+
if isinstance(event, PartStartEvent) and isinstance(event.part, TextPart):
|
|
220
|
+
if event.part.content:
|
|
221
|
+
accumulated_content.append(event.part.content)
|
|
222
|
+
|
|
223
|
+
# Capture tool call starts
|
|
224
|
+
elif isinstance(event, PartStartEvent) and isinstance(event.part, ToolCallPart):
|
|
225
|
+
tool_name = event.part.tool_name
|
|
226
|
+
if tool_name == "final_result":
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
import uuid
|
|
230
|
+
tool_id = f"call_{uuid.uuid4().hex[:8]}"
|
|
231
|
+
pending_tool_completions.append((tool_name, tool_id))
|
|
232
|
+
|
|
233
|
+
# Extract arguments
|
|
234
|
+
args_dict = {}
|
|
235
|
+
if hasattr(event.part, 'args'):
|
|
236
|
+
args = event.part.args
|
|
237
|
+
if isinstance(args, str):
|
|
238
|
+
try:
|
|
239
|
+
args_dict = json.loads(args)
|
|
240
|
+
except json.JSONDecodeError:
|
|
241
|
+
args_dict = {"raw": args}
|
|
242
|
+
elif isinstance(args, dict):
|
|
243
|
+
args_dict = args
|
|
244
|
+
|
|
245
|
+
pending_tool_data[tool_id] = {
|
|
246
|
+
"tool_name": tool_name,
|
|
247
|
+
"tool_id": tool_id,
|
|
248
|
+
"arguments": args_dict,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# Print tool call for CLI visibility
|
|
252
|
+
print(f"\n[Calling: {tool_name}]", flush=True)
|
|
253
|
+
|
|
254
|
+
# Capture tool call end (update arguments if changed)
|
|
255
|
+
elif isinstance(event, PartEndEvent) and isinstance(event.part, ToolCallPart):
|
|
256
|
+
pass # Arguments already captured at start
|
|
257
|
+
|
|
258
|
+
# Handle tool execution nodes (results)
|
|
259
|
+
elif Agent.is_call_tools_node(node):
|
|
260
|
+
async with node.stream(agent_run.ctx) as tools_stream:
|
|
261
|
+
async for event in tools_stream:
|
|
262
|
+
if isinstance(event, FunctionToolResultEvent):
|
|
263
|
+
# Get tool info from pending queue
|
|
264
|
+
if pending_tool_completions:
|
|
265
|
+
tool_name, tool_id = pending_tool_completions.pop(0)
|
|
266
|
+
else:
|
|
267
|
+
import uuid
|
|
268
|
+
tool_name = "tool"
|
|
269
|
+
tool_id = f"call_{uuid.uuid4().hex[:8]}"
|
|
270
|
+
|
|
271
|
+
result_content = event.result.content if hasattr(event.result, 'content') else event.result
|
|
272
|
+
|
|
273
|
+
# Capture tool call for persistence
|
|
274
|
+
if tool_id in pending_tool_data:
|
|
275
|
+
tool_data = pending_tool_data[tool_id]
|
|
276
|
+
tool_data["result"] = result_content
|
|
277
|
+
tool_calls.append(tool_data)
|
|
278
|
+
del pending_tool_data[tool_id]
|
|
279
|
+
|
|
280
|
+
# Get final result
|
|
281
|
+
result = agent_run.result
|
|
212
282
|
|
|
213
283
|
# Extract output data
|
|
214
284
|
output_data = None
|
|
215
285
|
assistant_content = None
|
|
216
|
-
if hasattr(result, "output"):
|
|
286
|
+
if result is not None and hasattr(result, "output"):
|
|
217
287
|
output = result.output
|
|
218
288
|
from rem.agentic.serialization import serialize_agent_result
|
|
219
289
|
output_data = serialize_agent_result(output)
|
|
220
290
|
|
|
221
291
|
if plan and isinstance(output_data, dict) and "query" in output_data:
|
|
222
|
-
# Plan mode: Output only the query
|
|
223
|
-
# Use sql formatting if possible or just raw string
|
|
224
292
|
assistant_content = output_data["query"]
|
|
225
293
|
print(assistant_content)
|
|
226
294
|
else:
|
|
227
|
-
#
|
|
228
|
-
|
|
295
|
+
# For string output, use it directly
|
|
296
|
+
if isinstance(output_data, str):
|
|
297
|
+
assistant_content = output_data
|
|
298
|
+
else:
|
|
299
|
+
assistant_content = json.dumps(output_data, indent=2)
|
|
229
300
|
print(assistant_content)
|
|
230
301
|
else:
|
|
231
|
-
|
|
232
|
-
assistant_content
|
|
233
|
-
|
|
302
|
+
assistant_content = str(result) if result else ""
|
|
303
|
+
if assistant_content:
|
|
304
|
+
print(assistant_content)
|
|
234
305
|
|
|
235
306
|
# Save to file if requested
|
|
236
307
|
if output_file and output_data:
|
|
237
308
|
await _save_output_file(output_file, output_data)
|
|
238
309
|
|
|
239
|
-
# Save session messages (
|
|
310
|
+
# Save session messages including tool calls (same as streaming code path)
|
|
240
311
|
if context and context.session_id and settings.postgres.enabled:
|
|
241
312
|
from ...services.session.compression import SessionMessageStore
|
|
242
313
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
# We need to extract the last user message
|
|
246
|
-
user_message_content = prompt.split("\n\n")[-1] if "\n\n" in prompt else prompt
|
|
314
|
+
timestamp = to_iso_with_z(utc_now())
|
|
315
|
+
messages_to_store = []
|
|
247
316
|
|
|
248
|
-
|
|
317
|
+
# Save user message first
|
|
318
|
+
user_message_content = user_message or (prompt.split("\n\n")[-1] if "\n\n" in prompt else prompt)
|
|
319
|
+
messages_to_store.append({
|
|
249
320
|
"role": "user",
|
|
250
321
|
"content": user_message_content,
|
|
251
|
-
"timestamp":
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
322
|
+
"timestamp": timestamp,
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
# Save tool call messages (message_type: "tool") - CRITICAL for state tracking
|
|
326
|
+
for tool_call in tool_calls:
|
|
327
|
+
if not tool_call:
|
|
328
|
+
continue
|
|
329
|
+
tool_message = {
|
|
330
|
+
"role": "tool",
|
|
331
|
+
"content": json.dumps(tool_call.get("result", {}), default=str),
|
|
332
|
+
"timestamp": timestamp,
|
|
333
|
+
"tool_call_id": tool_call.get("tool_id"),
|
|
334
|
+
"tool_name": tool_call.get("tool_name"),
|
|
335
|
+
"tool_arguments": tool_call.get("arguments"),
|
|
336
|
+
}
|
|
337
|
+
messages_to_store.append(tool_message)
|
|
338
|
+
|
|
339
|
+
# Save assistant message
|
|
340
|
+
if assistant_content:
|
|
341
|
+
messages_to_store.append({
|
|
342
|
+
"role": "assistant",
|
|
343
|
+
"content": assistant_content,
|
|
344
|
+
"timestamp": timestamp,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
# Store all messages
|
|
261
348
|
store = SessionMessageStore(user_id=context.user_id or settings.test.effective_user_id)
|
|
262
349
|
await store.store_session_messages(
|
|
263
350
|
session_id=context.session_id,
|
|
264
|
-
messages=
|
|
351
|
+
messages=messages_to_store,
|
|
265
352
|
user_id=context.user_id,
|
|
266
|
-
compress=
|
|
353
|
+
compress=False, # Store uncompressed; compression happens on reload
|
|
267
354
|
)
|
|
268
355
|
|
|
269
|
-
logger.debug(
|
|
356
|
+
logger.debug(
|
|
357
|
+
f"Saved {len(tool_calls)} tool calls + user/assistant messages "
|
|
358
|
+
f"to session {context.session_id}"
|
|
359
|
+
)
|
|
270
360
|
|
|
271
361
|
return output_data
|
|
272
362
|
|
|
@@ -352,8 +442,8 @@ async def _save_output_file(file_path: Path, data: dict[str, Any]) -> None:
|
|
|
352
442
|
)
|
|
353
443
|
@click.option(
|
|
354
444
|
"--stream/--no-stream",
|
|
355
|
-
default=
|
|
356
|
-
help="Enable streaming mode (default:
|
|
445
|
+
default=True,
|
|
446
|
+
help="Enable streaming mode (default: enabled)",
|
|
357
447
|
)
|
|
358
448
|
@click.option(
|
|
359
449
|
"--user-id",
|
|
@@ -549,7 +639,7 @@ async def _ask_async(
|
|
|
549
639
|
|
|
550
640
|
# Run agent with session persistence
|
|
551
641
|
if stream:
|
|
552
|
-
await run_agent_streaming(agent, prompt, max_turns=max_turns, context=context)
|
|
642
|
+
await run_agent_streaming(agent, prompt, max_turns=max_turns, context=context, user_message=query)
|
|
553
643
|
else:
|
|
554
644
|
await run_agent_non_streaming(
|
|
555
645
|
agent,
|
|
@@ -558,6 +648,7 @@ async def _ask_async(
|
|
|
558
648
|
output_file=output_file,
|
|
559
649
|
context=context,
|
|
560
650
|
plan=plan,
|
|
651
|
+
user_message=query,
|
|
561
652
|
)
|
|
562
653
|
|
|
563
654
|
# Log session ID for reuse
|
rem/cli/commands/process.py
CHANGED
|
@@ -193,7 +193,15 @@ def process_ingest(
|
|
|
193
193
|
try:
|
|
194
194
|
# Read file content
|
|
195
195
|
content = file_path.read_text(encoding="utf-8")
|
|
196
|
-
|
|
196
|
+
|
|
197
|
+
# Generate entity key from filename
|
|
198
|
+
# Special case: README files use parent directory as section name
|
|
199
|
+
if file_path.stem.lower() == "readme":
|
|
200
|
+
# Use parent directory name, e.g., "drugs" for drugs/README.md
|
|
201
|
+
# For nested paths like disorders/anxiety/README.md -> "anxiety"
|
|
202
|
+
entity_key = file_path.parent.name
|
|
203
|
+
else:
|
|
204
|
+
entity_key = file_path.stem # filename without extension
|
|
197
205
|
|
|
198
206
|
# Build entity based on table
|
|
199
207
|
entity_data = {
|
|
@@ -206,9 +214,9 @@ def process_ingest(
|
|
|
206
214
|
if category:
|
|
207
215
|
entity_data["category"] = category
|
|
208
216
|
|
|
209
|
-
# Scoping: user_id for private data,
|
|
210
|
-
# tenant_id=
|
|
211
|
-
entity_data["tenant_id"] = user_id
|
|
217
|
+
# Scoping: user_id for private data, "public" for shared
|
|
218
|
+
# tenant_id="public" is the default for shared knowledge bases
|
|
219
|
+
entity_data["tenant_id"] = user_id or "public"
|
|
212
220
|
entity_data["user_id"] = user_id # None = public/shared
|
|
213
221
|
|
|
214
222
|
# For ontologies, add URI
|
|
@@ -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
|
+
|