mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
### Why / What / How Users need a way to choose between fast, cheap responses (Sonnet) and deep reasoning (Opus) in the copilot. Previously only the SDK/Opus path existed, and the baseline path was a degraded fallback with no tool calling, no file attachments, no E2B sandbox, and no permission enforcement. This PR adds a copilot mode toggle and brings the baseline (fast) path to full feature parity with the SDK (extended thinking) path. ### Changes 🏗️ #### 1. Mode toggle (UI → full stack) - Add Fast / Thinking mode toggle to ChatInput footer (Phosphor `Brain`/`Zap` icons via lucide-react) - Thread `mode: "fast" | "extended_thinking" | null` from `StreamChatRequest` → RabbitMQ queue → executor → service selection - Fast → baseline service (Sonnet 4 via OpenRouter), Thinking → SDK service (Opus 4.6) - Toggle gated behind `CHAT_MODE_OPTION` feature flag with server-side enforcement - Mode persists in localStorage with SSR-safe init #### 2. Baseline service full tool parity - **Tool call persistence**: Store structured `ChatMessage` entries (assistant + tool results) instead of flat concatenated text — enables frontend to render tool call details and maintain context across turns - **E2B sandbox**: Wire up `get_or_create_sandbox()` so `bash_exec` routes to E2B (image download, Python/PIL compression, filesystem access) - **File attachments**: Accept `file_ids`, download workspace files, embed images as OpenAI vision blocks, save non-images to working dir - **Permissions**: Filter tool list via `CopilotPermissions` (whitelist/blacklist) - **URL context**: Pass `context` dict to user message for URL-shared content - **Execution context**: Pass `sandbox`, `sdk_cwd`, `permissions` to `set_execution_context()` - **Model**: Changed `fast_model` from `google/gemini-2.5-flash` to `anthropic/claude-sonnet-4` for reliable function calling - **Temp dir cleanup**: Lazy `mkdtemp` (only when files attached) + `shutil.rmtree` in finally #### 3. Transcript support for Fast mode - Baseline service now downloads / validates / loads / appends / uploads transcripts (parity with SDK) - Enables seamless mode switching mid-conversation via shared transcript - Upload shielded from cancellation, bounded at 5s timeout #### 4. Feature-flag infrastructure fixes - `FORCE_FLAG_*` env-var overrides on both backend and frontend for local dev / E2E - LaunchDarkly context parity (frontend mirrors backend user context) - `CHAT_MODE_OPTION` default flipped to `false` to match backend #### 5. Other hardening - Double-submit ref guard in `useChatInput` + reconnect dedup in `useCopilotStream` - `copilotModeRef` pattern to read latest mode without recreating transport - Shared `CopilotMode` type across frontend files - File name collision handling with numeric suffix - Path sanitization in file description hints (`os.path.basename`) ### Test plan - [x] 30 new unit tests: `_env_flag_override` (12), `envFlagOverride` (8), `_filter_tools_by_permissions` (4), `_prepare_baseline_attachments` (6) - [x] E2E tested on dev: fast mode creates E2B sandbox, calls 7-10 tools, generates and renders images - [x] Mode switching mid-session works (shared transcript + session messages) - [x] Server-side flag gate enforced (crafted `mode=fast` stripped when flag off) - [x] All 37 CI checks green - [x] Verified via agent-browser: workspace images render correctly in all message positions 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Zamil Majdy <majdy.zamil@gmail.com>
368 lines
12 KiB
Python
368 lines
12 KiB
Python
import contextlib
|
|
import logging
|
|
import os
|
|
from enum import Enum
|
|
from functools import wraps
|
|
from typing import Any, Awaitable, Callable, TypeVar
|
|
|
|
import ldclient
|
|
from autogpt_libs.auth.dependencies import get_optional_user_id
|
|
from fastapi import HTTPException, Security
|
|
from ldclient import Context, LDClient
|
|
from ldclient.config import Config
|
|
from typing_extensions import ParamSpec
|
|
|
|
from backend.util.cache import cached
|
|
from backend.util.settings import Settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Load settings at module level
|
|
settings = Settings()
|
|
|
|
P = ParamSpec("P")
|
|
T = TypeVar("T")
|
|
|
|
_is_initialized = False
|
|
|
|
|
|
class Flag(str, Enum):
|
|
"""
|
|
Centralized enum for all LaunchDarkly feature flags.
|
|
|
|
Add new flags here to ensure consistency across the codebase.
|
|
"""
|
|
|
|
AUTOMOD = "AutoMod"
|
|
AI_ACTIVITY_STATUS = "ai-agent-execution-summary"
|
|
BETA_BLOCKS = "beta-blocks"
|
|
AGENT_ACTIVITY = "agent-activity"
|
|
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment"
|
|
CHAT = "chat"
|
|
CHAT_MODE_OPTION = "chat-mode-option"
|
|
COPILOT_SDK = "copilot-sdk"
|
|
COPILOT_DAILY_TOKEN_LIMIT = "copilot-daily-token-limit"
|
|
COPILOT_WEEKLY_TOKEN_LIMIT = "copilot-weekly-token-limit"
|
|
|
|
|
|
def is_configured() -> bool:
|
|
"""Check if LaunchDarkly is configured with an SDK key."""
|
|
return bool(settings.secrets.launch_darkly_sdk_key)
|
|
|
|
|
|
def get_client() -> LDClient:
|
|
"""Get the LaunchDarkly client singleton."""
|
|
if not _is_initialized:
|
|
initialize_launchdarkly()
|
|
return ldclient.get()
|
|
|
|
|
|
def initialize_launchdarkly() -> None:
|
|
sdk_key = settings.secrets.launch_darkly_sdk_key
|
|
logger.debug(
|
|
f"Initializing LaunchDarkly with SDK key: {'present' if sdk_key else 'missing'}"
|
|
)
|
|
|
|
if not sdk_key:
|
|
logger.warning("LaunchDarkly SDK key not configured")
|
|
return
|
|
|
|
config = Config(sdk_key)
|
|
ldclient.set_config(config)
|
|
|
|
global _is_initialized
|
|
_is_initialized = True
|
|
if ldclient.get().is_initialized():
|
|
logger.info("LaunchDarkly client initialized successfully")
|
|
else:
|
|
logger.error("LaunchDarkly client failed to initialize")
|
|
|
|
|
|
def shutdown_launchdarkly() -> None:
|
|
"""Shutdown the LaunchDarkly client."""
|
|
if ldclient.get().is_initialized():
|
|
ldclient.get().close()
|
|
logger.info("LaunchDarkly client closed successfully")
|
|
|
|
|
|
@cached(maxsize=1000, ttl_seconds=86400) # 1000 entries, 24 hours TTL
|
|
async def _fetch_user_context_data(user_id: str) -> Context:
|
|
"""
|
|
Fetch user context for LaunchDarkly from Supabase.
|
|
|
|
Args:
|
|
user_id: The user ID to fetch data for
|
|
|
|
Returns:
|
|
LaunchDarkly Context object
|
|
"""
|
|
builder = Context.builder(user_id).kind("user").anonymous(True)
|
|
|
|
try:
|
|
from backend.util.clients import get_supabase
|
|
|
|
# If we have user data, update context
|
|
response = get_supabase().auth.admin.get_user_by_id(user_id)
|
|
if response and response.user:
|
|
user = response.user
|
|
builder.anonymous(False)
|
|
if user.role:
|
|
builder.set("role", user.role)
|
|
# It's weird, I know, but it is what it is.
|
|
builder.set("custom", {"role": user.role})
|
|
if user.email:
|
|
builder.set("email", user.email)
|
|
builder.set("email_domain", user.email.split("@")[-1])
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fetch user context for {user_id}: {e}")
|
|
|
|
return builder.build()
|
|
|
|
|
|
async def get_feature_flag_value(
|
|
flag_key: str,
|
|
user_id: str,
|
|
default: Any = None,
|
|
) -> Any:
|
|
"""
|
|
Get the raw value of a feature flag for a user.
|
|
|
|
This is the generic function that returns the actual flag value,
|
|
which could be a boolean, string, number, or JSON object.
|
|
|
|
Args:
|
|
flag_key: The LaunchDarkly feature flag key
|
|
user_id: The user ID to evaluate the flag for
|
|
default: Default value if LaunchDarkly is unavailable or flag evaluation fails
|
|
|
|
Returns:
|
|
The flag value from LaunchDarkly
|
|
"""
|
|
try:
|
|
client = get_client()
|
|
|
|
# Check if client is initialized
|
|
if not client.is_initialized():
|
|
logger.debug(
|
|
f"LaunchDarkly not initialized, using default={default} for {flag_key}"
|
|
)
|
|
return default
|
|
|
|
# Get user context from Supabase
|
|
context = await _fetch_user_context_data(user_id)
|
|
|
|
# Evaluate flag
|
|
result = client.variation(flag_key, context, default)
|
|
|
|
logger.debug(
|
|
f"Feature flag {flag_key} for user {user_id}: {result} (type: {type(result).__name__})"
|
|
)
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"LaunchDarkly flag evaluation failed for {flag_key}: {e}, using default={default}"
|
|
)
|
|
return default
|
|
|
|
|
|
def _env_flag_override(flag_key: Flag) -> bool | None:
|
|
"""Return a local override for ``flag_key`` from the environment.
|
|
|
|
Set ``FORCE_FLAG_<NAME>=true|false`` (``NAME`` = flag value with
|
|
``-`` → ``_``, upper-cased) to bypass LaunchDarkly for a single
|
|
flag in local dev or tests. Returns ``None`` when no override
|
|
is configured so the caller falls through to LaunchDarkly.
|
|
|
|
The ``NEXT_PUBLIC_FORCE_FLAG_<NAME>`` prefix is also accepted so a
|
|
single shared env var can toggle a flag across backend and
|
|
frontend (the frontend requires the ``NEXT_PUBLIC_`` prefix to
|
|
expose the value to the browser bundle).
|
|
|
|
Example: ``FORCE_FLAG_CHAT_MODE_OPTION=true`` forces
|
|
``Flag.CHAT_MODE_OPTION`` on regardless of LaunchDarkly.
|
|
"""
|
|
suffix = flag_key.value.upper().replace("-", "_")
|
|
for prefix in ("FORCE_FLAG_", "NEXT_PUBLIC_FORCE_FLAG_"):
|
|
raw = os.environ.get(prefix + suffix)
|
|
if raw is not None:
|
|
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
return None
|
|
|
|
|
|
async def is_feature_enabled(
|
|
flag_key: Flag,
|
|
user_id: str,
|
|
default: bool = False,
|
|
) -> bool:
|
|
"""
|
|
Check if a feature flag is enabled for a user.
|
|
|
|
Args:
|
|
flag_key: The Flag enum value
|
|
user_id: The user ID to evaluate the flag for
|
|
default: Default value if LaunchDarkly is unavailable or flag evaluation fails
|
|
|
|
Returns:
|
|
True if feature is enabled, False otherwise
|
|
"""
|
|
override = _env_flag_override(flag_key)
|
|
if override is not None:
|
|
logger.debug(f"Feature flag {flag_key} overridden by env: {override}")
|
|
return override
|
|
|
|
result = await get_feature_flag_value(flag_key.value, user_id, default)
|
|
|
|
# If the result is already a boolean, return it
|
|
if isinstance(result, bool):
|
|
return result
|
|
|
|
# Log a warning if the flag is not returning a boolean
|
|
logger.warning(
|
|
f"Feature flag {flag_key} returned non-boolean value: {result} (type: {type(result).__name__}). "
|
|
f"This flag should be configured as a boolean in LaunchDarkly. Using default={default}"
|
|
)
|
|
|
|
# Return the default if we get a non-boolean value
|
|
# This prevents objects from being incorrectly treated as True
|
|
return default
|
|
|
|
|
|
def feature_flag(
|
|
flag_key: str,
|
|
default: bool = False,
|
|
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
"""
|
|
Decorator for async feature flag protected endpoints.
|
|
|
|
Args:
|
|
flag_key: The LaunchDarkly feature flag key
|
|
default: Default value if flag evaluation fails
|
|
|
|
Returns:
|
|
Decorator that only works with async functions
|
|
"""
|
|
|
|
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
@wraps(func)
|
|
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
try:
|
|
user_id = kwargs.get("user_id")
|
|
if not user_id:
|
|
raise ValueError("user_id is required")
|
|
|
|
if not get_client().is_initialized():
|
|
logger.warning(
|
|
"LaunchDarkly not initialized, "
|
|
f"using default {flag_key}={repr(default)}"
|
|
)
|
|
is_enabled = default
|
|
else:
|
|
# Use the internal function directly since we have a raw string flag_key
|
|
flag_value = await get_feature_flag_value(
|
|
flag_key, str(user_id), default
|
|
)
|
|
# Ensure we treat flag value as boolean
|
|
if isinstance(flag_value, bool):
|
|
is_enabled = flag_value
|
|
else:
|
|
# Log warning and use default for non-boolean values
|
|
logger.warning(
|
|
f"Feature flag {flag_key} returned non-boolean value: "
|
|
f"{repr(flag_value)} (type: {type(flag_value).__name__}). "
|
|
f"Using default value {repr(default)}"
|
|
)
|
|
is_enabled = default
|
|
|
|
if not is_enabled:
|
|
raise HTTPException(status_code=404, detail="Feature not available")
|
|
|
|
return await func(*args, **kwargs)
|
|
except Exception as e:
|
|
logger.error(f"Error evaluating feature flag {flag_key}: {e}")
|
|
raise
|
|
|
|
return async_wrapper
|
|
|
|
return decorator
|
|
|
|
|
|
def create_feature_flag_dependency(
|
|
flag_key: Flag,
|
|
default: bool = False,
|
|
) -> Callable[[str | None], Awaitable[None]]:
|
|
"""
|
|
Create a FastAPI dependency that checks a feature flag.
|
|
|
|
This dependency automatically extracts the user_id from the JWT token
|
|
(if present) for proper LaunchDarkly user targeting, while still
|
|
supporting anonymous access.
|
|
|
|
Args:
|
|
flag_key: The Flag enum value to check
|
|
default: Default value if flag evaluation fails
|
|
|
|
Returns:
|
|
An async dependency function that raises HTTPException if flag is disabled
|
|
|
|
Example:
|
|
router = APIRouter(
|
|
dependencies=[Depends(create_feature_flag_dependency(Flag.CHAT))]
|
|
)
|
|
"""
|
|
|
|
async def check_feature_flag(
|
|
user_id: str | None = Security(get_optional_user_id),
|
|
) -> None:
|
|
"""Check if feature flag is enabled for the user.
|
|
|
|
The user_id is automatically injected from JWT authentication if present,
|
|
or None for anonymous access.
|
|
"""
|
|
# For routes that don't require authentication, use anonymous context
|
|
check_user_id = user_id or "anonymous"
|
|
|
|
if not is_configured():
|
|
logger.debug(
|
|
f"LaunchDarkly not configured, using default {flag_key.value}={default}"
|
|
)
|
|
if not default:
|
|
raise HTTPException(status_code=404, detail="Feature not available")
|
|
return
|
|
|
|
try:
|
|
client = get_client()
|
|
if not client.is_initialized():
|
|
logger.debug(
|
|
f"LaunchDarkly not initialized, using default {flag_key.value}={default}"
|
|
)
|
|
if not default:
|
|
raise HTTPException(status_code=404, detail="Feature not available")
|
|
return
|
|
|
|
is_enabled = await is_feature_enabled(flag_key, check_user_id, default)
|
|
|
|
if not is_enabled:
|
|
raise HTTPException(status_code=404, detail="Feature not available")
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"LaunchDarkly error for flag {flag_key.value}: {e}, using default={default}"
|
|
)
|
|
raise HTTPException(status_code=500, detail="Failed to check feature flag")
|
|
|
|
return check_feature_flag
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def mock_flag_variation(flag_key: str, return_value: Any):
|
|
"""Context manager for testing feature flags."""
|
|
original_variation = get_client().variation
|
|
get_client().variation = lambda key, context, default: (
|
|
return_value if key == flag_key else original_variation(key, context, default)
|
|
)
|
|
try:
|
|
yield
|
|
finally:
|
|
get_client().variation = original_variation
|