remdb 0.3.133__py3-none-any.whl → 0.3.171__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.
Files changed (60) 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 +54 -6
  6. rem/agentic/providers/phoenix.py +91 -21
  7. rem/agentic/providers/pydantic_ai.py +88 -45
  8. rem/api/deps.py +3 -5
  9. rem/api/main.py +22 -3
  10. rem/api/mcp_router/server.py +2 -0
  11. rem/api/mcp_router/tools.py +94 -2
  12. rem/api/middleware/tracking.py +5 -5
  13. rem/api/routers/auth.py +349 -6
  14. rem/api/routers/chat/completions.py +5 -3
  15. rem/api/routers/chat/streaming.py +95 -22
  16. rem/api/routers/messages.py +24 -15
  17. rem/auth/__init__.py +13 -3
  18. rem/auth/jwt.py +352 -0
  19. rem/auth/middleware.py +115 -10
  20. rem/auth/providers/__init__.py +4 -1
  21. rem/auth/providers/email.py +215 -0
  22. rem/cli/commands/configure.py +3 -4
  23. rem/cli/commands/experiments.py +50 -49
  24. rem/cli/commands/session.py +336 -0
  25. rem/cli/dreaming.py +2 -2
  26. rem/cli/main.py +2 -0
  27. rem/models/core/experiment.py +4 -14
  28. rem/models/entities/__init__.py +4 -0
  29. rem/models/entities/ontology.py +1 -1
  30. rem/models/entities/ontology_config.py +1 -1
  31. rem/models/entities/subscriber.py +175 -0
  32. rem/models/entities/user.py +1 -0
  33. rem/schemas/agents/core/agent-builder.yaml +235 -0
  34. rem/schemas/agents/examples/contract-analyzer.yaml +1 -1
  35. rem/schemas/agents/examples/contract-extractor.yaml +1 -1
  36. rem/schemas/agents/examples/cv-parser.yaml +1 -1
  37. rem/services/__init__.py +3 -1
  38. rem/services/content/service.py +4 -3
  39. rem/services/email/__init__.py +10 -0
  40. rem/services/email/service.py +513 -0
  41. rem/services/email/templates.py +360 -0
  42. rem/services/postgres/README.md +38 -0
  43. rem/services/postgres/diff_service.py +19 -3
  44. rem/services/postgres/pydantic_to_sqlalchemy.py +45 -13
  45. rem/services/postgres/repository.py +5 -4
  46. rem/services/session/compression.py +113 -50
  47. rem/services/session/reload.py +14 -7
  48. rem/services/user_service.py +41 -9
  49. rem/settings.py +200 -5
  50. rem/sql/migrations/001_install.sql +1 -1
  51. rem/sql/migrations/002_install_models.sql +91 -91
  52. rem/sql/migrations/005_schema_update.sql +145 -0
  53. rem/utils/README.md +45 -0
  54. rem/utils/files.py +157 -1
  55. rem/utils/schema_loader.py +45 -7
  56. rem/utils/vision.py +1 -1
  57. {remdb-0.3.133.dist-info → remdb-0.3.171.dist-info}/METADATA +7 -5
  58. {remdb-0.3.133.dist-info → remdb-0.3.171.dist-info}/RECORD +60 -50
  59. {remdb-0.3.133.dist-info → remdb-0.3.171.dist-info}/WHEEL +0 -0
  60. {remdb-0.3.133.dist-info → remdb-0.3.171.dist-info}/entry_points.txt +0 -0
@@ -94,6 +94,82 @@ def _check_phoenix_available() -> bool:
94
94
  return PHOENIX_AVAILABLE
95
95
 
96
96
 
97
+ def validate_evaluator_credentials(
98
+ model_name: str | None = None,
99
+ ) -> tuple[bool, str | None]:
100
+ """Validate that the evaluator's LLM provider has working credentials.
101
+
102
+ Performs a minimal API call to verify credentials before running experiments.
103
+ This prevents running expensive agent tasks only to have evaluations fail.
104
+
105
+ Args:
106
+ model_name: Model to validate (defaults to claude-sonnet-4-5-20250929)
107
+
108
+ Returns:
109
+ Tuple of (success: bool, error_message: str | None)
110
+ - (True, None) if credentials are valid
111
+ - (False, "error description") if validation fails
112
+
113
+ Example:
114
+ >>> success, error = validate_evaluator_credentials()
115
+ >>> if not success:
116
+ ... print(f"Evaluator validation failed: {error}")
117
+ ... return
118
+ """
119
+ if not _check_phoenix_available():
120
+ return False, "arize-phoenix package not installed"
121
+
122
+ from phoenix.evals import OpenAIModel, AnthropicModel
123
+
124
+ # Default model (check env var first)
125
+ if model_name is None:
126
+ import os
127
+ model_name = os.environ.get("EVALUATOR_MODEL", "claude-sonnet-4-5-20250929")
128
+
129
+ # Parse provider
130
+ if ":" in model_name:
131
+ provider, phoenix_model_name = model_name.split(":", 1)
132
+ else:
133
+ if model_name.startswith("claude"):
134
+ provider = "anthropic"
135
+ else:
136
+ provider = "openai"
137
+ phoenix_model_name = model_name
138
+
139
+ try:
140
+ # Create LLM wrapper
141
+ if provider.lower() == "anthropic":
142
+ llm = AnthropicModel(
143
+ model=phoenix_model_name,
144
+ temperature=0.0,
145
+ top_p=None,
146
+ )
147
+ else:
148
+ llm = OpenAIModel(model=phoenix_model_name, temperature=0.0)
149
+
150
+ # Test with minimal prompt
151
+ logger.info(f"Validating evaluator credentials for {provider}:{phoenix_model_name}")
152
+ response = llm("Say 'ok' if you can read this.")
153
+
154
+ if response and len(response) > 0:
155
+ logger.info(f"Evaluator credentials validated successfully for {provider}")
156
+ return True, None
157
+ else:
158
+ return False, f"Empty response from {provider} model"
159
+
160
+ except Exception as e:
161
+ error_msg = str(e)
162
+ # Extract meaningful error from common API errors
163
+ if "credit balance is too low" in error_msg.lower():
164
+ return False, f"Anthropic API credits exhausted. Add credits at https://console.anthropic.com/settings/billing"
165
+ elif "api key" in error_msg.lower() or "authentication" in error_msg.lower():
166
+ return False, f"{provider.capitalize()} API key missing or invalid. Set ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable."
167
+ elif "rate limit" in error_msg.lower():
168
+ return False, f"{provider.capitalize()} rate limit exceeded. Wait and retry."
169
+ else:
170
+ return False, f"{provider.capitalize()} API error: {error_msg[:200]}"
171
+
172
+
97
173
  # =============================================================================
98
174
  # NAME SANITIZATION
99
175
  # =============================================================================
@@ -207,8 +283,9 @@ def create_phoenix_evaluator(
207
283
 
208
284
  # Default model (use Claude Sonnet 4.5 for evaluators)
209
285
  if model_name is None:
210
- model_name = "claude-sonnet-4-5-20250929"
211
- logger.debug(f"Using default evaluator model: {model_name}")
286
+ import os
287
+ model_name = os.environ.get("EVALUATOR_MODEL", "claude-sonnet-4-5-20250929")
288
+ logger.debug(f"Using evaluator model: {model_name}")
212
289
 
213
290
  logger.info(f"Creating Phoenix evaluator: {evaluator_name} with model={model_name}")
214
291
 
@@ -589,33 +666,26 @@ Please evaluate the agent's answer according to the evaluation criteria."""
589
666
 
590
667
  logger.debug(f"Created {len(evaluations)} evaluations")
591
668
 
592
- # Phoenix run_experiment expects a single EvaluationResult, not a list.
593
- # Return the overall score with detailed evaluations in metadata.
594
- from phoenix.experiments.evaluators.base import EvaluationResult
595
-
669
+ # Phoenix client expects a dict with score, label, explanation
670
+ # (not the old EvaluationResult class)
596
671
  overall_eval = next(
597
672
  (e for e in evaluations if e["name"] == "overall"),
598
673
  {"score": 0.0, "label": "unknown", "explanation": None}
599
674
  )
600
675
 
601
- return EvaluationResult(
602
- score=overall_eval.get("score"),
603
- label=overall_eval.get("label"),
604
- explanation=overall_eval.get("explanation"),
605
- metadata={
606
- "evaluations": evaluations,
607
- "raw_response": response_json,
608
- }
609
- )
676
+ return {
677
+ "score": overall_eval.get("score", 0.0),
678
+ "label": overall_eval.get("label", "unknown"),
679
+ "explanation": overall_eval.get("explanation"),
680
+ }
610
681
 
611
682
  except Exception as e:
612
683
  logger.error(f"Evaluator error: {e}")
613
- from phoenix.experiments.evaluators.base import EvaluationResult
614
- return EvaluationResult(
615
- score=0.0,
616
- label="error",
617
- explanation=f"Evaluator failed: {str(e)}",
618
- )
684
+ return {
685
+ "score": 0.0,
686
+ "label": "error",
687
+ "explanation": f"Evaluator failed: {str(e)}",
688
+ }
619
689
 
620
690
  return evaluator_fn
621
691
 
@@ -550,10 +550,18 @@ async def create_agent(
550
550
  # Extract schema fields using typed helpers
551
551
  from ..schema import get_system_prompt, get_metadata
552
552
 
553
+ # Track whether mcp_servers was explicitly configured (even if empty)
554
+ mcp_servers_explicitly_set = False
555
+
553
556
  if agent_schema:
554
557
  system_prompt = get_system_prompt(agent_schema)
555
558
  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 []
559
+ # Check if mcp_servers was explicitly set (could be empty list to disable)
560
+ if hasattr(metadata, 'mcp_servers') and metadata.mcp_servers is not None:
561
+ mcp_server_configs = [s.model_dump() for s in metadata.mcp_servers]
562
+ mcp_servers_explicitly_set = True
563
+ else:
564
+ mcp_server_configs = []
557
565
  resource_configs = metadata.resources if hasattr(metadata, 'resources') else []
558
566
 
559
567
  if metadata.system_prompt:
@@ -564,9 +572,38 @@ async def create_agent(
564
572
  mcp_server_configs = []
565
573
  resource_configs = []
566
574
 
567
- # Default to rem.mcp_server if no MCP servers configured
568
- if not mcp_server_configs:
569
- mcp_server_configs = [{"type": "local", "module": "rem.mcp_server", "id": "rem"}]
575
+ # Auto-detect local MCP server if not explicitly configured
576
+ # This makes mcp_servers config optional - agents get tools automatically
577
+ # But if mcp_servers: [] is explicitly set, respect that (no auto-detection)
578
+ if not mcp_server_configs and not mcp_servers_explicitly_set:
579
+ import importlib
580
+ import os
581
+ import sys
582
+
583
+ # Ensure current working directory is in sys.path for local imports
584
+ cwd = os.getcwd()
585
+ if cwd not in sys.path:
586
+ sys.path.insert(0, cwd)
587
+
588
+ # Try common local MCP server module paths first
589
+ auto_detect_modules = [
590
+ "tools.mcp_server", # Convention: tools/mcp_server.py
591
+ "mcp_server", # Alternative: mcp_server.py in root
592
+ ]
593
+ for module_path in auto_detect_modules:
594
+ try:
595
+ mcp_module = importlib.import_module(module_path)
596
+ if hasattr(mcp_module, "mcp"):
597
+ logger.info(f"Auto-detected local MCP server: {module_path}")
598
+ mcp_server_configs = [{"type": "local", "module": module_path, "id": "auto-detected"}]
599
+ break
600
+ except ImportError:
601
+ continue
602
+
603
+ # Fall back to REM's default MCP server if no local server found
604
+ if not mcp_server_configs:
605
+ logger.debug("No local MCP server found, using REM default")
606
+ mcp_server_configs = [{"type": "local", "module": "rem.mcp_server", "id": "rem"}]
570
607
 
571
608
  # Extract temperature and max_iterations from schema metadata (with fallback to settings defaults)
572
609
  if metadata:
@@ -612,46 +649,51 @@ async def create_agent(
612
649
  search_rem_suffix += f"Example: `SEARCH \"your query\" FROM {default_table} LIMIT 10`"
613
650
 
614
651
  # Add tools from MCP server (in-process, no subprocess)
615
- if mcp_server_configs:
616
- for server_config in mcp_server_configs:
617
- server_type = server_config.get("type")
618
- server_id = server_config.get("id", "mcp-server")
619
-
620
- if server_type == "local":
621
- # Import MCP server directly (in-process)
622
- module_path = server_config.get("module", "rem.mcp_server")
623
-
624
- try:
625
- # Dynamic import of MCP server module
626
- import importlib
627
- mcp_module = importlib.import_module(module_path)
628
- mcp_server = mcp_module.mcp
629
-
630
- # Extract tools from MCP server (get_tools is async)
631
- from ..mcp.tool_wrapper import create_mcp_tool_wrapper
632
-
633
- # Await async get_tools() call
634
- mcp_tools_dict = await mcp_server.get_tools()
635
-
636
- for tool_name, tool_func in mcp_tools_dict.items():
637
- # Add description suffix to search_rem tool if schema specifies a default table
638
- tool_suffix = search_rem_suffix if tool_name == "search_rem" else None
639
-
640
- wrapped_tool = create_mcp_tool_wrapper(
641
- tool_name,
642
- tool_func,
643
- user_id=context.user_id if context else None,
644
- description_suffix=tool_suffix,
645
- )
646
- tools.append(wrapped_tool)
647
- logger.debug(f"Loaded MCP tool: {tool_name}" + (" (with schema suffix)" if tool_suffix else ""))
648
-
649
- logger.info(f"Loaded {len(mcp_tools_dict)} tools from MCP server: {server_id} (in-process)")
650
-
651
- except Exception as e:
652
- logger.error(f"Failed to load MCP server {server_id}: {e}", exc_info=True)
653
- else:
654
- logger.warning(f"Unsupported MCP server type: {server_type}")
652
+ # Track loaded MCP servers for resource resolution
653
+ loaded_mcp_server = None
654
+
655
+ for server_config in mcp_server_configs:
656
+ server_type = server_config.get("type")
657
+ server_id = server_config.get("id", "mcp-server")
658
+
659
+ if server_type == "local":
660
+ # Import MCP server directly (in-process)
661
+ module_path = server_config.get("module", "rem.mcp_server")
662
+
663
+ try:
664
+ # Dynamic import of MCP server module
665
+ import importlib
666
+ mcp_module = importlib.import_module(module_path)
667
+ mcp_server = mcp_module.mcp
668
+
669
+ # Store the loaded server for resource resolution
670
+ loaded_mcp_server = mcp_server
671
+
672
+ # Extract tools from MCP server (get_tools is async)
673
+ from ..mcp.tool_wrapper import create_mcp_tool_wrapper
674
+
675
+ # Await async get_tools() call
676
+ mcp_tools_dict = await mcp_server.get_tools()
677
+
678
+ for tool_name, tool_func in mcp_tools_dict.items():
679
+ # Add description suffix to search_rem tool if schema specifies a default table
680
+ tool_suffix = search_rem_suffix if tool_name == "search_rem" else None
681
+
682
+ wrapped_tool = create_mcp_tool_wrapper(
683
+ tool_name,
684
+ tool_func,
685
+ user_id=context.user_id if context else None,
686
+ description_suffix=tool_suffix,
687
+ )
688
+ tools.append(wrapped_tool)
689
+ logger.debug(f"Loaded MCP tool: {tool_name}" + (" (with schema suffix)" if tool_suffix else ""))
690
+
691
+ logger.info(f"Loaded {len(mcp_tools_dict)} tools from MCP server: {server_id} (in-process)")
692
+
693
+ except Exception as e:
694
+ logger.error(f"Failed to load MCP server {server_id}: {e}", exc_info=True)
695
+ else:
696
+ logger.warning(f"Unsupported MCP server type: {server_type}")
655
697
 
656
698
  # Convert resources to tools (MCP convenience syntax)
657
699
  # Resources declared in agent YAML become callable tools - eliminates
@@ -693,8 +735,9 @@ async def create_agent(
693
735
  resource_uris.append((tool_name, tool_desc))
694
736
 
695
737
  # Create tools from collected resource URIs
738
+ # Pass the loaded MCP server so resources can be resolved from it
696
739
  for uri, usage in resource_uris:
697
- resource_tool = create_resource_tool(uri, usage)
740
+ resource_tool = create_resource_tool(uri, usage, mcp_server=loaded_mcp_server)
698
741
  tools.append(resource_tool)
699
742
  logger.debug(f"Loaded resource as tool: {uri}")
700
743
 
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
@@ -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