remdb 0.3.127__py3-none-any.whl → 0.3.172__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.

Files changed (62) hide show
  1. rem/agentic/agents/__init__.py +16 -0
  2. rem/agentic/agents/agent_manager.py +311 -0
  3. rem/agentic/context.py +81 -3
  4. rem/agentic/context_builder.py +36 -9
  5. rem/agentic/mcp/tool_wrapper.py +132 -15
  6. rem/agentic/providers/phoenix.py +371 -108
  7. rem/agentic/providers/pydantic_ai.py +163 -45
  8. rem/agentic/schema.py +8 -4
  9. rem/api/deps.py +3 -5
  10. rem/api/main.py +22 -3
  11. rem/api/mcp_router/resources.py +15 -10
  12. rem/api/mcp_router/server.py +2 -0
  13. rem/api/mcp_router/tools.py +94 -2
  14. rem/api/middleware/tracking.py +5 -5
  15. rem/api/routers/auth.py +349 -6
  16. rem/api/routers/chat/completions.py +5 -3
  17. rem/api/routers/chat/streaming.py +95 -22
  18. rem/api/routers/messages.py +24 -15
  19. rem/auth/__init__.py +13 -3
  20. rem/auth/jwt.py +352 -0
  21. rem/auth/middleware.py +115 -10
  22. rem/auth/providers/__init__.py +4 -1
  23. rem/auth/providers/email.py +215 -0
  24. rem/cli/commands/configure.py +3 -4
  25. rem/cli/commands/experiments.py +226 -50
  26. rem/cli/commands/session.py +336 -0
  27. rem/cli/dreaming.py +2 -2
  28. rem/cli/main.py +2 -0
  29. rem/models/core/experiment.py +58 -14
  30. rem/models/entities/__init__.py +4 -0
  31. rem/models/entities/ontology.py +1 -1
  32. rem/models/entities/ontology_config.py +1 -1
  33. rem/models/entities/subscriber.py +175 -0
  34. rem/models/entities/user.py +1 -0
  35. rem/schemas/agents/core/agent-builder.yaml +235 -0
  36. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  37. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  38. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  39. rem/services/__init__.py +3 -1
  40. rem/services/content/service.py +4 -3
  41. rem/services/email/__init__.py +10 -0
  42. rem/services/email/service.py +513 -0
  43. rem/services/email/templates.py +360 -0
  44. rem/services/postgres/README.md +38 -0
  45. rem/services/postgres/diff_service.py +19 -3
  46. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  47. rem/services/postgres/repository.py +5 -4
  48. rem/services/session/compression.py +113 -50
  49. rem/services/session/reload.py +14 -7
  50. rem/services/user_service.py +41 -9
  51. rem/settings.py +292 -5
  52. rem/sql/migrations/001_install.sql +1 -1
  53. rem/sql/migrations/002_install_models.sql +91 -91
  54. rem/sql/migrations/005_schema_update.sql +145 -0
  55. rem/utils/README.md +45 -0
  56. rem/utils/files.py +157 -1
  57. rem/utils/schema_loader.py +45 -7
  58. rem/utils/vision.py +1 -1
  59. {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/METADATA +7 -5
  60. {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/RECORD +62 -52
  61. {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/WHEEL +0 -0
  62. {remdb-0.3.127.dist-info → remdb-0.3.172.dist-info}/entry_points.txt +0 -0
@@ -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",
@@ -550,10 +579,18 @@ async def create_agent(
550
579
  # Extract schema fields using typed helpers
551
580
  from ..schema import get_system_prompt, get_metadata
552
581
 
582
+ # Track whether mcp_servers was explicitly configured (even if empty)
583
+ mcp_servers_explicitly_set = False
584
+
553
585
  if agent_schema:
554
586
  system_prompt = get_system_prompt(agent_schema)
555
587
  metadata = get_metadata(agent_schema)
556
- mcp_server_configs = [s.model_dump() for s in metadata.mcp_servers] if hasattr(metadata, 'mcp_servers') else []
588
+ # Check if mcp_servers was explicitly set (could be empty list to disable)
589
+ if hasattr(metadata, 'mcp_servers') and metadata.mcp_servers is not None:
590
+ mcp_server_configs = [s.model_dump() for s in metadata.mcp_servers]
591
+ mcp_servers_explicitly_set = True
592
+ else:
593
+ mcp_server_configs = []
557
594
  resource_configs = metadata.resources if hasattr(metadata, 'resources') else []
558
595
 
559
596
  if metadata.system_prompt:
@@ -564,15 +601,49 @@ async def create_agent(
564
601
  mcp_server_configs = []
565
602
  resource_configs = []
566
603
 
604
+ # Auto-detect local MCP server if not explicitly configured
605
+ # This makes mcp_servers config optional - agents get tools automatically
606
+ # But if mcp_servers: [] is explicitly set, respect that (no auto-detection)
607
+ if not mcp_server_configs and not mcp_servers_explicitly_set:
608
+ import importlib
609
+ import os
610
+ import sys
611
+
612
+ # Ensure current working directory is in sys.path for local imports
613
+ cwd = os.getcwd()
614
+ if cwd not in sys.path:
615
+ sys.path.insert(0, cwd)
616
+
617
+ # Try common local MCP server module paths first
618
+ auto_detect_modules = [
619
+ "tools.mcp_server", # Convention: tools/mcp_server.py
620
+ "mcp_server", # Alternative: mcp_server.py in root
621
+ ]
622
+ for module_path in auto_detect_modules:
623
+ try:
624
+ mcp_module = importlib.import_module(module_path)
625
+ if hasattr(mcp_module, "mcp"):
626
+ logger.info(f"Auto-detected local MCP server: {module_path}")
627
+ mcp_server_configs = [{"type": "local", "module": module_path, "id": "auto-detected"}]
628
+ break
629
+ except ImportError:
630
+ continue
631
+
632
+ # Fall back to REM's default MCP server if no local server found
633
+ if not mcp_server_configs:
634
+ logger.debug("No local MCP server found, using REM default")
635
+ mcp_server_configs = [{"type": "local", "module": "rem.mcp_server", "id": "rem"}]
636
+
567
637
  # Extract temperature and max_iterations from schema metadata (with fallback to settings defaults)
568
638
  if metadata:
569
639
  temperature = metadata.override_temperature if metadata.override_temperature is not None else settings.llm.default_temperature
570
640
  max_iterations = metadata.override_max_iterations if metadata.override_max_iterations is not None else settings.llm.default_max_iterations
571
- use_structured_output = metadata.structured_output
641
+ # Use schema-level structured_output if set, otherwise fall back to global setting
642
+ use_structured_output = metadata.structured_output if metadata.structured_output is not None else settings.llm.default_structured_output
572
643
  else:
573
644
  temperature = settings.llm.default_temperature
574
645
  max_iterations = settings.llm.default_max_iterations
575
- use_structured_output = True
646
+ use_structured_output = settings.llm.default_structured_output
576
647
 
577
648
  # Build list of tools - start with built-in tools
578
649
  tools = _get_builtin_tools()
@@ -608,50 +679,97 @@ async def create_agent(
608
679
  search_rem_suffix += f"Example: `SEARCH \"your query\" FROM {default_table} LIMIT 10`"
609
680
 
610
681
  # Add tools from MCP server (in-process, no subprocess)
611
- if mcp_server_configs:
612
- for server_config in mcp_server_configs:
613
- server_type = server_config.get("type")
614
- server_id = server_config.get("id", "mcp-server")
615
-
616
- if server_type == "local":
617
- # Import MCP server directly (in-process)
618
- module_path = server_config.get("module", "rem.mcp_server")
619
-
620
- try:
621
- # Dynamic import of MCP server module
622
- import importlib
623
- mcp_module = importlib.import_module(module_path)
624
- mcp_server = mcp_module.mcp
625
-
626
- # Extract tools from MCP server (get_tools is async)
627
- from ..mcp.tool_wrapper import create_mcp_tool_wrapper
628
-
629
- # Await async get_tools() call
630
- mcp_tools_dict = await mcp_server.get_tools()
631
-
632
- for tool_name, tool_func in mcp_tools_dict.items():
633
- # Add description suffix to search_rem tool if schema specifies a default table
634
- tool_suffix = search_rem_suffix if tool_name == "search_rem" else None
635
-
636
- wrapped_tool = create_mcp_tool_wrapper(
637
- tool_name,
638
- tool_func,
639
- user_id=context.user_id if context else None,
640
- description_suffix=tool_suffix,
641
- )
642
- tools.append(wrapped_tool)
643
- logger.debug(f"Loaded MCP tool: {tool_name}" + (" (with schema suffix)" if tool_suffix else ""))
644
-
645
- logger.info(f"Loaded {len(mcp_tools_dict)} tools from MCP server: {server_id} (in-process)")
646
-
647
- except Exception as e:
648
- logger.error(f"Failed to load MCP server {server_id}: {e}", exc_info=True)
649
- else:
650
- logger.warning(f"Unsupported MCP server type: {server_type}")
682
+ # Track loaded MCP servers for resource resolution
683
+ loaded_mcp_server = None
684
+
685
+ for server_config in mcp_server_configs:
686
+ server_type = server_config.get("type")
687
+ server_id = server_config.get("id", "mcp-server")
688
+
689
+ if server_type == "local":
690
+ # Import MCP server directly (in-process)
691
+ module_path = server_config.get("module", "rem.mcp_server")
692
+
693
+ try:
694
+ # Dynamic import of MCP server module
695
+ import importlib
696
+ mcp_module = importlib.import_module(module_path)
697
+ mcp_server = mcp_module.mcp
698
+
699
+ # Store the loaded server for resource resolution
700
+ loaded_mcp_server = mcp_server
701
+
702
+ # Extract tools from MCP server (get_tools is async)
703
+ from ..mcp.tool_wrapper import create_mcp_tool_wrapper
704
+
705
+ # Await async get_tools() call
706
+ mcp_tools_dict = await mcp_server.get_tools()
651
707
 
708
+ for tool_name, tool_func in mcp_tools_dict.items():
709
+ # Add description suffix to search_rem tool if schema specifies a default table
710
+ tool_suffix = search_rem_suffix if tool_name == "search_rem" else None
711
+
712
+ wrapped_tool = create_mcp_tool_wrapper(
713
+ tool_name,
714
+ tool_func,
715
+ user_id=context.user_id if context else None,
716
+ description_suffix=tool_suffix,
717
+ )
718
+ tools.append(wrapped_tool)
719
+ logger.debug(f"Loaded MCP tool: {tool_name}" + (" (with schema suffix)" if tool_suffix else ""))
720
+
721
+ logger.info(f"Loaded {len(mcp_tools_dict)} tools from MCP server: {server_id} (in-process)")
722
+
723
+ except Exception as e:
724
+ logger.error(f"Failed to load MCP server {server_id}: {e}", exc_info=True)
725
+ else:
726
+ logger.warning(f"Unsupported MCP server type: {server_type}")
727
+
728
+ # Convert resources to tools (MCP convenience syntax)
729
+ # Resources declared in agent YAML become callable tools - eliminates
730
+ # the artificial MCP distinction between tools and resources
731
+ #
732
+ # Supports both concrete and template URIs:
733
+ # - Concrete: "rem://schemas" -> no-param tool
734
+ # - Template: "patient-profile://field/{field_key}" -> tool with field_key param
735
+ from ..mcp.tool_wrapper import create_resource_tool
736
+
737
+ # Collect all resource URIs from both resources section AND tools section
738
+ resource_uris = []
739
+
740
+ # From resources section (legacy format)
652
741
  if resource_configs:
653
- # TODO: Convert resources to tools (MCP convenience syntax)
654
- pass
742
+ for resource_config in resource_configs:
743
+ if hasattr(resource_config, 'uri'):
744
+ uri = resource_config.uri
745
+ usage = resource_config.description or ""
746
+ else:
747
+ uri = resource_config.get("uri", "")
748
+ usage = resource_config.get("description", "")
749
+ if uri:
750
+ resource_uris.append((uri, usage))
751
+
752
+ # From tools section - detect URIs (anything with ://)
753
+ # This allows unified syntax: resources as tools
754
+ tool_configs = metadata.tools if metadata and hasattr(metadata, 'tools') else []
755
+ for tool_config in tool_configs:
756
+ if hasattr(tool_config, 'name'):
757
+ tool_name = tool_config.name
758
+ tool_desc = tool_config.description or ""
759
+ else:
760
+ tool_name = tool_config.get("name", "")
761
+ tool_desc = tool_config.get("description", "")
762
+
763
+ # Auto-detect resource URIs (anything with :// scheme)
764
+ if "://" in tool_name:
765
+ resource_uris.append((tool_name, tool_desc))
766
+
767
+ # Create tools from collected resource URIs
768
+ # Pass the loaded MCP server so resources can be resolved from it
769
+ for uri, usage in resource_uris:
770
+ resource_tool = create_resource_tool(uri, usage, mcp_server=loaded_mcp_server)
771
+ tools.append(resource_tool)
772
+ logger.debug(f"Loaded resource as tool: {uri}")
655
773
 
656
774
  # Create dynamic result_type from schema if not provided
657
775
  # Note: use_structured_output is set earlier from metadata.structured_output
rem/agentic/schema.py CHANGED
@@ -154,8 +154,10 @@ class MCPServerConfig(BaseModel):
154
154
  )
155
155
 
156
156
  id: str = Field(
157
+ default="mcp-server",
157
158
  description=(
158
159
  "Server identifier for logging and debugging. "
160
+ "Defaults to 'mcp-server' if not specified. "
159
161
  "Example: 'rem-local'"
160
162
  )
161
163
  )
@@ -213,12 +215,13 @@ class AgentSchemaMetadata(BaseModel):
213
215
  )
214
216
 
215
217
  # Structured output toggle
216
- structured_output: bool = Field(
217
- default=True,
218
+ structured_output: bool | None = Field(
219
+ default=None,
218
220
  description=(
219
221
  "Whether to enforce structured JSON output. "
220
222
  "When False, the agent produces free-form text and schema properties "
221
- "are converted to prompt guidance instead. Default: True (JSON output)."
223
+ "are converted to prompt guidance instead. "
224
+ "Default: None (uses LLM__DEFAULT_STRUCTURED_OUTPUT setting, which defaults to False)."
222
225
  ),
223
226
  )
224
227
 
@@ -228,7 +231,8 @@ class AgentSchemaMetadata(BaseModel):
228
231
  description=(
229
232
  "MCP server configurations for dynamic tool loading. "
230
233
  "Servers are loaded in-process at agent creation time. "
231
- "All tools from configured servers become available to the agent."
234
+ "All tools from configured servers become available to the agent. "
235
+ "If not specified, defaults to rem.mcp_server (REM's built-in tools)."
232
236
  ),
233
237
  )
234
238
 
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] = {"tenant_id": x_tenant_id}
170
+ filters: dict[str, Any] = {}
173
171
 
174
172
  if is_admin(user):
175
173
  # Admin can filter by any user or see all
@@ -185,8 +183,8 @@ async def get_user_filter(
185
183
  f"User {user.get('email')} attempted to filter by user_id={x_user_id}"
186
184
  )
187
185
  else:
188
- # Anonymous: could use anonymous tracking ID or restrict access
189
- # For now, anonymous can't access user-scoped data
186
+ # Anonymous: use anonymous tracking ID
187
+ # Note: user_id should come from JWT, not from parameters
190
188
  anon_id = getattr(request.state, "anon_id", None)
191
189
  if anon_id:
192
190
  filters["user_id"] = f"anon:{anon_id}"
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
- # Log request and response together
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"User-Agent: {user_agent}"
183
+ f"{user_info}"
165
184
  )
166
185
  log_fn(
167
186
  f"← RESPONSE: {request.method} {path} | "
@@ -304,7 +323,7 @@ def create_app() -> FastAPI:
304
323
  app.add_middleware(
305
324
  AuthMiddleware,
306
325
  protected_paths=["/api/v1"],
307
- excluded_paths=["/api/auth", "/api/dev", "/api/v1/mcp/auth"],
326
+ excluded_paths=["/api/auth", "/api/dev", "/api/v1/mcp/auth", "/api/v1/slack"],
308
327
  # Allow anonymous when auth is disabled, otherwise use setting
309
328
  allow_anonymous=(not settings.auth.enabled) or settings.auth.allow_anonymous,
310
329
  # MCP requires auth only when auth is fully enabled
@@ -512,9 +512,10 @@ async def load_resource(uri: str) -> dict | str:
512
512
  Raises:
513
513
  ValueError: If URI is invalid or resource not found
514
514
  """
515
- # Create temporary MCP instance with resources
515
+ import inspect
516
516
  from fastmcp import FastMCP
517
517
 
518
+ # Create temporary MCP instance with resources
518
519
  mcp = FastMCP(name="temp")
519
520
 
520
521
  # Register all resources
@@ -523,14 +524,18 @@ async def load_resource(uri: str) -> dict | str:
523
524
  register_file_resources(mcp)
524
525
  register_status_resources(mcp)
525
526
 
526
- # Get resource handlers from MCP internal registry
527
- # FastMCP stores resources in a dict by URI
528
- if hasattr(mcp, "_resources"):
529
- if uri in mcp._resources:
530
- handler = mcp._resources[uri]
531
- if callable(handler):
532
- result = handler()
533
- return result if result else {"error": "Resource returned None"}
527
+ # Get resources using FastMCP's async get_resources() method
528
+ resources = await mcp.get_resources()
529
+
530
+ if uri in resources:
531
+ resource = resources[uri]
532
+ # Call the underlying function
533
+ result = resource.fn()
534
+ # Handle async functions
535
+ if inspect.iscoroutine(result):
536
+ result = await result
537
+ return result if result else {"error": "Resource returned None"}
534
538
 
535
539
  # If not found, raise error
536
- raise ValueError(f"Resource not found: {uri}. Available resources: {list(mcp._resources.keys()) if hasattr(mcp, '_resources') else 'unknown'}")
540
+ available = list(resources.keys())
541
+ raise ValueError(f"Resource not found: {uri}. Available resources: {available}")
@@ -182,6 +182,7 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
182
182
  list_schema,
183
183
  read_resource,
184
184
  register_metadata,
185
+ save_agent,
185
186
  search_rem,
186
187
  )
187
188
 
@@ -191,6 +192,7 @@ def create_mcp_server(is_local: bool = False) -> FastMCP:
191
192
  mcp.tool()(register_metadata)
192
193
  mcp.tool()(list_schema)
193
194
  mcp.tool()(get_schema)
195
+ mcp.tool()(save_agent)
194
196
 
195
197
  # File ingestion tool (with local path support for local servers)
196
198
  # Wrap to inject is_local parameter
@@ -116,7 +116,8 @@ def mcp_tool_error_handler(func: Callable) -> Callable:
116
116
  # Otherwise wrap in success response
117
117
  return {"status": "success", **result}
118
118
  except Exception as e:
119
- logger.error(f"{func.__name__} failed: {e}", exc_info=True)
119
+ # Use %s format to avoid issues with curly braces in error messages
120
+ logger.opt(exception=True).error("{} failed: {}", func.__name__, str(e))
120
121
  return {
121
122
  "status": "error",
122
123
  "error": str(e),
@@ -380,9 +381,10 @@ async def ask_rem_agent(
380
381
  from ...utils.schema_loader import load_agent_schema
381
382
 
382
383
  # Create agent context
384
+ # Note: tenant_id defaults to "default" if user_id is None
383
385
  context = AgentContext(
384
386
  user_id=user_id,
385
- tenant_id=user_id, # Set tenant_id to user_id for backward compat
387
+ tenant_id=user_id or "default", # Use default tenant for anonymous users
386
388
  default_model=settings.llm.default_model,
387
389
  )
388
390
 
@@ -1040,3 +1042,93 @@ async def get_schema(
1040
1042
  logger.info(f"Retrieved schema for table '{table_name}' with {len(column_defs)} columns")
1041
1043
 
1042
1044
  return result
1045
+
1046
+
1047
+ @mcp_tool_error_handler
1048
+ async def save_agent(
1049
+ name: str,
1050
+ description: str,
1051
+ properties: dict[str, Any] | None = None,
1052
+ required: list[str] | None = None,
1053
+ tools: list[str] | None = None,
1054
+ tags: list[str] | None = None,
1055
+ version: str = "1.0.0",
1056
+ user_id: str | None = None,
1057
+ ) -> dict[str, Any]:
1058
+ """
1059
+ Save an agent schema to REM, making it available for use.
1060
+
1061
+ This tool creates or updates an agent definition in the user's schema space.
1062
+ The agent becomes immediately available for conversations.
1063
+
1064
+ **Default Tools**: All agents automatically get `search_rem` and `register_metadata`
1065
+ tools unless explicitly overridden.
1066
+
1067
+ Args:
1068
+ name: Agent name in kebab-case (e.g., "code-reviewer", "sales-assistant").
1069
+ Must be unique within the user's schema space.
1070
+ description: The agent's system prompt. This is the full instruction set
1071
+ that defines the agent's behavior, personality, and capabilities.
1072
+ Use markdown formatting for structure.
1073
+ properties: Output schema properties as a dict. Each property should have:
1074
+ - type: "string", "number", "boolean", "array", "object"
1075
+ - description: What this field captures
1076
+ Example: {"answer": {"type": "string", "description": "Response to user"}}
1077
+ If not provided, defaults to a simple {"answer": {"type": "string"}} schema.
1078
+ required: List of required property names. Defaults to ["answer"] if not provided.
1079
+ tools: List of tool names the agent can use. Defaults to ["search_rem", "register_metadata"].
1080
+ tags: Optional tags for categorizing the agent.
1081
+ version: Semantic version string (default: "1.0.0").
1082
+ user_id: User identifier for scoping. Uses authenticated user if not provided.
1083
+
1084
+ Returns:
1085
+ Dict with:
1086
+ - status: "success" or "error"
1087
+ - agent_name: Name of the saved agent
1088
+ - version: Version saved
1089
+ - message: Human-readable status
1090
+
1091
+ Examples:
1092
+ # Create a simple agent
1093
+ save_agent(
1094
+ name="greeting-bot",
1095
+ description="You are a friendly greeter. Say hello warmly.",
1096
+ properties={"answer": {"type": "string", "description": "Greeting message"}},
1097
+ required=["answer"]
1098
+ )
1099
+
1100
+ # Create agent with structured output
1101
+ save_agent(
1102
+ name="sentiment-analyzer",
1103
+ description="Analyze sentiment of text provided by the user.",
1104
+ properties={
1105
+ "answer": {"type": "string", "description": "Analysis explanation"},
1106
+ "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
1107
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1}
1108
+ },
1109
+ required=["answer", "sentiment"],
1110
+ tags=["analysis", "nlp"]
1111
+ )
1112
+ """
1113
+ from ...agentic.agents.agent_manager import save_agent as _save_agent
1114
+
1115
+ # Get user_id from context if not provided
1116
+ user_id = AgentContext.get_user_id_or_default(user_id, source="save_agent")
1117
+
1118
+ # Delegate to agent_manager
1119
+ result = await _save_agent(
1120
+ name=name,
1121
+ description=description,
1122
+ user_id=user_id,
1123
+ properties=properties,
1124
+ required=required,
1125
+ tools=tools,
1126
+ tags=tags,
1127
+ version=version,
1128
+ )
1129
+
1130
+ # Add helpful message for Slack users
1131
+ if result.get("status") == "success":
1132
+ result["message"] = f"Agent '{name}' saved. Use `/custom-agent {name}` to chat with it."
1133
+
1134
+ return result
@@ -102,14 +102,14 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
102
102
  # Tenant ID from header or default
103
103
  tenant_id = request.headers.get("X-Tenant-Id", "default")
104
104
 
105
- # 4. Rate Limiting
106
- if settings.postgres.enabled:
105
+ # 4. Rate Limiting (skip if disabled via settings)
106
+ if settings.postgres.enabled and settings.api.rate_limit_enabled:
107
107
  is_allowed, current, limit = await self.rate_limiter.check_rate_limit(
108
108
  tenant_id=tenant_id,
109
109
  identifier=identifier,
110
110
  tier=tier
111
111
  )
112
-
112
+
113
113
  if not is_allowed:
114
114
  return JSONResponse(
115
115
  status_code=429,
@@ -141,8 +141,8 @@ class AnonymousTrackingMiddleware(BaseHTTPMiddleware):
141
141
  secure=settings.environment == "production"
142
142
  )
143
143
 
144
- # Add Rate Limit headers
145
- if settings.postgres.enabled and 'limit' in locals():
144
+ # Add Rate Limit headers (only if rate limiting is enabled)
145
+ if settings.postgres.enabled and settings.api.rate_limit_enabled and 'limit' in locals():
146
146
  response.headers["X-RateLimit-Limit"] = str(limit)
147
147
  response.headers["X-RateLimit-Remaining"] = str(max(0, limit - current))
148
148