remdb 0.3.157__py3-none-any.whl → 0.3.180__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.
- rem/agentic/agents/agent_manager.py +2 -1
- rem/agentic/context.py +81 -3
- rem/agentic/context_builder.py +31 -6
- rem/agentic/mcp/tool_wrapper.py +43 -14
- rem/agentic/providers/pydantic_ai.py +76 -34
- rem/agentic/schema.py +4 -3
- rem/agentic/tools/rem_tools.py +11 -0
- rem/api/deps.py +1 -3
- rem/api/main.py +21 -2
- rem/api/mcp_router/resources.py +75 -14
- rem/api/mcp_router/server.py +27 -24
- rem/api/mcp_router/tools.py +83 -2
- rem/api/middleware/tracking.py +5 -5
- rem/api/routers/auth.py +152 -10
- rem/api/routers/chat/completions.py +5 -3
- rem/api/routers/chat/streaming.py +18 -0
- rem/api/routers/messages.py +24 -15
- rem/auth/jwt.py +352 -0
- rem/auth/middleware.py +70 -30
- rem/cli/commands/ask.py +1 -1
- rem/cli/commands/db.py +98 -44
- rem/models/entities/ontology.py +93 -101
- rem/schemas/agents/core/agent-builder.yaml +143 -42
- rem/services/email/service.py +72 -9
- rem/services/postgres/register_type.py +1 -1
- rem/services/postgres/repository.py +5 -4
- rem/services/user_service.py +41 -9
- rem/settings.py +15 -1
- rem/sql/background_indexes.sql +5 -0
- rem/sql/migrations/001_install.sql +33 -4
- rem/sql/migrations/002_install_models.sql +186 -168
- rem/utils/model_helpers.py +101 -0
- rem/utils/schema_loader.py +45 -7
- {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/METADATA +1 -1
- {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/RECORD +37 -36
- {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/WHEEL +0 -0
- {remdb-0.3.157.dist-info → remdb-0.3.180.dist-info}/entry_points.txt +0 -0
|
@@ -128,8 +128,9 @@ async def save_agent(
|
|
|
128
128
|
)
|
|
129
129
|
|
|
130
130
|
# Create Schema entity (user-scoped)
|
|
131
|
+
# Note: tenant_id defaults to "default" for anonymous users
|
|
131
132
|
schema_entity = Schema(
|
|
132
|
-
tenant_id=user_id,
|
|
133
|
+
tenant_id=user_id or "default",
|
|
133
134
|
user_id=user_id,
|
|
134
135
|
name=name,
|
|
135
136
|
spec=spec,
|
rem/agentic/context.py
CHANGED
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
Agent execution context and configuration.
|
|
3
3
|
|
|
4
4
|
Design pattern for session context that can be constructed from:
|
|
5
|
+
- FastAPI Request object (preferred - extracts user from JWT via request.state)
|
|
5
6
|
- HTTP headers (X-User-Id, X-Session-Id, X-Model-Name, X-Is-Eval, etc.)
|
|
6
7
|
- Direct instantiation for testing/CLI
|
|
7
8
|
|
|
9
|
+
User ID Sources (in priority order):
|
|
10
|
+
1. request.state.user.id - From JWT token validated by auth middleware (SECURE)
|
|
11
|
+
2. X-User-Id header - Fallback for backwards compatibility (less secure)
|
|
12
|
+
|
|
8
13
|
Headers Mapping:
|
|
9
|
-
X-User-Id → context.user_id
|
|
10
14
|
X-Tenant-Id → context.tenant_id (default: "default")
|
|
11
15
|
X-Session-Id → context.session_id
|
|
12
16
|
X-Agent-Schema → context.agent_schema_uri (default: "rem")
|
|
@@ -128,13 +132,87 @@ class AgentContext(BaseModel):
|
|
|
128
132
|
logger.debug(f"No user_id from {source}, using None (anonymous/shared data)")
|
|
129
133
|
return None
|
|
130
134
|
|
|
135
|
+
@classmethod
|
|
136
|
+
def from_request(cls, request: "Request") -> "AgentContext":
|
|
137
|
+
"""
|
|
138
|
+
Construct AgentContext from a FastAPI Request object.
|
|
139
|
+
|
|
140
|
+
This is the PREFERRED method for API endpoints. It extracts user_id
|
|
141
|
+
from the authenticated user in request.state (set by auth middleware
|
|
142
|
+
from JWT token), which is more secure than trusting X-User-Id header.
|
|
143
|
+
|
|
144
|
+
Priority for user_id:
|
|
145
|
+
1. request.state.user.id - From validated JWT token (SECURE)
|
|
146
|
+
2. X-User-Id header - Fallback for backwards compatibility
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
request: FastAPI Request object
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
AgentContext with user from JWT and other values from headers
|
|
153
|
+
|
|
154
|
+
Example:
|
|
155
|
+
@app.post("/api/v1/chat/completions")
|
|
156
|
+
async def chat(request: Request, body: ChatRequest):
|
|
157
|
+
context = AgentContext.from_request(request)
|
|
158
|
+
# context.user_id is from JWT, not header
|
|
159
|
+
"""
|
|
160
|
+
from typing import TYPE_CHECKING
|
|
161
|
+
if TYPE_CHECKING:
|
|
162
|
+
from starlette.requests import Request
|
|
163
|
+
|
|
164
|
+
# Get headers dict
|
|
165
|
+
headers = dict(request.headers)
|
|
166
|
+
normalized = {k.lower(): v for k, v in headers.items()}
|
|
167
|
+
|
|
168
|
+
# Extract user_id from authenticated user (JWT) - this is the source of truth
|
|
169
|
+
user_id = None
|
|
170
|
+
tenant_id = "default"
|
|
171
|
+
|
|
172
|
+
if hasattr(request, "state"):
|
|
173
|
+
user = getattr(request.state, "user", None)
|
|
174
|
+
if user and isinstance(user, dict):
|
|
175
|
+
user_id = user.get("id")
|
|
176
|
+
# Also get tenant_id from authenticated user if available
|
|
177
|
+
if user.get("tenant_id"):
|
|
178
|
+
tenant_id = user.get("tenant_id")
|
|
179
|
+
if user_id:
|
|
180
|
+
logger.debug(f"User ID from JWT: {user_id}")
|
|
181
|
+
|
|
182
|
+
# Fallback to X-User-Id header if no authenticated user
|
|
183
|
+
if not user_id:
|
|
184
|
+
user_id = normalized.get("x-user-id")
|
|
185
|
+
if user_id:
|
|
186
|
+
logger.debug(f"User ID from X-User-Id header (fallback): {user_id}")
|
|
187
|
+
|
|
188
|
+
# Override tenant_id from header if provided
|
|
189
|
+
header_tenant = normalized.get("x-tenant-id")
|
|
190
|
+
if header_tenant:
|
|
191
|
+
tenant_id = header_tenant
|
|
192
|
+
|
|
193
|
+
# Parse X-Is-Eval header
|
|
194
|
+
is_eval_str = normalized.get("x-is-eval", "").lower()
|
|
195
|
+
is_eval = is_eval_str in ("true", "1", "yes")
|
|
196
|
+
|
|
197
|
+
return cls(
|
|
198
|
+
user_id=user_id,
|
|
199
|
+
tenant_id=tenant_id,
|
|
200
|
+
session_id=normalized.get("x-session-id"),
|
|
201
|
+
default_model=normalized.get("x-model-name") or settings.llm.default_model,
|
|
202
|
+
agent_schema_uri=normalized.get("x-agent-schema"),
|
|
203
|
+
is_eval=is_eval,
|
|
204
|
+
)
|
|
205
|
+
|
|
131
206
|
@classmethod
|
|
132
207
|
def from_headers(cls, headers: dict[str, str]) -> "AgentContext":
|
|
133
208
|
"""
|
|
134
|
-
Construct AgentContext from HTTP headers.
|
|
209
|
+
Construct AgentContext from HTTP headers dict.
|
|
210
|
+
|
|
211
|
+
NOTE: Prefer from_request() for API endpoints as it extracts user_id
|
|
212
|
+
from the validated JWT token in request.state, which is more secure.
|
|
135
213
|
|
|
136
214
|
Reads standard headers:
|
|
137
|
-
- X-User-Id: User identifier
|
|
215
|
+
- X-User-Id: User identifier (fallback - prefer JWT)
|
|
138
216
|
- X-Tenant-Id: Tenant identifier
|
|
139
217
|
- X-Session-Id: Session identifier
|
|
140
218
|
- X-Model-Name: Model override
|
rem/agentic/context_builder.py
CHANGED
|
@@ -12,7 +12,7 @@ User Context (on-demand by default):
|
|
|
12
12
|
- System message includes REM LOOKUP hint for user profile
|
|
13
13
|
- Agent decides whether to load profile based on query
|
|
14
14
|
- More efficient for queries that don't need personalization
|
|
15
|
-
- Example: "User
|
|
15
|
+
- Example: "User: sarah@example.com. To load user profile: Use REM LOOKUP \"sarah@example.com\""
|
|
16
16
|
|
|
17
17
|
User Context (auto-inject when enabled):
|
|
18
18
|
- Set CHAT__AUTO_INJECT_USER_CONTEXT=true
|
|
@@ -40,7 +40,7 @@ Usage (on-demand, default):
|
|
|
40
40
|
|
|
41
41
|
# Messages list structure (on-demand):
|
|
42
42
|
# [
|
|
43
|
-
# {"role": "system", "content": "Today's date: 2025-11-22\nUser
|
|
43
|
+
# {"role": "system", "content": "Today's date: 2025-11-22\nUser: sarah@example.com\nTo load user profile: Use REM LOOKUP \"sarah@example.com\"\nSession ID: sess-123\nTo load session history: Use REM LOOKUP messages?session_id=sess-123"},
|
|
44
44
|
# {"role": "user", "content": "What's next for the API migration?"}
|
|
45
45
|
# ]
|
|
46
46
|
|
|
@@ -103,6 +103,7 @@ class ContextBuilder:
|
|
|
103
103
|
headers: dict[str, str],
|
|
104
104
|
new_messages: list[dict[str, str]] | None = None,
|
|
105
105
|
db: PostgresService | None = None,
|
|
106
|
+
user_id: str | None = None,
|
|
106
107
|
) -> tuple[AgentContext, list[ContextMessage]]:
|
|
107
108
|
"""
|
|
108
109
|
Build complete context from HTTP headers.
|
|
@@ -114,7 +115,7 @@ class ContextBuilder:
|
|
|
114
115
|
- Agent can retrieve full content on-demand using REM LOOKUP
|
|
115
116
|
|
|
116
117
|
User Context (on-demand by default):
|
|
117
|
-
- System message includes REM LOOKUP hint: "User
|
|
118
|
+
- System message includes REM LOOKUP hint: "User: {email}. To load user profile: Use REM LOOKUP \"{email}\""
|
|
118
119
|
- Agent decides whether to load profile based on query
|
|
119
120
|
|
|
120
121
|
User Context (auto-inject when enabled):
|
|
@@ -125,6 +126,7 @@ class ContextBuilder:
|
|
|
125
126
|
headers: HTTP request headers (case-insensitive)
|
|
126
127
|
new_messages: New messages from current request
|
|
127
128
|
db: Optional PostgresService (creates if None)
|
|
129
|
+
user_id: Override user_id from JWT token (takes precedence over X-User-Id header)
|
|
128
130
|
|
|
129
131
|
Returns:
|
|
130
132
|
Tuple of (AgentContext, messages list)
|
|
@@ -135,7 +137,7 @@ class ContextBuilder:
|
|
|
135
137
|
|
|
136
138
|
# messages structure:
|
|
137
139
|
# [
|
|
138
|
-
# {"role": "system", "content": "Today's date: 2025-11-22\nUser
|
|
140
|
+
# {"role": "system", "content": "Today's date: 2025-11-22\nUser: sarah@example.com\nTo load user profile: Use REM LOOKUP \"sarah@example.com\""},
|
|
139
141
|
# {"role": "user", "content": "Previous message"},
|
|
140
142
|
# {"role": "assistant", "content": "Start of long response... [REM LOOKUP session-123-msg-1] ...end"},
|
|
141
143
|
# {"role": "user", "content": "New message"}
|
|
@@ -147,6 +149,17 @@ class ContextBuilder:
|
|
|
147
149
|
# Extract AgentContext from headers
|
|
148
150
|
context = AgentContext.from_headers(headers)
|
|
149
151
|
|
|
152
|
+
# Override user_id if provided (from JWT token - takes precedence over header)
|
|
153
|
+
if user_id is not None:
|
|
154
|
+
context = AgentContext(
|
|
155
|
+
user_id=user_id,
|
|
156
|
+
tenant_id=context.tenant_id,
|
|
157
|
+
session_id=context.session_id,
|
|
158
|
+
default_model=context.default_model,
|
|
159
|
+
agent_schema_uri=context.agent_schema_uri,
|
|
160
|
+
is_eval=context.is_eval,
|
|
161
|
+
)
|
|
162
|
+
|
|
150
163
|
# Initialize DB if not provided and needed (for user context or session history)
|
|
151
164
|
close_db = False
|
|
152
165
|
if db is None and (settings.chat.auto_inject_user_context or context.session_id):
|
|
@@ -178,8 +191,16 @@ class ContextBuilder:
|
|
|
178
191
|
context_hint += "\n\nNo user context available (anonymous or new user)."
|
|
179
192
|
elif context.user_id:
|
|
180
193
|
# On-demand: Provide hint to use REM LOOKUP
|
|
181
|
-
|
|
182
|
-
|
|
194
|
+
# user_id is UUID5 hash of email - load user to get email for display and LOOKUP
|
|
195
|
+
user_repo = Repository(User, "users", db=db)
|
|
196
|
+
user = await user_repo.get_by_id(context.user_id, context.tenant_id)
|
|
197
|
+
if user and user.email:
|
|
198
|
+
# Show email (more useful than UUID) and LOOKUP hint
|
|
199
|
+
context_hint += f"\n\nUser: {user.email}"
|
|
200
|
+
context_hint += f"\nTo load user profile: Use REM LOOKUP \"{user.email}\""
|
|
201
|
+
else:
|
|
202
|
+
context_hint += f"\n\nUser ID: {context.user_id}"
|
|
203
|
+
context_hint += "\nUser profile not available."
|
|
183
204
|
|
|
184
205
|
# Add system context hint
|
|
185
206
|
messages.append(ContextMessage(role="system", content=context_hint))
|
|
@@ -226,6 +247,9 @@ class ContextBuilder:
|
|
|
226
247
|
"""
|
|
227
248
|
Load user profile from database and format as context.
|
|
228
249
|
|
|
250
|
+
user_id is always a UUID5 hash of email (bijection).
|
|
251
|
+
Looks up user by their id field in the database.
|
|
252
|
+
|
|
229
253
|
Returns formatted string with:
|
|
230
254
|
- User summary (generated by dreaming worker)
|
|
231
255
|
- Current projects
|
|
@@ -239,6 +263,7 @@ class ContextBuilder:
|
|
|
239
263
|
|
|
240
264
|
try:
|
|
241
265
|
user_repo = Repository(User, "users", db=db)
|
|
266
|
+
# user_id is UUID5 hash of email - look up by database id
|
|
242
267
|
user = await user_repo.get_by_id(user_id, tenant_id)
|
|
243
268
|
|
|
244
269
|
if not user:
|
rem/agentic/mcp/tool_wrapper.py
CHANGED
|
@@ -149,12 +149,23 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
149
149
|
parts = re.sub(r'_+', '_', parts).strip('_') # Clean up multiple underscores
|
|
150
150
|
func_name = f"get_{parts}"
|
|
151
151
|
|
|
152
|
+
# For parameterized URIs, append _by_{params} to avoid naming conflicts
|
|
153
|
+
# e.g., rem://agents/{name} -> get_rem_agents_by_name (distinct from get_rem_agents)
|
|
154
|
+
if template_vars:
|
|
155
|
+
param_suffix = "_by_" + "_".join(template_vars)
|
|
156
|
+
func_name = f"{func_name}{param_suffix}"
|
|
157
|
+
|
|
152
158
|
# Build description including parameter info
|
|
153
159
|
description = usage or f"Fetch {uri} resource"
|
|
154
160
|
if template_vars:
|
|
155
161
|
param_desc = ", ".join(template_vars)
|
|
156
162
|
description = f"{description}\n\nParameters: {param_desc}"
|
|
157
163
|
|
|
164
|
+
# Capture mcp_server reference at tool creation time (for closure)
|
|
165
|
+
# This ensures the correct server is used even if called later
|
|
166
|
+
_captured_mcp_server = mcp_server
|
|
167
|
+
_captured_uri = uri # Also capture URI for consistent logging
|
|
168
|
+
|
|
158
169
|
if template_vars:
|
|
159
170
|
# Template URI -> create parameterized tool
|
|
160
171
|
async def wrapper(**kwargs: Any) -> str:
|
|
@@ -162,13 +173,17 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
162
173
|
import asyncio
|
|
163
174
|
import inspect
|
|
164
175
|
|
|
176
|
+
logger.debug(f"Resource tool invoked: uri={_captured_uri}, kwargs={kwargs}, mcp_server={'set' if _captured_mcp_server else 'None'}")
|
|
177
|
+
|
|
165
178
|
# Try to resolve from MCP server's resource templates first
|
|
166
|
-
if
|
|
179
|
+
if _captured_mcp_server is not None:
|
|
167
180
|
try:
|
|
168
181
|
# Get resource templates from MCP server
|
|
169
|
-
templates = await
|
|
170
|
-
|
|
171
|
-
|
|
182
|
+
templates = await _captured_mcp_server.get_resource_templates()
|
|
183
|
+
logger.debug(f"MCP server templates: {list(templates.keys())}")
|
|
184
|
+
if _captured_uri in templates:
|
|
185
|
+
template = templates[_captured_uri]
|
|
186
|
+
logger.debug(f"Found template for {_captured_uri}, calling fn with kwargs={kwargs}")
|
|
172
187
|
# Call the template's underlying function directly
|
|
173
188
|
# The fn expects the template variables as kwargs
|
|
174
189
|
fn_result = template.fn(**kwargs)
|
|
@@ -178,17 +193,22 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
178
193
|
if isinstance(fn_result, str):
|
|
179
194
|
return fn_result
|
|
180
195
|
return json.dumps(fn_result, indent=2)
|
|
196
|
+
else:
|
|
197
|
+
logger.warning(f"Template {_captured_uri} not found in MCP server templates: {list(templates.keys())}")
|
|
181
198
|
except Exception as e:
|
|
182
|
-
logger.warning(f"Failed to resolve resource {
|
|
199
|
+
logger.warning(f"Failed to resolve resource {_captured_uri} from MCP server: {e}", exc_info=True)
|
|
200
|
+
else:
|
|
201
|
+
logger.warning(f"No MCP server provided for resource tool {_captured_uri}, using fallback")
|
|
183
202
|
|
|
184
203
|
# Fallback: substitute template variables and use load_resource
|
|
185
|
-
resolved_uri =
|
|
204
|
+
resolved_uri = _captured_uri
|
|
186
205
|
for var in template_vars:
|
|
187
206
|
if var in kwargs:
|
|
188
207
|
resolved_uri = resolved_uri.replace(f"{{{var}}}", str(kwargs[var]))
|
|
189
208
|
else:
|
|
190
209
|
return json.dumps({"error": f"Missing required parameter: {var}"})
|
|
191
210
|
|
|
211
|
+
logger.debug(f"Using fallback load_resource for resolved URI: {resolved_uri}")
|
|
192
212
|
from rem.api.mcp_router.resources import load_resource
|
|
193
213
|
result = await load_resource(resolved_uri)
|
|
194
214
|
if isinstance(result, str):
|
|
@@ -202,7 +222,7 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
202
222
|
wrapper.__annotations__ = {var: str for var in template_vars}
|
|
203
223
|
wrapper.__annotations__['return'] = str
|
|
204
224
|
|
|
205
|
-
logger.info(f"Built parameterized resource tool: {func_name} (uri: {uri}, params: {template_vars})")
|
|
225
|
+
logger.info(f"Built parameterized resource tool: {func_name} (uri: {uri}, params: {template_vars}, mcp_server={'provided' if mcp_server else 'None'})")
|
|
206
226
|
else:
|
|
207
227
|
# Concrete URI -> no-param tool
|
|
208
228
|
async def wrapper(**kwargs: Any) -> str:
|
|
@@ -213,12 +233,16 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
213
233
|
if kwargs:
|
|
214
234
|
logger.warning(f"Resource tool {func_name} called with unexpected kwargs: {list(kwargs.keys())}")
|
|
215
235
|
|
|
236
|
+
logger.debug(f"Concrete resource tool invoked: uri={_captured_uri}, mcp_server={'set' if _captured_mcp_server else 'None'}")
|
|
237
|
+
|
|
216
238
|
# Try to resolve from MCP server's resources first
|
|
217
|
-
if
|
|
239
|
+
if _captured_mcp_server is not None:
|
|
218
240
|
try:
|
|
219
|
-
resources = await
|
|
220
|
-
|
|
221
|
-
|
|
241
|
+
resources = await _captured_mcp_server.get_resources()
|
|
242
|
+
logger.debug(f"MCP server resources: {list(resources.keys())}")
|
|
243
|
+
if _captured_uri in resources:
|
|
244
|
+
resource = resources[_captured_uri]
|
|
245
|
+
logger.debug(f"Found resource for {_captured_uri}")
|
|
222
246
|
# Call the resource's underlying function
|
|
223
247
|
fn_result = resource.fn()
|
|
224
248
|
if inspect.iscoroutine(fn_result):
|
|
@@ -226,12 +250,17 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
226
250
|
if isinstance(fn_result, str):
|
|
227
251
|
return fn_result
|
|
228
252
|
return json.dumps(fn_result, indent=2)
|
|
253
|
+
else:
|
|
254
|
+
logger.warning(f"Resource {_captured_uri} not found in MCP server resources: {list(resources.keys())}")
|
|
229
255
|
except Exception as e:
|
|
230
|
-
logger.warning(f"Failed to resolve resource {
|
|
256
|
+
logger.warning(f"Failed to resolve resource {_captured_uri} from MCP server: {e}", exc_info=True)
|
|
257
|
+
else:
|
|
258
|
+
logger.warning(f"No MCP server provided for resource tool {_captured_uri}, using fallback")
|
|
231
259
|
|
|
232
260
|
# Fallback to load_resource
|
|
261
|
+
logger.debug(f"Using fallback load_resource for URI: {_captured_uri}")
|
|
233
262
|
from rem.api.mcp_router.resources import load_resource
|
|
234
|
-
result = await load_resource(
|
|
263
|
+
result = await load_resource(_captured_uri)
|
|
235
264
|
if isinstance(result, str):
|
|
236
265
|
return result
|
|
237
266
|
return json.dumps(result, indent=2)
|
|
@@ -239,6 +268,6 @@ def create_resource_tool(uri: str, usage: str = "", mcp_server: Any = None) -> T
|
|
|
239
268
|
wrapper.__name__ = func_name
|
|
240
269
|
wrapper.__doc__ = description
|
|
241
270
|
|
|
242
|
-
logger.info(f"Built resource tool: {func_name} (uri: {uri})")
|
|
271
|
+
logger.info(f"Built resource tool: {func_name} (uri: {uri}, mcp_server={'provided' if mcp_server else 'None'})")
|
|
243
272
|
|
|
244
273
|
return Tool(wrapper)
|
|
@@ -96,6 +96,35 @@ TODO:
|
|
|
96
96
|
|
|
97
97
|
Priority: HIGH (blocks production scaling beyond 50 req/sec)
|
|
98
98
|
|
|
99
|
+
4. Response Format Control (structured_output enhancement):
|
|
100
|
+
- Current: structured_output is bool (True=strict schema, False=free-form text)
|
|
101
|
+
- Missing: OpenAI JSON mode (valid JSON without strict schema enforcement)
|
|
102
|
+
- Missing: Completions API support (some models only support completions, not chat)
|
|
103
|
+
|
|
104
|
+
Proposed schema field values for `structured_output`:
|
|
105
|
+
- True (default): Strict structured output using provider's native schema support
|
|
106
|
+
- False: Free-form text response (properties converted to prompt guidance)
|
|
107
|
+
- "json": JSON mode - ensures valid JSON but no schema enforcement
|
|
108
|
+
(OpenAI: response_format={"type": "json_object"})
|
|
109
|
+
- "text": Explicit free-form text (alias for False)
|
|
110
|
+
|
|
111
|
+
Implementation:
|
|
112
|
+
a) Update AgentSchemaMetadata.structured_output type:
|
|
113
|
+
structured_output: bool | Literal["json", "text"] = True
|
|
114
|
+
b) In create_agent(), handle each mode:
|
|
115
|
+
- True: Use output_type with Pydantic model (current behavior)
|
|
116
|
+
- False/"text": Convert properties to prompt guidance (current behavior)
|
|
117
|
+
- "json": Use provider's JSON mode without strict schema
|
|
118
|
+
c) Provider-specific JSON mode:
|
|
119
|
+
- OpenAI: model_settings={"response_format": {"type": "json_object"}}
|
|
120
|
+
- Anthropic: Not supported natively, use prompt guidance
|
|
121
|
+
- Others: Fallback to prompt guidance with JSON instruction
|
|
122
|
+
|
|
123
|
+
Related: Some providers (Cerebras) have completions-only models where
|
|
124
|
+
structured output isn't available. Consider model capability detection.
|
|
125
|
+
|
|
126
|
+
Priority: MEDIUM (enables more flexible output control)
|
|
127
|
+
|
|
99
128
|
Example Agent Schema:
|
|
100
129
|
{
|
|
101
130
|
"type": "object",
|
|
@@ -553,58 +582,70 @@ async def create_agent(
|
|
|
553
582
|
if agent_schema:
|
|
554
583
|
system_prompt = get_system_prompt(agent_schema)
|
|
555
584
|
metadata = get_metadata(agent_schema)
|
|
556
|
-
mcp_server_configs = [s.model_dump() for s in metadata.mcp_servers] if hasattr(metadata, 'mcp_servers') and metadata.mcp_servers else []
|
|
557
585
|
resource_configs = metadata.resources if hasattr(metadata, 'resources') else []
|
|
558
586
|
|
|
587
|
+
# DEPRECATED: mcp_servers in agent schemas is ignored
|
|
588
|
+
# MCP servers are now always auto-detected at the application level
|
|
589
|
+
if hasattr(metadata, 'mcp_servers') and metadata.mcp_servers:
|
|
590
|
+
logger.warning(
|
|
591
|
+
"DEPRECATED: mcp_servers in agent schema is ignored. "
|
|
592
|
+
"MCP servers are auto-detected from tools.mcp_server module. "
|
|
593
|
+
"Remove mcp_servers from your agent schema."
|
|
594
|
+
)
|
|
595
|
+
|
|
559
596
|
if metadata.system_prompt:
|
|
560
597
|
logger.debug("Using custom system_prompt from json_schema_extra")
|
|
561
598
|
else:
|
|
562
599
|
system_prompt = ""
|
|
563
600
|
metadata = None
|
|
564
|
-
mcp_server_configs = []
|
|
565
601
|
resource_configs = []
|
|
566
602
|
|
|
567
|
-
# Auto-detect
|
|
568
|
-
#
|
|
603
|
+
# Auto-detect MCP server at application level
|
|
604
|
+
# Convention: tools/mcp_server.py exports `mcp` FastMCP instance
|
|
605
|
+
# Falls back to REM's built-in MCP server if no local server found
|
|
606
|
+
import importlib
|
|
607
|
+
import os
|
|
608
|
+
import sys
|
|
609
|
+
|
|
610
|
+
# Ensure current working directory is in sys.path for local imports
|
|
611
|
+
cwd = os.getcwd()
|
|
612
|
+
if cwd not in sys.path:
|
|
613
|
+
sys.path.insert(0, cwd)
|
|
614
|
+
|
|
615
|
+
mcp_server_configs = []
|
|
616
|
+
auto_detect_modules = [
|
|
617
|
+
"tools.mcp_server", # Convention: tools/mcp_server.py
|
|
618
|
+
"mcp_server", # Alternative: mcp_server.py in root
|
|
619
|
+
]
|
|
620
|
+
for module_path in auto_detect_modules:
|
|
621
|
+
try:
|
|
622
|
+
mcp_module = importlib.import_module(module_path)
|
|
623
|
+
if hasattr(mcp_module, "mcp"):
|
|
624
|
+
logger.info(f"Auto-detected local MCP server: {module_path}")
|
|
625
|
+
mcp_server_configs = [{"type": "local", "module": module_path, "id": "auto-detected"}]
|
|
626
|
+
break
|
|
627
|
+
except ImportError as e:
|
|
628
|
+
logger.debug(f"MCP server auto-detect: {module_path} not found ({e})")
|
|
629
|
+
continue
|
|
630
|
+
except Exception as e:
|
|
631
|
+
logger.warning(f"MCP server auto-detect: {module_path} failed to load: {e}")
|
|
632
|
+
continue
|
|
633
|
+
|
|
634
|
+
# Fall back to REM's default MCP server if no local server found
|
|
569
635
|
if not mcp_server_configs:
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
import sys
|
|
573
|
-
|
|
574
|
-
# Ensure current working directory is in sys.path for local imports
|
|
575
|
-
cwd = os.getcwd()
|
|
576
|
-
if cwd not in sys.path:
|
|
577
|
-
sys.path.insert(0, cwd)
|
|
578
|
-
|
|
579
|
-
# Try common local MCP server module paths first
|
|
580
|
-
auto_detect_modules = [
|
|
581
|
-
"tools.mcp_server", # Convention: tools/mcp_server.py
|
|
582
|
-
"mcp_server", # Alternative: mcp_server.py in root
|
|
583
|
-
]
|
|
584
|
-
for module_path in auto_detect_modules:
|
|
585
|
-
try:
|
|
586
|
-
mcp_module = importlib.import_module(module_path)
|
|
587
|
-
if hasattr(mcp_module, "mcp"):
|
|
588
|
-
logger.info(f"Auto-detected local MCP server: {module_path}")
|
|
589
|
-
mcp_server_configs = [{"type": "local", "module": module_path, "id": "auto-detected"}]
|
|
590
|
-
break
|
|
591
|
-
except ImportError:
|
|
592
|
-
continue
|
|
593
|
-
|
|
594
|
-
# Fall back to REM's default MCP server if no local server found
|
|
595
|
-
if not mcp_server_configs:
|
|
596
|
-
logger.debug("No local MCP server found, using REM default")
|
|
597
|
-
mcp_server_configs = [{"type": "local", "module": "rem.mcp_server", "id": "rem"}]
|
|
636
|
+
logger.info("No local MCP server found, using REM default (rem.mcp_server)")
|
|
637
|
+
mcp_server_configs = [{"type": "local", "module": "rem.mcp_server", "id": "rem"}]
|
|
598
638
|
|
|
599
639
|
# Extract temperature and max_iterations from schema metadata (with fallback to settings defaults)
|
|
600
640
|
if metadata:
|
|
601
641
|
temperature = metadata.override_temperature if metadata.override_temperature is not None else settings.llm.default_temperature
|
|
602
642
|
max_iterations = metadata.override_max_iterations if metadata.override_max_iterations is not None else settings.llm.default_max_iterations
|
|
603
|
-
|
|
643
|
+
# Use schema-level structured_output if set, otherwise fall back to global setting
|
|
644
|
+
use_structured_output = metadata.structured_output if metadata.structured_output is not None else settings.llm.default_structured_output
|
|
604
645
|
else:
|
|
605
646
|
temperature = settings.llm.default_temperature
|
|
606
647
|
max_iterations = settings.llm.default_max_iterations
|
|
607
|
-
use_structured_output =
|
|
648
|
+
use_structured_output = settings.llm.default_structured_output
|
|
608
649
|
|
|
609
650
|
# Build list of tools - start with built-in tools
|
|
610
651
|
tools = _get_builtin_tools()
|
|
@@ -727,6 +768,7 @@ async def create_agent(
|
|
|
727
768
|
|
|
728
769
|
# Create tools from collected resource URIs
|
|
729
770
|
# Pass the loaded MCP server so resources can be resolved from it
|
|
771
|
+
logger.info(f"Creating {len(resource_uris)} resource tools with mcp_server={'set' if loaded_mcp_server else 'None'}")
|
|
730
772
|
for uri, usage in resource_uris:
|
|
731
773
|
resource_tool = create_resource_tool(uri, usage, mcp_server=loaded_mcp_server)
|
|
732
774
|
tools.append(resource_tool)
|
rem/agentic/schema.py
CHANGED
|
@@ -215,12 +215,13 @@ class AgentSchemaMetadata(BaseModel):
|
|
|
215
215
|
)
|
|
216
216
|
|
|
217
217
|
# Structured output toggle
|
|
218
|
-
structured_output: bool = Field(
|
|
219
|
-
default=
|
|
218
|
+
structured_output: bool | None = Field(
|
|
219
|
+
default=None,
|
|
220
220
|
description=(
|
|
221
221
|
"Whether to enforce structured JSON output. "
|
|
222
222
|
"When False, the agent produces free-form text and schema properties "
|
|
223
|
-
"are converted to prompt guidance instead.
|
|
223
|
+
"are converted to prompt guidance instead. "
|
|
224
|
+
"Default: None (uses LLM__DEFAULT_STRUCTURED_OUTPUT setting, which defaults to False)."
|
|
224
225
|
),
|
|
225
226
|
)
|
|
226
227
|
|
rem/agentic/tools/rem_tools.py
CHANGED
|
@@ -3,6 +3,17 @@ REM tools for agent execution (CLI and API compatible).
|
|
|
3
3
|
|
|
4
4
|
These tools work in both CLI and API contexts by initializing services on-demand.
|
|
5
5
|
They wrap the service layer directly, not MCP tools.
|
|
6
|
+
|
|
7
|
+
Core tables (always available):
|
|
8
|
+
- resources: Documents, content chunks, artifacts
|
|
9
|
+
- moments: Temporal narratives extracted from resources (usually user-specific)
|
|
10
|
+
- ontologies: Domain entities with semantic links for further lookups (like a wiki)
|
|
11
|
+
|
|
12
|
+
Other tables (may vary by deployment):
|
|
13
|
+
- users, sessions, messages, files, schemas, feedbacks
|
|
14
|
+
|
|
15
|
+
Note: Not all tables are populated in all systems. Use FUZZY or SEARCH
|
|
16
|
+
to discover what data exists before assuming specific tables have content.
|
|
6
17
|
"""
|
|
7
18
|
|
|
8
19
|
from typing import Any, Literal, cast
|
rem/api/deps.py
CHANGED
|
@@ -147,7 +147,6 @@ def is_admin(user: dict | None) -> bool:
|
|
|
147
147
|
async def get_user_filter(
|
|
148
148
|
request: Request,
|
|
149
149
|
x_user_id: str | None = None,
|
|
150
|
-
x_tenant_id: str = "default",
|
|
151
150
|
) -> dict[str, Any]:
|
|
152
151
|
"""
|
|
153
152
|
Get user-scoped filter dict for database queries.
|
|
@@ -158,7 +157,6 @@ async def get_user_filter(
|
|
|
158
157
|
Args:
|
|
159
158
|
request: FastAPI request
|
|
160
159
|
x_user_id: Optional user_id filter (admin only for cross-user)
|
|
161
|
-
x_tenant_id: Tenant ID for multi-tenancy
|
|
162
160
|
|
|
163
161
|
Returns:
|
|
164
162
|
Filter dict with appropriate user_id constraint
|
|
@@ -169,7 +167,7 @@ async def get_user_filter(
|
|
|
169
167
|
return await repo.find(filters)
|
|
170
168
|
"""
|
|
171
169
|
user = get_current_user(request)
|
|
172
|
-
filters: dict[str, Any] = {
|
|
170
|
+
filters: dict[str, Any] = {}
|
|
173
171
|
|
|
174
172
|
if is_admin(user):
|
|
175
173
|
# Admin can filter by any user or see all
|
rem/api/main.py
CHANGED
|
@@ -149,19 +149,38 @@ class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
|
149
149
|
client_host = request.client.host if request.client else "unknown"
|
|
150
150
|
user_agent = request.headers.get('user-agent', 'unknown')[:100]
|
|
151
151
|
|
|
152
|
+
# Extract auth info for logging (first 8 chars of token for debugging)
|
|
153
|
+
auth_header = request.headers.get('authorization', '')
|
|
154
|
+
auth_preview = ""
|
|
155
|
+
if auth_header.startswith('Bearer '):
|
|
156
|
+
token = auth_header[7:]
|
|
157
|
+
auth_preview = f"Bearer {token[:8]}..." if len(token) > 8 else f"Bearer {token}"
|
|
158
|
+
|
|
152
159
|
# Process request
|
|
153
160
|
response = await call_next(request)
|
|
154
161
|
|
|
162
|
+
# Extract user info set by auth middleware (after processing)
|
|
163
|
+
user = getattr(request.state, "user", None)
|
|
164
|
+
user_id = user.get("id", "none")[:12] if user else "anon"
|
|
165
|
+
user_email = user.get("email", "") if user else ""
|
|
166
|
+
|
|
155
167
|
# Determine log level based on path AND response status
|
|
156
168
|
duration_ms = (time.time() - start_time) * 1000
|
|
157
169
|
use_debug = self._should_log_at_debug(path, response.status_code)
|
|
158
170
|
log_fn = logger.debug if use_debug else logger.info
|
|
159
171
|
|
|
160
|
-
#
|
|
172
|
+
# Build user info string
|
|
173
|
+
user_info = f"user={user_id}"
|
|
174
|
+
if user_email:
|
|
175
|
+
user_info += f" ({user_email})"
|
|
176
|
+
if auth_preview:
|
|
177
|
+
user_info += f" | auth={auth_preview}"
|
|
178
|
+
|
|
179
|
+
# Log request and response together with auth info
|
|
161
180
|
log_fn(
|
|
162
181
|
f"→ REQUEST: {request.method} {path} | "
|
|
163
182
|
f"Client: {client_host} | "
|
|
164
|
-
f"
|
|
183
|
+
f"{user_info}"
|
|
165
184
|
)
|
|
166
185
|
log_fn(
|
|
167
186
|
f"← RESPONSE: {request.method} {path} | "
|