mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'dev' into fix/scheduler-thread-dump-debugging
This commit is contained in:
@@ -33,7 +33,7 @@ from prisma.types import (
|
||||
AgentNodeExecutionUpdateInput,
|
||||
AgentNodeExecutionWhereInput,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue
|
||||
from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError
|
||||
from pydantic.fields import Field
|
||||
|
||||
from backend.server.v2.store.exceptions import DatabaseError
|
||||
@@ -59,7 +59,7 @@ from .includes import (
|
||||
GRAPH_EXECUTION_INCLUDE_WITH_NODES,
|
||||
graph_execution_include,
|
||||
)
|
||||
from .model import GraphExecutionStats
|
||||
from .model import GraphExecutionStats, NodeExecutionStats
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
@@ -318,18 +318,30 @@ class NodeExecutionResult(BaseModel):
|
||||
|
||||
@staticmethod
|
||||
def from_db(_node_exec: AgentNodeExecution, user_id: Optional[str] = None):
|
||||
if _node_exec.executionData:
|
||||
# Execution that has been queued for execution will persist its data.
|
||||
try:
|
||||
stats = NodeExecutionStats.model_validate(_node_exec.stats or {})
|
||||
except (ValueError, ValidationError):
|
||||
stats = NodeExecutionStats()
|
||||
|
||||
if stats.cleared_inputs:
|
||||
input_data: BlockInput = defaultdict()
|
||||
for name, messages in stats.cleared_inputs.items():
|
||||
input_data[name] = messages[-1] if messages else ""
|
||||
elif _node_exec.executionData:
|
||||
input_data = type_utils.convert(_node_exec.executionData, dict[str, Any])
|
||||
else:
|
||||
# For incomplete execution, executionData will not be yet available.
|
||||
input_data: BlockInput = defaultdict()
|
||||
for data in _node_exec.Input or []:
|
||||
input_data[data.name] = type_utils.convert(data.data, type[Any])
|
||||
|
||||
output_data: CompletedBlockOutput = defaultdict(list)
|
||||
for data in _node_exec.Output or []:
|
||||
output_data[data.name].append(type_utils.convert(data.data, type[Any]))
|
||||
|
||||
if stats.cleared_outputs:
|
||||
for name, messages in stats.cleared_outputs.items():
|
||||
output_data[name].extend(messages)
|
||||
else:
|
||||
for data in _node_exec.Output or []:
|
||||
output_data[data.name].append(type_utils.convert(data.data, type[Any]))
|
||||
|
||||
graph_execution: AgentGraphExecution | None = _node_exec.GraphExecution
|
||||
if graph_execution:
|
||||
|
||||
@@ -655,6 +655,9 @@ class NodeExecutionStats(BaseModel):
|
||||
output_token_count: int = 0
|
||||
extra_cost: int = 0
|
||||
extra_steps: int = 0
|
||||
# Moderation fields
|
||||
cleared_inputs: Optional[dict[str, list[str]]] = None
|
||||
cleared_outputs: Optional[dict[str, list[str]]] = None
|
||||
|
||||
def __iadd__(self, other: "NodeExecutionStats") -> "NodeExecutionStats":
|
||||
"""Mutate this instance by adding another NodeExecutionStats."""
|
||||
|
||||
@@ -27,7 +27,7 @@ from backend.executor.activity_status_generator import (
|
||||
)
|
||||
from backend.executor.utils import LogMetadata
|
||||
from backend.notifications.notifications import queue_notification
|
||||
from backend.util.exceptions import InsufficientBalanceError
|
||||
from backend.util.exceptions import InsufficientBalanceError, ModerationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.executor import DatabaseManagerClient, DatabaseManagerAsyncClient
|
||||
@@ -67,6 +67,7 @@ from backend.executor.utils import (
|
||||
validate_exec,
|
||||
)
|
||||
from backend.integrations.creds_manager import IntegrationCredentialsManager
|
||||
from backend.server.v2.AutoMod.manager import automod_manager
|
||||
from backend.util import json
|
||||
from backend.util.clients import (
|
||||
get_async_execution_event_bus,
|
||||
@@ -759,6 +760,22 @@ class ExecutionProcessor:
|
||||
amount=1,
|
||||
)
|
||||
|
||||
# Input moderation
|
||||
try:
|
||||
if moderation_error := asyncio.run_coroutine_threadsafe(
|
||||
automod_manager.moderate_graph_execution_inputs(
|
||||
db_client=get_db_async_client(),
|
||||
graph_exec=graph_exec,
|
||||
),
|
||||
self.node_evaluation_loop,
|
||||
).result(timeout=30.0):
|
||||
raise moderation_error
|
||||
except asyncio.TimeoutError:
|
||||
log_metadata.warning(
|
||||
f"Input moderation timed out for graph execution {graph_exec.graph_exec_id}, bypassing moderation and continuing execution"
|
||||
)
|
||||
# Continue execution without moderation
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# Pre‑populate queue ---------------------------------------
|
||||
# ------------------------------------------------------------
|
||||
@@ -897,6 +914,25 @@ class ExecutionProcessor:
|
||||
time.sleep(0.1)
|
||||
|
||||
# loop done --------------------------------------------------
|
||||
|
||||
# Output moderation
|
||||
try:
|
||||
if moderation_error := asyncio.run_coroutine_threadsafe(
|
||||
automod_manager.moderate_graph_execution_outputs(
|
||||
db_client=get_db_async_client(),
|
||||
graph_exec_id=graph_exec.graph_exec_id,
|
||||
user_id=graph_exec.user_id,
|
||||
graph_id=graph_exec.graph_id,
|
||||
),
|
||||
self.node_evaluation_loop,
|
||||
).result(timeout=30.0):
|
||||
raise moderation_error
|
||||
except asyncio.TimeoutError:
|
||||
log_metadata.warning(
|
||||
f"Output moderation timed out for graph execution {graph_exec.graph_exec_id}, bypassing moderation and continuing execution"
|
||||
)
|
||||
# Continue execution without moderation
|
||||
|
||||
# Determine final execution status based on whether there was an error or termination
|
||||
if cancel.is_set():
|
||||
execution_status = ExecutionStatus.TERMINATED
|
||||
@@ -917,11 +953,12 @@ class ExecutionProcessor:
|
||||
else Exception(f"{e.__class__.__name__}: {e}")
|
||||
)
|
||||
|
||||
known_errors = (InsufficientBalanceError,)
|
||||
known_errors = (InsufficientBalanceError, ModerationError)
|
||||
if isinstance(error, known_errors):
|
||||
execution_stats.error = str(error)
|
||||
return ExecutionStatus.FAILED
|
||||
|
||||
execution_status = ExecutionStatus.FAILED
|
||||
log_metadata.exception(
|
||||
f"Failed graph execution {graph_exec.graph_exec_id}: {error}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# AutoMod integration for content moderation
|
||||
353
autogpt_platform/backend/backend/server/v2/AutoMod/manager.py
Normal file
353
autogpt_platform/backend/backend/server/v2/AutoMod/manager.py
Normal file
@@ -0,0 +1,353 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from backend.executor import DatabaseManagerAsyncClient
|
||||
|
||||
from autogpt_libs.feature_flag.client import is_feature_enabled
|
||||
from pydantic import ValidationError
|
||||
|
||||
from backend.data.execution import ExecutionStatus
|
||||
from backend.server.v2.AutoMod.models import (
|
||||
AutoModRequest,
|
||||
AutoModResponse,
|
||||
ModerationConfig,
|
||||
)
|
||||
from backend.util.exceptions import ModerationError
|
||||
from backend.util.request import Requests
|
||||
from backend.util.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AutoModManager:
|
||||
|
||||
def __init__(self):
|
||||
self.config = self._load_config()
|
||||
|
||||
def _load_config(self) -> ModerationConfig:
|
||||
"""Load AutoMod configuration from settings"""
|
||||
settings = Settings()
|
||||
return ModerationConfig(
|
||||
enabled=settings.config.automod_enabled,
|
||||
api_url=settings.config.automod_api_url,
|
||||
api_key=settings.secrets.automod_api_key,
|
||||
timeout=settings.config.automod_timeout,
|
||||
retry_attempts=settings.config.automod_retry_attempts,
|
||||
retry_delay=settings.config.automod_retry_delay,
|
||||
fail_open=settings.config.automod_fail_open,
|
||||
)
|
||||
|
||||
async def moderate_graph_execution_inputs(
|
||||
self, db_client: "DatabaseManagerAsyncClient", graph_exec, timeout: int = 10
|
||||
) -> Exception | None:
|
||||
"""
|
||||
Complete input moderation flow for graph execution
|
||||
Returns: error_if_failed (None means success)
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
|
||||
# Check if AutoMod feature is enabled for this user
|
||||
if not is_feature_enabled("AutoMod", graph_exec.user_id, default=False):
|
||||
logger.debug(f"AutoMod feature not enabled for user {graph_exec.user_id}")
|
||||
return None
|
||||
|
||||
# Get graph model and collect all inputs
|
||||
graph_model = await db_client.get_graph(
|
||||
graph_exec.graph_id,
|
||||
user_id=graph_exec.user_id,
|
||||
version=graph_exec.graph_version,
|
||||
)
|
||||
|
||||
if not graph_model or not graph_model.nodes:
|
||||
return None
|
||||
|
||||
all_inputs = []
|
||||
for node in graph_model.nodes:
|
||||
if node.input_default:
|
||||
all_inputs.extend(str(v) for v in node.input_default.values() if v)
|
||||
if (masks := graph_exec.nodes_input_masks) and (mask := masks.get(node.id)):
|
||||
all_inputs.extend(str(v) for v in mask.values() if v)
|
||||
|
||||
if not all_inputs:
|
||||
return None
|
||||
|
||||
# Combine all content and moderate directly
|
||||
content = " ".join(all_inputs)
|
||||
|
||||
# Run moderation
|
||||
logger.warning(
|
||||
f"Moderating inputs for graph execution {graph_exec.graph_exec_id}"
|
||||
)
|
||||
try:
|
||||
moderation_passed = await self._moderate_content(
|
||||
content,
|
||||
{
|
||||
"user_id": graph_exec.user_id,
|
||||
"graph_id": graph_exec.graph_id,
|
||||
"graph_exec_id": graph_exec.graph_exec_id,
|
||||
"moderation_type": "execution_input",
|
||||
},
|
||||
)
|
||||
|
||||
if not moderation_passed:
|
||||
logger.warning(
|
||||
f"Moderation failed for graph execution {graph_exec.graph_exec_id}"
|
||||
)
|
||||
# Update node statuses for frontend display before raising error
|
||||
await self._update_failed_nodes_for_moderation(
|
||||
db_client, graph_exec.graph_exec_id, "input"
|
||||
)
|
||||
|
||||
return ModerationError(
|
||||
message="Execution failed due to input content moderation",
|
||||
user_id=graph_exec.user_id,
|
||||
graph_exec_id=graph_exec.graph_exec_id,
|
||||
moderation_type="input",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"Input moderation timed out for graph execution {graph_exec.graph_exec_id}, bypassing moderation"
|
||||
)
|
||||
return None # Bypass moderation on timeout
|
||||
except Exception as e:
|
||||
logger.warning(f"Input moderation execution failed: {e}")
|
||||
return ModerationError(
|
||||
message="Execution failed due to input content moderation error",
|
||||
user_id=graph_exec.user_id,
|
||||
graph_exec_id=graph_exec.graph_exec_id,
|
||||
moderation_type="input",
|
||||
)
|
||||
|
||||
async def moderate_graph_execution_outputs(
|
||||
self,
|
||||
db_client: "DatabaseManagerAsyncClient",
|
||||
graph_exec_id: str,
|
||||
user_id: str,
|
||||
graph_id: str,
|
||||
timeout: int = 10,
|
||||
) -> Exception | None:
|
||||
"""
|
||||
Complete output moderation flow for graph execution
|
||||
Returns: error_if_failed (None means success)
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return None
|
||||
|
||||
# Check if AutoMod feature is enabled for this user
|
||||
if not is_feature_enabled("AutoMod", user_id, default=False):
|
||||
logger.debug(f"AutoMod feature not enabled for user {user_id}")
|
||||
return None
|
||||
|
||||
# Get completed executions and collect outputs
|
||||
completed_executions = await db_client.get_node_executions(
|
||||
graph_exec_id, statuses=[ExecutionStatus.COMPLETED], include_exec_data=True
|
||||
)
|
||||
|
||||
if not completed_executions:
|
||||
return None
|
||||
|
||||
all_outputs = []
|
||||
for exec_entry in completed_executions:
|
||||
if exec_entry.output_data:
|
||||
all_outputs.extend(str(v) for v in exec_entry.output_data.values() if v)
|
||||
|
||||
if not all_outputs:
|
||||
return None
|
||||
|
||||
# Combine all content and moderate directly
|
||||
content = " ".join(all_outputs)
|
||||
|
||||
# Run moderation
|
||||
logger.warning(f"Moderating outputs for graph execution {graph_exec_id}")
|
||||
try:
|
||||
moderation_passed = await self._moderate_content(
|
||||
content,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"graph_id": graph_id,
|
||||
"graph_exec_id": graph_exec_id,
|
||||
"moderation_type": "execution_output",
|
||||
},
|
||||
)
|
||||
|
||||
if not moderation_passed:
|
||||
logger.warning(f"Moderation failed for graph execution {graph_exec_id}")
|
||||
# Update node statuses for frontend display before raising error
|
||||
await self._update_failed_nodes_for_moderation(
|
||||
db_client, graph_exec_id, "output"
|
||||
)
|
||||
|
||||
return ModerationError(
|
||||
message="Execution failed due to output content moderation",
|
||||
user_id=user_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
moderation_type="output",
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"Output moderation timed out for graph execution {graph_exec_id}, bypassing moderation"
|
||||
)
|
||||
return None # Bypass moderation on timeout
|
||||
except Exception as e:
|
||||
logger.warning(f"Output moderation execution failed: {e}")
|
||||
return ModerationError(
|
||||
message="Execution failed due to output content moderation error",
|
||||
user_id=user_id,
|
||||
graph_exec_id=graph_exec_id,
|
||||
moderation_type="output",
|
||||
)
|
||||
|
||||
async def _update_failed_nodes_for_moderation(
|
||||
self,
|
||||
db_client: "DatabaseManagerAsyncClient",
|
||||
graph_exec_id: str,
|
||||
moderation_type: Literal["input", "output"],
|
||||
):
|
||||
"""Update node execution statuses for frontend display when moderation fails"""
|
||||
# Import here to avoid circular imports
|
||||
from backend.executor.manager import send_async_execution_update
|
||||
|
||||
if moderation_type == "input":
|
||||
# For input moderation, mark queued/running/incomplete nodes as failed
|
||||
target_statuses = [
|
||||
ExecutionStatus.QUEUED,
|
||||
ExecutionStatus.RUNNING,
|
||||
ExecutionStatus.INCOMPLETE,
|
||||
]
|
||||
else:
|
||||
# For output moderation, mark completed nodes as failed
|
||||
target_statuses = [ExecutionStatus.COMPLETED]
|
||||
|
||||
# Get the executions that need to be updated
|
||||
executions_to_update = await db_client.get_node_executions(
|
||||
graph_exec_id, statuses=target_statuses, include_exec_data=True
|
||||
)
|
||||
|
||||
if not executions_to_update:
|
||||
return
|
||||
|
||||
# Prepare database update tasks
|
||||
exec_updates = []
|
||||
for exec_entry in executions_to_update:
|
||||
# Collect all input and output names to clear
|
||||
cleared_inputs = {}
|
||||
cleared_outputs = {}
|
||||
|
||||
if exec_entry.input_data:
|
||||
for name in exec_entry.input_data.keys():
|
||||
cleared_inputs[name] = ["Failed due to content moderation"]
|
||||
|
||||
if exec_entry.output_data:
|
||||
for name in exec_entry.output_data.keys():
|
||||
cleared_outputs[name] = ["Failed due to content moderation"]
|
||||
|
||||
# Add update task to list
|
||||
exec_updates.append(
|
||||
db_client.update_node_execution_status(
|
||||
exec_entry.node_exec_id,
|
||||
status=ExecutionStatus.FAILED,
|
||||
stats={
|
||||
"error": "Failed due to content moderation",
|
||||
"cleared_inputs": cleared_inputs,
|
||||
"cleared_outputs": cleared_outputs,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# Execute all database updates in parallel
|
||||
updated_execs = await asyncio.gather(*exec_updates)
|
||||
|
||||
# Send all websocket updates in parallel
|
||||
await asyncio.gather(
|
||||
*[
|
||||
send_async_execution_update(updated_exec)
|
||||
for updated_exec in updated_execs
|
||||
]
|
||||
)
|
||||
|
||||
async def _moderate_content(self, content: str, metadata: dict[str, Any]) -> bool:
|
||||
"""Moderate content using AutoMod API
|
||||
|
||||
Returns:
|
||||
True: Content approved or timeout occurred
|
||||
False: Content rejected by moderation
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: When moderation times out (should be bypassed)
|
||||
"""
|
||||
try:
|
||||
request_data = AutoModRequest(
|
||||
type="text",
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
response = await self._make_request(request_data)
|
||||
|
||||
if response.success and response.status == "approved":
|
||||
logger.debug(
|
||||
f"Content approved for {metadata.get('graph_exec_id', 'unknown')}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
reasons = [r.reason for r in response.moderation_results if r.reason]
|
||||
error_msg = f"Content rejected by AutoMod: {'; '.join(reasons)}"
|
||||
logger.warning(f"Content rejected: {error_msg}")
|
||||
return False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Re-raise timeout to be handled by calling methods
|
||||
logger.warning(
|
||||
f"AutoMod API timeout for {metadata.get('graph_exec_id', 'unknown')}"
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"AutoMod moderation error: {e}")
|
||||
return self.config.fail_open
|
||||
|
||||
async def _make_request(self, request_data: AutoModRequest) -> AutoModResponse:
|
||||
"""Make HTTP request to AutoMod API using the standard request utility"""
|
||||
url = f"{self.config.api_url}/moderate"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-API-Key": self.config.api_key,
|
||||
}
|
||||
|
||||
# Create requests instance with timeout and retry configuration
|
||||
requests = Requests(
|
||||
extra_headers=headers,
|
||||
retry_max_wait=float(self.config.timeout),
|
||||
)
|
||||
|
||||
try:
|
||||
response = await requests.post(
|
||||
url, json=request_data.model_dump(), timeout=self.config.timeout
|
||||
)
|
||||
|
||||
response_data = response.json()
|
||||
return AutoModResponse.model_validate(response_data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Re-raise timeout error to be caught by _moderate_content
|
||||
raise
|
||||
except (json.JSONDecodeError, ValidationError) as e:
|
||||
raise Exception(f"Invalid response from AutoMod API: {e}")
|
||||
except Exception as e:
|
||||
# Check if this is an aiohttp timeout that we should convert
|
||||
if "timeout" in str(e).lower():
|
||||
raise asyncio.TimeoutError(f"AutoMod API request timed out: {e}")
|
||||
raise Exception(f"AutoMod API request failed: {e}")
|
||||
|
||||
|
||||
# Global instance
|
||||
automod_manager = AutoModManager()
|
||||
57
autogpt_platform/backend/backend/server/v2/AutoMod/models.py
Normal file
57
autogpt_platform/backend/backend/server/v2/AutoMod/models.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AutoModRequest(BaseModel):
|
||||
"""Request model for AutoMod API"""
|
||||
|
||||
type: str = Field(..., description="Content type - 'text', 'image', 'video'")
|
||||
content: str = Field(..., description="The content to moderate")
|
||||
metadata: Optional[Dict[str, Any]] = Field(
|
||||
default=None, description="Additional context about the content"
|
||||
)
|
||||
|
||||
|
||||
class ModerationResult(BaseModel):
|
||||
"""Individual moderation result"""
|
||||
|
||||
decision: str = Field(
|
||||
..., description="Moderation decision: 'approved', 'rejected', 'flagged'"
|
||||
)
|
||||
reason: Optional[str] = Field(default=None, description="Reason for the decision")
|
||||
|
||||
|
||||
class AutoModResponse(BaseModel):
|
||||
"""Response model for AutoMod API"""
|
||||
|
||||
success: bool = Field(..., description="Whether the request was successful")
|
||||
status: str = Field(
|
||||
..., description="Overall status: 'approved', 'rejected', 'flagged', 'pending'"
|
||||
)
|
||||
moderation_results: List[ModerationResult] = Field(
|
||||
default_factory=list, description="List of moderation results"
|
||||
)
|
||||
|
||||
|
||||
class ModerationConfig(BaseModel):
|
||||
"""Configuration for AutoMod integration"""
|
||||
|
||||
enabled: bool = Field(default=True, description="Whether moderation is enabled")
|
||||
api_url: str = Field(default="", description="AutoMod API base URL")
|
||||
api_key: str = Field(..., description="AutoMod API key")
|
||||
timeout: int = Field(default=30, description="Request timeout in seconds")
|
||||
retry_attempts: int = Field(default=3, description="Number of retry attempts")
|
||||
retry_delay: float = Field(
|
||||
default=1.0, description="Delay between retries in seconds"
|
||||
)
|
||||
fail_open: bool = Field(
|
||||
default=False,
|
||||
description="If True, allow execution to continue if moderation fails",
|
||||
)
|
||||
moderate_inputs: bool = Field(
|
||||
default=True, description="Whether to moderate block inputs"
|
||||
)
|
||||
moderate_outputs: bool = Field(
|
||||
default=True, description="Whether to moderate block outputs"
|
||||
)
|
||||
@@ -33,6 +33,33 @@ class InsufficientBalanceError(ValueError):
|
||||
return self.message
|
||||
|
||||
|
||||
class ModerationError(ValueError):
|
||||
"""Content moderation failure during execution"""
|
||||
|
||||
user_id: str
|
||||
message: str
|
||||
graph_exec_id: str
|
||||
moderation_type: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
user_id: str,
|
||||
graph_exec_id: str,
|
||||
moderation_type: str = "content",
|
||||
):
|
||||
super().__init__(message)
|
||||
self.args = (message, user_id, graph_exec_id, moderation_type)
|
||||
self.message = message
|
||||
self.user_id = user_id
|
||||
self.graph_exec_id = graph_exec_id
|
||||
self.moderation_type = moderation_type
|
||||
|
||||
def __str__(self):
|
||||
"""Used to display the error message in the frontend, because we str() the error when sending the execution update"""
|
||||
return self.message
|
||||
|
||||
|
||||
class GraphValidationError(ValueError):
|
||||
"""Structured validation error for graph validation failures"""
|
||||
|
||||
|
||||
@@ -295,6 +295,32 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
|
||||
description="Maximum file size in MB for file uploads (1-1024 MB)",
|
||||
)
|
||||
|
||||
# AutoMod configuration
|
||||
automod_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether AutoMod content moderation is enabled",
|
||||
)
|
||||
automod_api_url: str = Field(
|
||||
default="",
|
||||
description="AutoMod API base URL - Make sure it ends in /api",
|
||||
)
|
||||
automod_timeout: int = Field(
|
||||
default=30,
|
||||
description="Timeout in seconds for AutoMod API requests",
|
||||
)
|
||||
automod_retry_attempts: int = Field(
|
||||
default=3,
|
||||
description="Number of retry attempts for AutoMod API requests",
|
||||
)
|
||||
automod_retry_delay: float = Field(
|
||||
default=1.0,
|
||||
description="Delay between retries for AutoMod API requests in seconds",
|
||||
)
|
||||
automod_fail_open: bool = Field(
|
||||
default=False,
|
||||
description="If True, allow execution to continue if AutoMod fails",
|
||||
)
|
||||
|
||||
@field_validator("platform_base_url", "frontend_base_url")
|
||||
@classmethod
|
||||
def validate_platform_base_url(cls, v: str, info: ValidationInfo) -> str:
|
||||
@@ -495,6 +521,10 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
|
||||
apollo_api_key: str = Field(default="", description="Apollo API Key")
|
||||
smartlead_api_key: str = Field(default="", description="SmartLead API Key")
|
||||
zerobounce_api_key: str = Field(default="", description="ZeroBounce API Key")
|
||||
|
||||
# AutoMod API credentials
|
||||
automod_api_key: str = Field(default="", description="AutoMod API key")
|
||||
|
||||
ayrshare_api_key: str = Field(default="", description="Ayrshare API Key")
|
||||
ayrshare_jwt_key: str = Field(default="", description="Ayrshare private Key")
|
||||
# Add more secret fields as needed
|
||||
|
||||
2
autogpt_platform/backend/poetry.lock
generated
2
autogpt_platform/backend/poetry.lock
generated
@@ -6737,4 +6737,4 @@ cffi = ["cffi (>=1.11)"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.13"
|
||||
content-hash = "05e2b99bd6dc5a74a89df0e1e504853b66bd519062159b0de5fbedf6a1f4d986"
|
||||
content-hash = "795414d7ce8f288ea6c65893268b5c29a7c9a60ad75cde28ac7bcdb65f426dfe"
|
||||
|
||||
@@ -10,6 +10,7 @@ packages = [{ include = "backend", format = "sdist" }]
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<3.13"
|
||||
aio-pika = "^9.5.5"
|
||||
aiohttp = "^3.10.0"
|
||||
aiodns = "^3.5.0"
|
||||
anthropic = "^0.59.0"
|
||||
apscheduler = "^3.11.0"
|
||||
|
||||
@@ -56,7 +56,10 @@ export function ProfileInfoForm({ profile }: { profile: ProfileDetails }) {
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-[800px] px-4 sm:px-8">
|
||||
<h1 className="font-circular mb-6 text-[28px] font-normal text-neutral-900 dark:text-neutral-100 sm:mb-8 sm:text-[35px]">
|
||||
<h1
|
||||
data-testid="profile-info-form-title"
|
||||
className="font-circular mb-6 text-[28px] font-normal text-neutral-900 dark:text-neutral-100 sm:mb-8 sm:text-[35px]"
|
||||
>
|
||||
Profile
|
||||
</h1>
|
||||
|
||||
@@ -92,13 +95,18 @@ export function ProfileInfoForm({ profile }: { profile: ProfileDetails }) {
|
||||
|
||||
<form className="space-y-4 sm:space-y-6" onSubmit={submitForm}>
|
||||
<div className="w-full">
|
||||
<label className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
<label
|
||||
htmlFor="displayName"
|
||||
className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<div className="rounded-[55px] border border-slate-200 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||
<input
|
||||
type="text"
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
data-testid="profile-info-form-display-name"
|
||||
defaultValue={profileData.name}
|
||||
placeholder="Enter your display name"
|
||||
className="font-circular w-full border-none bg-transparent text-base font-normal text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500"
|
||||
@@ -114,13 +122,17 @@ export function ProfileInfoForm({ profile }: { profile: ProfileDetails }) {
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
<label
|
||||
htmlFor="handle"
|
||||
className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Handle
|
||||
</label>
|
||||
<div className="rounded-[55px] border border-slate-200 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||
<input
|
||||
type="text"
|
||||
name="handle"
|
||||
id="handle"
|
||||
defaultValue={profileData.username}
|
||||
placeholder="@username"
|
||||
className="font-circular w-full border-none bg-transparent text-base font-normal text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500"
|
||||
@@ -136,12 +148,16 @@ export function ProfileInfoForm({ profile }: { profile: ProfileDetails }) {
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
<label
|
||||
htmlFor="bio"
|
||||
className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Bio
|
||||
</label>
|
||||
<div className="h-[220px] rounded-2xl border border-slate-200 py-2.5 pl-4 pr-4 dark:border-slate-700 dark:bg-slate-800">
|
||||
<textarea
|
||||
name="bio"
|
||||
id="bio"
|
||||
defaultValue={profileData.description}
|
||||
placeholder="Tell us about yourself..."
|
||||
className="font-circular h-full w-full resize-none border-none bg-transparent text-base font-normal text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500"
|
||||
@@ -169,13 +185,17 @@ export function ProfileInfoForm({ profile }: { profile: ProfileDetails }) {
|
||||
const link = profileData.links[linkNum - 1];
|
||||
return (
|
||||
<div key={linkNum} className="w-full">
|
||||
<label className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
<label
|
||||
htmlFor={`link${linkNum}`}
|
||||
className="font-circular mb-1.5 block text-base font-normal leading-tight text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
Link {linkNum}
|
||||
</label>
|
||||
<div className="rounded-[55px] border border-slate-200 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
|
||||
<input
|
||||
type="text"
|
||||
name={`link${linkNum}`}
|
||||
id={`link${linkNum}`}
|
||||
placeholder="https://"
|
||||
defaultValue={link || ""}
|
||||
className="font-circular w-full border-none bg-transparent text-base font-normal text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white dark:placeholder:text-neutral-500"
|
||||
@@ -199,7 +219,8 @@ export function ProfileInfoForm({ profile }: { profile: ProfileDetails }) {
|
||||
<Separator />
|
||||
|
||||
<div className="flex h-[50px] items-center justify-end gap-3 py-8">
|
||||
<Button
|
||||
{/* FRONTEND-TODO: Need to fix it */}
|
||||
{/* <Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="font-circular h-[50px] rounded-[35px] bg-neutral-200 px-6 py-3 text-base font-medium text-neutral-800 transition-colors hover:bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-700 dark:text-neutral-200 dark:hover:border-neutral-600 dark:hover:bg-neutral-600"
|
||||
@@ -208,7 +229,7 @@ export function ProfileInfoForm({ profile }: { profile: ProfileDetails }) {
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -13,15 +13,8 @@ const MILLISECONDS_PER_MINUTE = SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
|
||||
const MILLISECONDS_PER_HOUR = MINUTES_PER_HOUR * MILLISECONDS_PER_MINUTE;
|
||||
const MILLISECONDS_PER_DAY = HOURS_PER_DAY * MILLISECONDS_PER_HOUR;
|
||||
const MILLISECONDS_PER_WEEK = DAYS_PER_WEEK * MILLISECONDS_PER_DAY;
|
||||
|
||||
// Display constants
|
||||
const SHORT_DURATION_THRESHOLD_SECONDS = 5;
|
||||
|
||||
// State sanity limits - keep only most recent executions to prevent unbounded growth
|
||||
const MAX_ACTIVE_EXECUTIONS_IN_STATE = 200; // Most important - these are running
|
||||
const MAX_RECENT_COMPLETIONS_IN_STATE = 100;
|
||||
const MAX_RECENT_FAILURES_IN_STATE = 100;
|
||||
|
||||
export function formatTimeAgo(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
@@ -210,17 +203,15 @@ export function categorizeExecutions(
|
||||
);
|
||||
|
||||
// Filter and limit each category to prevent unbounded state growth
|
||||
const activeExecutions = enrichedExecutions
|
||||
.filter(isActiveExecution)
|
||||
.slice(0, MAX_ACTIVE_EXECUTIONS_IN_STATE);
|
||||
const activeExecutions = enrichedExecutions.filter(isActiveExecution);
|
||||
|
||||
const recentCompletions = enrichedExecutions
|
||||
.filter((execution) => isRecentCompletion(execution, oneWeekAgo))
|
||||
.slice(0, MAX_RECENT_COMPLETIONS_IN_STATE);
|
||||
const recentCompletions = enrichedExecutions.filter((execution) =>
|
||||
isRecentCompletion(execution, oneWeekAgo),
|
||||
);
|
||||
|
||||
const recentFailures = enrichedExecutions
|
||||
.filter((execution) => isRecentFailure(execution, oneWeekAgo))
|
||||
.slice(0, MAX_RECENT_FAILURES_IN_STATE);
|
||||
const recentFailures = enrichedExecutions.filter((execution) =>
|
||||
isRecentFailure(execution, oneWeekAgo),
|
||||
);
|
||||
|
||||
return {
|
||||
activeExecutions,
|
||||
@@ -263,20 +254,11 @@ export function addExecutionToCategory(
|
||||
const newState = { ...state };
|
||||
|
||||
if (isActiveExecution(execution)) {
|
||||
newState.activeExecutions = [execution, ...newState.activeExecutions].slice(
|
||||
0,
|
||||
MAX_ACTIVE_EXECUTIONS_IN_STATE,
|
||||
);
|
||||
newState.activeExecutions = [execution, ...newState.activeExecutions];
|
||||
} else if (isRecentCompletion(execution, oneWeekAgo)) {
|
||||
newState.recentCompletions = [
|
||||
execution,
|
||||
...newState.recentCompletions,
|
||||
].slice(0, MAX_RECENT_COMPLETIONS_IN_STATE);
|
||||
newState.recentCompletions = [execution, ...newState.recentCompletions];
|
||||
} else if (isRecentFailure(execution, oneWeekAgo)) {
|
||||
newState.recentFailures = [execution, ...newState.recentFailures].slice(
|
||||
0,
|
||||
MAX_RECENT_FAILURES_IN_STATE,
|
||||
);
|
||||
newState.recentFailures = [execution, ...newState.recentFailures];
|
||||
}
|
||||
|
||||
return newState;
|
||||
|
||||
145
autogpt_platform/frontend/src/tests/pages/profile-form.page.ts
Normal file
145
autogpt_platform/frontend/src/tests/pages/profile-form.page.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Locator, Page } from "@playwright/test";
|
||||
import { BasePage } from "./base.page";
|
||||
import { getSelectors } from "../utils/selectors";
|
||||
|
||||
export class ProfileFormPage extends BasePage {
|
||||
constructor(page: Page) {
|
||||
super(page);
|
||||
}
|
||||
|
||||
private getId(id: string | RegExp): Locator {
|
||||
const { getId } = getSelectors(this.page);
|
||||
return getId(id);
|
||||
}
|
||||
|
||||
private async hideFloatingWidgets(): Promise<void> {
|
||||
await this.page.addStyleTag({
|
||||
content: `
|
||||
[data-tally-open] { display: none !important; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
// Locators
|
||||
title(): Locator {
|
||||
return this.getId("profile-info-form-title");
|
||||
}
|
||||
|
||||
displayNameField(): Locator {
|
||||
const { getField } = getSelectors(this.page);
|
||||
return getField("Display name");
|
||||
}
|
||||
|
||||
handleField(): Locator {
|
||||
const { getField } = getSelectors(this.page);
|
||||
return getField("Handle");
|
||||
}
|
||||
|
||||
bioField(): Locator {
|
||||
const { getField } = getSelectors(this.page);
|
||||
return getField("Bio");
|
||||
}
|
||||
|
||||
linkField(index: number): Locator {
|
||||
this.assertValidLinkIndex(index);
|
||||
const { getField } = getSelectors(this.page);
|
||||
return getField(`Link ${index}`);
|
||||
}
|
||||
|
||||
cancelButton(): Locator {
|
||||
const { getButton } = getSelectors(this.page);
|
||||
return getButton("Cancel");
|
||||
}
|
||||
|
||||
saveButton(): Locator {
|
||||
const { getButton } = getSelectors(this.page);
|
||||
return getButton("Save changes");
|
||||
}
|
||||
|
||||
// State
|
||||
async isLoaded(): Promise<boolean> {
|
||||
try {
|
||||
await this.title().waitFor({ state: "visible", timeout: 10_000 });
|
||||
await this.displayNameField().waitFor({
|
||||
state: "visible",
|
||||
timeout: 10_000,
|
||||
});
|
||||
await this.handleField().waitFor({ state: "visible", timeout: 10_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
async setDisplayName(name: string): Promise<void> {
|
||||
await this.displayNameField().fill(name);
|
||||
}
|
||||
|
||||
async getDisplayName(): Promise<string> {
|
||||
return this.displayNameField().inputValue();
|
||||
}
|
||||
|
||||
async setHandle(handle: string): Promise<void> {
|
||||
await this.handleField().fill(handle);
|
||||
}
|
||||
|
||||
async getHandle(): Promise<string> {
|
||||
return this.handleField().inputValue();
|
||||
}
|
||||
|
||||
async setBio(bio: string): Promise<void> {
|
||||
await this.bioField().fill(bio);
|
||||
}
|
||||
|
||||
async getBio(): Promise<string> {
|
||||
return this.bioField().inputValue();
|
||||
}
|
||||
|
||||
async setLink(index: number, url: string): Promise<void> {
|
||||
await this.linkField(index).fill(url);
|
||||
}
|
||||
|
||||
async getLink(index: number): Promise<string> {
|
||||
return this.linkField(index).inputValue();
|
||||
}
|
||||
|
||||
async setLinks(links: Array<string | undefined>): Promise<void> {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const val = links[i - 1] ?? "";
|
||||
await this.setLink(i, val);
|
||||
}
|
||||
}
|
||||
|
||||
async clickCancel(): Promise<void> {
|
||||
await this.cancelButton().click();
|
||||
}
|
||||
|
||||
async clickSave(): Promise<void> {
|
||||
await this.saveButton().click();
|
||||
}
|
||||
|
||||
async saveChanges(): Promise<void> {
|
||||
await this.hideFloatingWidgets();
|
||||
await this.clickSave();
|
||||
await this.waitForSaveComplete();
|
||||
}
|
||||
|
||||
async waitForSaveComplete(timeoutMs: number = 15_000): Promise<void> {
|
||||
const { getButton } = getSelectors(this.page);
|
||||
await getButton("Save changes").waitFor({
|
||||
state: "attached",
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
await getButton("Save changes").waitFor({
|
||||
state: "visible",
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
private assertValidLinkIndex(index: number) {
|
||||
if (index < 1 || index > 5) {
|
||||
throw new Error(`Link index must be between 1 and 5. Received: ${index}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
108
autogpt_platform/frontend/src/tests/profile-form.spec.ts
Normal file
108
autogpt_platform/frontend/src/tests/profile-form.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import test, { expect } from "@playwright/test";
|
||||
import { ProfileFormPage } from "./pages/profile-form.page";
|
||||
import { LoginPage } from "./pages/login.page";
|
||||
import { hasUrl } from "./utils/assertion";
|
||||
import { TEST_CREDENTIALS } from "./credentials";
|
||||
|
||||
test.describe("Profile Form", () => {
|
||||
let profileFormPage: ProfileFormPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
profileFormPage = new ProfileFormPage(page);
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password);
|
||||
await hasUrl(page, "/marketplace");
|
||||
});
|
||||
|
||||
test("redirects to login when user is not authenticated", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
await page.goto("/profile");
|
||||
await hasUrl(page, "/login");
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("can save profile changes successfully", async ({ page }) => {
|
||||
await profileFormPage.navbar.clickProfileLink();
|
||||
|
||||
await expect(profileFormPage.isLoaded()).resolves.toBeTruthy();
|
||||
await hasUrl(page, new RegExp("/profile"));
|
||||
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const newDisplayName = `E2E Name ${suffix}`;
|
||||
const newHandle = `e2euser${suffix}`;
|
||||
const newBio = `E2E bio ${suffix}`;
|
||||
const newLinks = [
|
||||
`https://example.com/${suffix}/1`,
|
||||
`https://example.com/${suffix}/2`,
|
||||
`https://example.com/${suffix}/3`,
|
||||
`https://example.com/${suffix}/4`,
|
||||
`https://example.com/${suffix}/5`,
|
||||
];
|
||||
|
||||
await profileFormPage.setDisplayName(newDisplayName);
|
||||
await profileFormPage.setHandle(newHandle);
|
||||
await profileFormPage.setBio(newBio);
|
||||
await profileFormPage.setLinks(newLinks);
|
||||
await profileFormPage.saveChanges();
|
||||
|
||||
expect(await profileFormPage.getDisplayName()).toBe(newDisplayName);
|
||||
expect(await profileFormPage.getHandle()).toBe(newHandle);
|
||||
expect(await profileFormPage.getBio()).toBe(newBio);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(await profileFormPage.getLink(i)).toBe(newLinks[i - 1]);
|
||||
}
|
||||
|
||||
await page.reload();
|
||||
await expect(profileFormPage.isLoaded()).resolves.toBeTruthy();
|
||||
|
||||
expect(await profileFormPage.getDisplayName()).toBe(newDisplayName);
|
||||
expect(await profileFormPage.getHandle()).toBe(newHandle);
|
||||
expect(await profileFormPage.getBio()).toBe(newBio);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(await profileFormPage.getLink(i)).toBe(newLinks[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Currently we are not using hook form inside the profile form, so cancel button is not working as expected, once that's fixed, we can unskip this test
|
||||
test.skip("can cancel profile changes", async ({ page }) => {
|
||||
await profileFormPage.navbar.clickProfileLink();
|
||||
|
||||
await expect(profileFormPage.isLoaded()).resolves.toBeTruthy();
|
||||
await hasUrl(page, new RegExp("/profile"));
|
||||
|
||||
const originalDisplayName = await profileFormPage.getDisplayName();
|
||||
const originalHandle = await profileFormPage.getHandle();
|
||||
const originalBio = await profileFormPage.getBio();
|
||||
const originalLinks: string[] = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
originalLinks.push(await profileFormPage.getLink(i));
|
||||
}
|
||||
|
||||
const suffix = `${Date.now().toString().slice(-6)}_cancel`;
|
||||
await profileFormPage.setDisplayName(`Tmp Name ${suffix}`);
|
||||
await profileFormPage.setHandle(`tmpuser${suffix}`);
|
||||
await profileFormPage.setBio(`Tmp bio ${suffix}`);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await profileFormPage.setLink(i, `https://tmp.example/${suffix}/${i}`);
|
||||
}
|
||||
|
||||
await profileFormPage.clickCancel();
|
||||
|
||||
expect(await profileFormPage.getDisplayName()).toBe(originalDisplayName);
|
||||
expect(await profileFormPage.getHandle()).toBe(originalHandle);
|
||||
expect(await profileFormPage.getBio()).toBe(originalBio);
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
expect(await profileFormPage.getLink(i)).toBe(originalLinks[i - 1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user