Files
AutoGPT/autogpt_platform/backend/backend/util/feature_flag.py
Zamil Majdy 5f82a71d5f feat(copilot): add Fast/Thinking mode toggle with full tool parity (#12623)
### 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>
2026-04-06 19:54:36 +07:00

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