mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-14 22:52:15 +02:00
feat: moved most things behind correct feature flag
This commit is contained in:
parent
bdb97a0888
commit
c938d39277
13 changed files with 237 additions and 85 deletions
|
|
@ -3,8 +3,10 @@ Feature flags for the SurfSense new_chat agent stack.
|
|||
|
||||
These flags gate the newer agent middleware (some ported from OpenCode,
|
||||
some sourced from ``langchain.agents.middleware`` / ``deepagents``, some
|
||||
SurfSense-native). They follow a "default-OFF for risky things,
|
||||
default-ON for safe upgrades, master kill-switch for everything new" model.
|
||||
SurfSense-native). Most shipped agent-stack upgrades default ON so Docker
|
||||
image updates work even when older installs do not have newly introduced
|
||||
environment variables. Risky/experimental integrations stay default OFF,
|
||||
and the master kill-switch can still disable everything new.
|
||||
|
||||
All new middleware checks its flag at agent build time. If the master
|
||||
kill-switch ``SURFSENSE_DISABLE_NEW_AGENT_STACK`` is set, every new
|
||||
|
|
@ -14,16 +16,19 @@ operators a single switch to revert to pre-port behavior.
|
|||
Examples
|
||||
--------
|
||||
|
||||
Local development (recommended for trying everything except doom-loop / selector):
|
||||
Defaults:
|
||||
|
||||
SURFSENSE_ENABLE_CONTEXT_EDITING=true
|
||||
SURFSENSE_ENABLE_COMPACTION_V2=true
|
||||
SURFSENSE_ENABLE_RETRY_AFTER=true
|
||||
SURFSENSE_ENABLE_MODEL_FALLBACK=false
|
||||
SURFSENSE_ENABLE_MODEL_CALL_LIMIT=true
|
||||
SURFSENSE_ENABLE_TOOL_CALL_LIMIT=true
|
||||
SURFSENSE_ENABLE_TOOL_CALL_REPAIR=true
|
||||
SURFSENSE_ENABLE_PERMISSION=false # default off, opt-in per deploy
|
||||
SURFSENSE_ENABLE_DOOM_LOOP=false # default off until UI ships
|
||||
SURFSENSE_ENABLE_LLM_TOOL_SELECTOR=false
|
||||
SURFSENSE_ENABLE_STREAM_PARITY_V2=false # structured streaming events
|
||||
SURFSENSE_ENABLE_PERMISSION=true
|
||||
SURFSENSE_ENABLE_DOOM_LOOP=true
|
||||
SURFSENSE_ENABLE_LLM_TOOL_SELECTOR=false # adds a per-turn LLM call
|
||||
SURFSENSE_ENABLE_STREAM_PARITY_V2=true
|
||||
|
||||
Master kill-switch (overrides everything else):
|
||||
|
||||
|
|
@ -60,32 +65,28 @@ class AgentFeatureFlags:
|
|||
disable_new_agent_stack: bool = False
|
||||
|
||||
# Agent quality — context budget, retry/limits, name-repair, doom-loop
|
||||
enable_context_editing: bool = False
|
||||
enable_compaction_v2: bool = False
|
||||
enable_retry_after: bool = False
|
||||
enable_context_editing: bool = True
|
||||
enable_compaction_v2: bool = True
|
||||
enable_retry_after: bool = True
|
||||
enable_model_fallback: bool = False
|
||||
enable_model_call_limit: bool = False
|
||||
enable_tool_call_limit: bool = False
|
||||
enable_tool_call_repair: bool = False
|
||||
enable_doom_loop: bool = (
|
||||
False # Default OFF until UI handles permission='doom_loop'
|
||||
)
|
||||
enable_model_call_limit: bool = True
|
||||
enable_tool_call_limit: bool = True
|
||||
enable_tool_call_repair: bool = True
|
||||
enable_doom_loop: bool = True
|
||||
|
||||
# Safety — permissions, concurrency, tool-set narrowing
|
||||
enable_permission: bool = False # Default OFF for first deploy
|
||||
enable_busy_mutex: bool = False
|
||||
enable_permission: bool = True
|
||||
enable_busy_mutex: bool = True
|
||||
enable_llm_tool_selector: bool = False # Default OFF — adds per-turn LLM cost
|
||||
|
||||
# Skills + subagents
|
||||
enable_skills: bool = False
|
||||
enable_specialized_subagents: bool = False
|
||||
enable_kb_planner_runnable: bool = False
|
||||
enable_skills: bool = True
|
||||
enable_specialized_subagents: bool = True
|
||||
enable_kb_planner_runnable: bool = True
|
||||
|
||||
# Snapshot / revert
|
||||
enable_action_log: bool = False
|
||||
enable_revert_route: bool = (
|
||||
False # Backend ships before UI; route returns 503 until this flips
|
||||
)
|
||||
enable_action_log: bool = True
|
||||
enable_revert_route: bool = True
|
||||
|
||||
# Streaming parity v2 — opt in to LangChain's structured
|
||||
# ``AIMessageChunk`` content (typed reasoning blocks, tool-input
|
||||
|
|
@ -94,7 +95,7 @@ class AgentFeatureFlags:
|
|||
# text path and the synthetic ``call_<run_id>`` tool-call id (no
|
||||
# ``langchainToolCallId`` propagation). Schema migrations 135/136
|
||||
# ship unconditionally because they're forward-compatible.
|
||||
enable_stream_parity_v2: bool = False
|
||||
enable_stream_parity_v2: bool = True
|
||||
|
||||
# Plugins
|
||||
enable_plugin_loader: bool = False
|
||||
|
|
@ -115,43 +116,64 @@ class AgentFeatureFlags:
|
|||
"SURFSENSE_DISABLE_NEW_AGENT_STACK is set: every new agent "
|
||||
"middleware is forced OFF for this build."
|
||||
)
|
||||
return cls(disable_new_agent_stack=True)
|
||||
return cls(
|
||||
disable_new_agent_stack=True,
|
||||
enable_context_editing=False,
|
||||
enable_compaction_v2=False,
|
||||
enable_retry_after=False,
|
||||
enable_model_fallback=False,
|
||||
enable_model_call_limit=False,
|
||||
enable_tool_call_limit=False,
|
||||
enable_tool_call_repair=False,
|
||||
enable_doom_loop=False,
|
||||
enable_permission=False,
|
||||
enable_busy_mutex=False,
|
||||
enable_llm_tool_selector=False,
|
||||
enable_skills=False,
|
||||
enable_specialized_subagents=False,
|
||||
enable_kb_planner_runnable=False,
|
||||
enable_action_log=False,
|
||||
enable_revert_route=False,
|
||||
enable_stream_parity_v2=False,
|
||||
enable_plugin_loader=False,
|
||||
enable_otel=False,
|
||||
)
|
||||
|
||||
return cls(
|
||||
disable_new_agent_stack=False,
|
||||
# Agent quality
|
||||
enable_context_editing=_env_bool("SURFSENSE_ENABLE_CONTEXT_EDITING", False),
|
||||
enable_compaction_v2=_env_bool("SURFSENSE_ENABLE_COMPACTION_V2", False),
|
||||
enable_retry_after=_env_bool("SURFSENSE_ENABLE_RETRY_AFTER", False),
|
||||
enable_context_editing=_env_bool("SURFSENSE_ENABLE_CONTEXT_EDITING", True),
|
||||
enable_compaction_v2=_env_bool("SURFSENSE_ENABLE_COMPACTION_V2", True),
|
||||
enable_retry_after=_env_bool("SURFSENSE_ENABLE_RETRY_AFTER", True),
|
||||
enable_model_fallback=_env_bool("SURFSENSE_ENABLE_MODEL_FALLBACK", False),
|
||||
enable_model_call_limit=_env_bool(
|
||||
"SURFSENSE_ENABLE_MODEL_CALL_LIMIT", False
|
||||
"SURFSENSE_ENABLE_MODEL_CALL_LIMIT", True
|
||||
),
|
||||
enable_tool_call_limit=_env_bool("SURFSENSE_ENABLE_TOOL_CALL_LIMIT", False),
|
||||
enable_tool_call_limit=_env_bool("SURFSENSE_ENABLE_TOOL_CALL_LIMIT", True),
|
||||
enable_tool_call_repair=_env_bool(
|
||||
"SURFSENSE_ENABLE_TOOL_CALL_REPAIR", False
|
||||
"SURFSENSE_ENABLE_TOOL_CALL_REPAIR", True
|
||||
),
|
||||
enable_doom_loop=_env_bool("SURFSENSE_ENABLE_DOOM_LOOP", False),
|
||||
enable_doom_loop=_env_bool("SURFSENSE_ENABLE_DOOM_LOOP", True),
|
||||
# Safety
|
||||
enable_permission=_env_bool("SURFSENSE_ENABLE_PERMISSION", False),
|
||||
enable_busy_mutex=_env_bool("SURFSENSE_ENABLE_BUSY_MUTEX", False),
|
||||
enable_permission=_env_bool("SURFSENSE_ENABLE_PERMISSION", True),
|
||||
enable_busy_mutex=_env_bool("SURFSENSE_ENABLE_BUSY_MUTEX", True),
|
||||
enable_llm_tool_selector=_env_bool(
|
||||
"SURFSENSE_ENABLE_LLM_TOOL_SELECTOR", False
|
||||
),
|
||||
# Skills + subagents
|
||||
enable_skills=_env_bool("SURFSENSE_ENABLE_SKILLS", False),
|
||||
enable_skills=_env_bool("SURFSENSE_ENABLE_SKILLS", True),
|
||||
enable_specialized_subagents=_env_bool(
|
||||
"SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS", False
|
||||
"SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS", True
|
||||
),
|
||||
enable_kb_planner_runnable=_env_bool(
|
||||
"SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE", False
|
||||
"SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE", True
|
||||
),
|
||||
# Snapshot / revert
|
||||
enable_action_log=_env_bool("SURFSENSE_ENABLE_ACTION_LOG", False),
|
||||
enable_revert_route=_env_bool("SURFSENSE_ENABLE_REVERT_ROUTE", False),
|
||||
enable_action_log=_env_bool("SURFSENSE_ENABLE_ACTION_LOG", True),
|
||||
enable_revert_route=_env_bool("SURFSENSE_ENABLE_REVERT_ROUTE", True),
|
||||
# Streaming parity v2
|
||||
enable_stream_parity_v2=_env_bool(
|
||||
"SURFSENSE_ENABLE_STREAM_PARITY_V2", False
|
||||
"SURFSENSE_ENABLE_STREAM_PARITY_V2", True
|
||||
),
|
||||
# Plugins
|
||||
enable_plugin_loader=_env_bool("SURFSENSE_ENABLE_PLUGIN_LOADER", False),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from fastapi import APIRouter, Depends
|
|||
from pydantic import BaseModel
|
||||
|
||||
from app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags
|
||||
from app.config import config
|
||||
from app.db import User
|
||||
from app.users import current_active_user
|
||||
|
||||
|
|
@ -58,10 +59,15 @@ class AgentFeatureFlagsRead(BaseModel):
|
|||
|
||||
enable_otel: bool
|
||||
|
||||
enable_desktop_local_filesystem: bool
|
||||
|
||||
@classmethod
|
||||
def from_flags(cls, flags: AgentFeatureFlags) -> AgentFeatureFlagsRead:
|
||||
# asdict() avoids missing-field bugs when AgentFeatureFlags grows.
|
||||
return cls(**asdict(flags))
|
||||
return cls(
|
||||
**asdict(flags),
|
||||
enable_desktop_local_filesystem=config.ENABLE_DESKTOP_LOCAL_FILESYSTEM,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/agent/flags", response_model=AgentFeatureFlagsRead)
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ async def resolve_or_get_pinned_llm_config_id(
|
|||
False if force_repin_free else await _is_premium_eligible(session, user_id)
|
||||
)
|
||||
if premium_eligible:
|
||||
eligible = candidates
|
||||
eligible = [c for c in candidates if _tier_of(c) == "premium"]
|
||||
else:
|
||||
eligible = [c for c in candidates if _tier_of(c) != "premium"]
|
||||
|
||||
|
|
|
|||
|
|
@ -31,18 +31,38 @@ def _clear_all(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||
"SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE",
|
||||
"SURFSENSE_ENABLE_ACTION_LOG",
|
||||
"SURFSENSE_ENABLE_REVERT_ROUTE",
|
||||
"SURFSENSE_ENABLE_STREAM_PARITY_V2",
|
||||
"SURFSENSE_ENABLE_PLUGIN_LOADER",
|
||||
"SURFSENSE_ENABLE_OTEL",
|
||||
]:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def test_defaults_all_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_defaults_match_shipped_agent_stack(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_clear_all(monkeypatch)
|
||||
flags = reload_for_tests()
|
||||
assert isinstance(flags, AgentFeatureFlags)
|
||||
assert flags.disable_new_agent_stack is False
|
||||
assert flags.any_new_middleware_enabled() is False
|
||||
assert flags.enable_context_editing is True
|
||||
assert flags.enable_compaction_v2 is True
|
||||
assert flags.enable_retry_after is True
|
||||
assert flags.enable_model_fallback is False
|
||||
assert flags.enable_model_call_limit is True
|
||||
assert flags.enable_tool_call_limit is True
|
||||
assert flags.enable_tool_call_repair is True
|
||||
assert flags.enable_doom_loop is True
|
||||
assert flags.enable_permission is True
|
||||
assert flags.enable_busy_mutex is True
|
||||
assert flags.enable_llm_tool_selector is False
|
||||
assert flags.enable_skills is True
|
||||
assert flags.enable_specialized_subagents is True
|
||||
assert flags.enable_kb_planner_runnable is True
|
||||
assert flags.enable_action_log is True
|
||||
assert flags.enable_revert_route is True
|
||||
assert flags.enable_stream_parity_v2 is True
|
||||
assert flags.enable_plugin_loader is False
|
||||
assert flags.enable_otel is False
|
||||
assert flags.any_new_middleware_enabled() is True
|
||||
|
||||
|
||||
def test_master_kill_switch_overrides_individual_flags(
|
||||
|
|
@ -100,21 +120,13 @@ def test_each_flag_can_be_set_independently(monkeypatch: pytest.MonkeyPatch) ->
|
|||
"enable_kb_planner_runnable": "SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE",
|
||||
"enable_action_log": "SURFSENSE_ENABLE_ACTION_LOG",
|
||||
"enable_revert_route": "SURFSENSE_ENABLE_REVERT_ROUTE",
|
||||
"enable_stream_parity_v2": "SURFSENSE_ENABLE_STREAM_PARITY_V2",
|
||||
"enable_plugin_loader": "SURFSENSE_ENABLE_PLUGIN_LOADER",
|
||||
"enable_otel": "SURFSENSE_ENABLE_OTEL",
|
||||
}
|
||||
|
||||
# `enable_otel` is intentionally orthogonal — it does NOT count toward
|
||||
# ``any_new_middleware_enabled`` because OTel is observability-only and
|
||||
# ships under its own ``OTEL_EXPORTER_OTLP_ENDPOINT`` requirement.
|
||||
counts_toward_middleware = {k for k in flag_to_env if k != "enable_otel"}
|
||||
|
||||
for attr, env_name in flag_to_env.items():
|
||||
_clear_all(monkeypatch)
|
||||
monkeypatch.setenv(env_name, "true")
|
||||
monkeypatch.setenv(env_name, "false")
|
||||
flags = reload_for_tests()
|
||||
assert getattr(flags, attr) is True, f"{attr} did not flip on for {env_name}"
|
||||
if attr in counts_toward_middleware:
|
||||
assert flags.any_new_middleware_enabled() is True
|
||||
else:
|
||||
assert flags.any_new_middleware_enabled() is False
|
||||
assert getattr(flags, attr) is False, f"{attr} did not flip off for {env_name}"
|
||||
|
|
|
|||
|
|
@ -101,11 +101,58 @@ async def test_auto_first_turn_pins_one_model(monkeypatch):
|
|||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
selected_llm_config_id=0,
|
||||
)
|
||||
assert result.resolved_llm_config_id in {-1, -2}
|
||||
assert result.resolved_llm_config_id == -1
|
||||
assert session.thread.pinned_llm_config_id == result.resolved_llm_config_id
|
||||
assert session.commit_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_premium_eligible_auto_prefers_premium_over_free(monkeypatch):
|
||||
from app.config import config
|
||||
|
||||
session = _FakeSession(_thread())
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"GLOBAL_LLM_CONFIGS",
|
||||
[
|
||||
{
|
||||
"id": -2,
|
||||
"provider": "OPENAI",
|
||||
"model_name": "gpt-free",
|
||||
"api_key": "k1",
|
||||
"billing_tier": "free",
|
||||
"quality_score": 100,
|
||||
},
|
||||
{
|
||||
"id": -1,
|
||||
"provider": "OPENAI",
|
||||
"model_name": "gpt-prem",
|
||||
"api_key": "k2",
|
||||
"billing_tier": "premium",
|
||||
"quality_score": 10,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
async def _allowed(*_args, **_kwargs):
|
||||
return _FakeQuotaResult(allowed=True)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage",
|
||||
_allowed,
|
||||
)
|
||||
|
||||
result = await resolve_or_get_pinned_llm_config_id(
|
||||
session,
|
||||
thread_id=1,
|
||||
search_space_id=10,
|
||||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
selected_llm_config_id=0,
|
||||
)
|
||||
assert result.resolved_llm_config_id == -1
|
||||
assert result.resolved_tier == "premium"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_next_turn_reuses_existing_pin(monkeypatch):
|
||||
from app.config import config
|
||||
|
|
@ -361,12 +408,12 @@ async def test_invalid_pinned_config_repairs_with_new_pin(monkeypatch):
|
|||
],
|
||||
)
|
||||
|
||||
async def _allowed(*_args, **_kwargs):
|
||||
return _FakeQuotaResult(allowed=True)
|
||||
async def _blocked(*_args, **_kwargs):
|
||||
return _FakeQuotaResult(allowed=False)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.auto_model_pin_service.TokenQuotaService.premium_get_usage",
|
||||
_allowed,
|
||||
_blocked,
|
||||
)
|
||||
|
||||
result = await resolve_or_get_pinned_llm_config_id(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue