refactor(agents): move feature flags to app/agents/shared/feature_flags (slice 2b)

Promote the agent feature-flag resolver (AgentFeatureFlags / get_flags) out of
`new_chat` into the cross-agent `app/agents/shared` kernel.

feature_flags is a pure leaf consumed across the multi-agent middleware stack,
the chat routes, and tests. Moved it via git mv (content unchanged) and flipped
all 37 importers to app.agents.shared.feature_flags. A thin re-export shim
remains at new_chat/feature_flags.py only for the not-yet-retired single-agent
(chat_deepagent); it goes away with the single-agent deletion.

Behavior-preserving: only import paths change. 1243 tests green.
This commit is contained in:
CREDO23 2026-06-04 12:23:12 +02:00
parent 28b13ed25b
commit a975754e7d
38 changed files with 304 additions and 282 deletions

View file

@ -15,7 +15,7 @@ from app.agents.multi_agent_chat.middleware.stack import (
build_main_agent_deepagent_middleware, build_main_agent_deepagent_middleware,
) )
from app.agents.shared.context import SurfSenseContextSchema from app.agents.shared.context import SurfSenseContextSchema
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.db import ChatVisibility from app.db import ChatVisibility

View file

@ -17,7 +17,7 @@ from app.agents.new_chat.agent_cache import (
system_prompt_hash, system_prompt_hash,
tools_signature, tools_signature,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.db import ChatVisibility from app.db import ChatVisibility

View file

@ -22,7 +22,7 @@ from app.agents.multi_agent_chat.subagents.mcp_tools.index import (
from app.agents.new_chat.connector_searchable_types import ( from app.agents.new_chat.connector_searchable_types import (
map_connectors_to_searchable_types, map_connectors_to_searchable_types,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags from app.agents.shared.feature_flags import AgentFeatureFlags, get_flags
from app.agents.new_chat.filesystem_backends import build_backend_resolver from app.agents.new_chat.filesystem_backends import build_backend_resolver
from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection from app.agents.new_chat.filesystem_selection import FilesystemMode, FilesystemSelection
from app.agents.new_chat.llm_config import AgentConfig from app.agents.new_chat.llm_config import AgentConfig

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import ActionLogMiddleware from app.agents.new_chat.middleware import ActionLogMiddleware
from app.agents.new_chat.tools.registry import BUILTIN_TOOLS from app.agents.new_chat.tools.registry import BUILTIN_TOOLS

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import BusyMutexMiddleware from app.agents.new_chat.middleware import BusyMutexMiddleware
from ..shared.flags import enabled from ..shared.flags import enabled

View file

@ -10,7 +10,7 @@ from langchain_core.tools import BaseTool
from app.agents.multi_agent_chat.main_agent.context_prune.prune_tool_names import ( from app.agents.multi_agent_chat.main_agent.context_prune.prune_tool_names import (
safe_exclude_tools, safe_exclude_tools,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import ( from app.agents.new_chat.middleware import (
ClearToolUsesEdit, ClearToolUsesEdit,
SpillingContextEditingMiddleware, SpillingContextEditingMiddleware,

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import DoomLoopMiddleware from app.agents.new_chat.middleware import DoomLoopMiddleware
from ..shared.flags import enabled from ..shared.flags import enabled

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import NoopInjectionMiddleware from app.agents.new_chat.middleware import NoopInjectionMiddleware
from ..shared.flags import enabled from ..shared.flags import enabled

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import OtelSpanMiddleware from app.agents.new_chat.middleware import OtelSpanMiddleware
from ..shared.flags import enabled from ..shared.flags import enabled

View file

@ -7,7 +7,7 @@ from typing import Any
from langchain_core.language_models import BaseChatModel from langchain_core.language_models import BaseChatModel
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.plugin_loader import ( from app.agents.new_chat.plugin_loader import (
PluginContext, PluginContext,
load_allowed_plugin_names_from_env, load_allowed_plugin_names_from_env,

View file

@ -6,7 +6,7 @@ from collections.abc import Sequence
from langchain_core.tools import BaseTool from langchain_core.tools import BaseTool
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import ToolCallNameRepairMiddleware from app.agents.new_chat.middleware import ToolCallNameRepairMiddleware
from ..shared.flags import enabled from ..shared.flags import enabled

View file

@ -6,7 +6,7 @@ import logging
from deepagents.middleware.skills import SkillsMiddleware from deepagents.middleware.skills import SkillsMiddleware
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.middleware import ( from app.agents.new_chat.middleware import (
build_skills_backend_factory, build_skills_backend_factory,

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
def enabled(flags: AgentFeatureFlags, attr: str) -> bool: def enabled(flags: AgentFeatureFlags, attr: str) -> bool:

View file

@ -27,7 +27,7 @@ from collections.abc import Sequence
from langchain_core.tools import BaseTool from langchain_core.tools import BaseTool
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.permissions import Rule, Ruleset from app.agents.new_chat.permissions import Rule, Ruleset
from app.services.user_tool_allowlist import TrustedToolSaver from app.services.user_tool_allowlist import TrustedToolSaver

View file

@ -10,7 +10,7 @@ from langchain.agents.middleware import (
ToolCallLimitMiddleware, ToolCallLimitMiddleware,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import RetryAfterMiddleware from app.agents.new_chat.middleware import RetryAfterMiddleware
from app.agents.new_chat.middleware.scoped_model_fallback import ( from app.agents.new_chat.middleware.scoped_model_fallback import (
ScopedModelFallbackMiddleware, ScopedModelFallbackMiddleware,

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import logging import logging
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware.scoped_model_fallback import ( from app.agents.new_chat.middleware.scoped_model_fallback import (
ScopedModelFallbackMiddleware, ScopedModelFallbackMiddleware,
) )

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from langchain.agents.middleware import ModelCallLimitMiddleware from langchain.agents.middleware import ModelCallLimitMiddleware
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from ..flags import enabled from ..flags import enabled

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware import RetryAfterMiddleware from app.agents.new_chat.middleware import RetryAfterMiddleware
from ..flags import enabled from ..flags import enabled

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from langchain.agents.middleware import ToolCallLimitMiddleware from langchain.agents.middleware import ToolCallLimitMiddleware
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from ..flags import enabled from ..flags import enabled

View file

@ -31,7 +31,7 @@ from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.agent import
from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.ask_knowledge_base_tool import ( from app.agents.multi_agent_chat.subagents.builtins.knowledge_base.ask_knowledge_base_tool import (
build_ask_knowledge_base_tool, build_ask_knowledge_base_tool,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.db import ChatVisibility from app.db import ChatVisibility

View file

@ -14,7 +14,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from ..shared.permissions import build_permission_mw from ..shared.permissions import build_permission_mw
from ..shared.resilience import ResilienceMiddlewares from ..shared.resilience import ResilienceMiddlewares

View file

@ -28,7 +28,7 @@ from app.agents.multi_agent_chat.middleware.shared.patch_tool_calls import (
from app.agents.multi_agent_chat.middleware.shared.permissions import ( from app.agents.multi_agent_chat.middleware.shared.permissions import (
build_permission_mw, build_permission_mw,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.permissions import Ruleset from app.agents.new_chat.permissions import Ruleset

View file

@ -1,254 +1,19 @@
""" """Backward-compatible shim.
Feature flags for the SurfSense new_chat agent stack.
These flags gate the newer agent middleware (some ported from OpenCode, The agent feature-flag resolver moved to :mod:`app.agents.shared.feature_flags`
some sourced from ``langchain.agents.middleware`` / ``deepagents``, some as part of promoting the shared agent toolkit out of ``new_chat`` into the
SurfSense-native). Most shipped agent-stack upgrades default ON so Docker cross-agent kernel. Import from there directly; this re-export keeps the
image updates work even when older installs do not have newly introduced not-yet-retired single-agent stack working during the migration and will be
environment variables. Risky/experimental integrations stay default OFF, removed with it.
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
middleware is disabled regardless of its individual flag. This gives
operators a single switch to revert to pre-port behavior.
Examples
--------
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=true
SURFSENSE_ENABLE_DOOM_LOOP=true
SURFSENSE_ENABLE_LLM_TOOL_SELECTOR=false # adds a per-turn LLM call
Master kill-switch (overrides everything else):
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
""" """
from __future__ import annotations from __future__ import annotations
import logging from app.agents.shared.feature_flags import (
import os AgentFeatureFlags,
from dataclasses import dataclass get_flags,
reload_for_tests,
logger = logging.getLogger(__name__) )
def _env_bool(name: str, default: bool) -> bool:
"""Parse a boolean env var. Accepts ``1``/``true``/``yes``/``on`` (case-insensitive)."""
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in ("1", "true", "yes", "on")
@dataclass(frozen=True)
class AgentFeatureFlags:
"""Resolved feature-flag state for one agent build.
Constructed via :meth:`from_env`. The dataclass is frozen so it can be
safely shared across coroutines.
"""
# Master kill-switch — when true, every flag below resolves to False
# regardless of its env value. Used for rapid rollback.
disable_new_agent_stack: bool = False
# Agent quality — context budget, retry/limits, name-repair, doom-loop
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 = 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 = True
enable_busy_mutex: bool = True
enable_llm_tool_selector: bool = False # Default OFF — adds per-turn LLM cost
# Skills + subagents
enable_skills: bool = True
enable_specialized_subagents: bool = True
enable_kb_planner_runnable: bool = True
# Snapshot / revert
enable_action_log: bool = True
enable_revert_route: bool = True
# Plugins
enable_plugin_loader: bool = False
# Observability — OTel (orthogonal; also requires OTEL_EXPORTER_OTLP_ENDPOINT)
enable_otel: bool = False
# Performance — compiled-agent cache (Phase 1 + Phase 2).
# When ON, ``create_surfsense_deep_agent`` reuses a previously-compiled
# graph if the cache key matches (LLM config + thread + tool surface +
# flags + system prompt + filesystem mode). Cuts per-turn agent-build
# wall clock from ~4-5s to <50µs on cache hits.
#
# SAFETY (Phase 2 unblocked this default-on):
# All connector mutation tools (``tools/notion``, ``tools/gmail``,
# ``tools/google_drive``, ``tools/dropbox``, ``tools/onedrive``,
# ``tools/google_calendar``, ``tools/confluence``, ``tools/discord``,
# ``tools/teams``, ``tools/luma``, ``connected_accounts``,
# ``update_memory``) now acquire fresh
# short-lived ``AsyncSession`` instances per call via
# :data:`async_session_maker`. The factory still accepts ``db_session``
# for registry compatibility but ``del``'s it immediately — see any
# of those files' factory docstrings for the rationale. The ``llm``
# closure is per-(provider, model, config_id) which is already in
# the cache key, so the LLM is safe to share across cached hits of
# the same key. The KB priority middleware reads
# ``mentioned_document_ids`` from ``runtime.context`` (Phase 1.5),
# not its constructor closure, so the same compiled agent serves
# turns with different mention lists correctly.
#
# Rollback: set ``SURFSENSE_ENABLE_AGENT_CACHE=false`` in the
# environment if a regression surfaces. The path is exercised by
# the ``tests/unit/agents/new_chat/test_agent_cache_*`` suite.
enable_agent_cache: bool = True
# Phase 1 (deferred — measure first): pre-build & share the
# general-purpose subagent ``CompiledSubAgent`` across cold-cache
# misses. Only helps when the outer cache MISSES (cache hits already
# reuse the entire SubAgentMiddleware-compiled graph). Off by default
# until we have data showing cold misses are frequent enough to
# justify the extra global state.
enable_agent_cache_share_gp_subagent: bool = False
@classmethod
def from_env(cls) -> AgentFeatureFlags:
"""Read flags from environment.
Master kill-switch is evaluated first; when set, all other flags
force to False.
"""
master_off = _env_bool("SURFSENSE_DISABLE_NEW_AGENT_STACK", False)
if master_off:
logger.info(
"SURFSENSE_DISABLE_NEW_AGENT_STACK is set: every new agent "
"middleware is forced OFF for this build."
)
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_plugin_loader=False,
enable_otel=False,
enable_agent_cache=False,
enable_agent_cache_share_gp_subagent=False,
)
return cls(
disable_new_agent_stack=False,
# Agent quality
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", True
),
enable_tool_call_limit=_env_bool("SURFSENSE_ENABLE_TOOL_CALL_LIMIT", True),
enable_tool_call_repair=_env_bool(
"SURFSENSE_ENABLE_TOOL_CALL_REPAIR", True
),
enable_doom_loop=_env_bool("SURFSENSE_ENABLE_DOOM_LOOP", True),
# Safety
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", True),
enable_specialized_subagents=_env_bool(
"SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS", True
),
enable_kb_planner_runnable=_env_bool(
"SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE", True
),
# Snapshot / revert
enable_action_log=_env_bool("SURFSENSE_ENABLE_ACTION_LOG", True),
enable_revert_route=_env_bool("SURFSENSE_ENABLE_REVERT_ROUTE", True),
# Plugins
enable_plugin_loader=_env_bool("SURFSENSE_ENABLE_PLUGIN_LOADER", False),
# Observability
enable_otel=_env_bool("SURFSENSE_ENABLE_OTEL", False),
# Performance
enable_agent_cache=_env_bool("SURFSENSE_ENABLE_AGENT_CACHE", True),
enable_agent_cache_share_gp_subagent=_env_bool(
"SURFSENSE_ENABLE_AGENT_CACHE_SHARE_GP_SUBAGENT", False
),
)
def any_new_middleware_enabled(self) -> bool:
"""Return True if any new middleware flag is on."""
if self.disable_new_agent_stack:
return False
return any(
(
self.enable_context_editing,
self.enable_compaction_v2,
self.enable_retry_after,
self.enable_model_fallback,
self.enable_model_call_limit,
self.enable_tool_call_limit,
self.enable_tool_call_repair,
self.enable_doom_loop,
self.enable_permission,
self.enable_busy_mutex,
self.enable_llm_tool_selector,
self.enable_skills,
self.enable_specialized_subagents,
self.enable_kb_planner_runnable,
self.enable_action_log,
self.enable_revert_route,
self.enable_plugin_loader,
)
)
def get_flags() -> AgentFeatureFlags:
"""Return the resolved feature-flag state from the **current** process environment.
Intentionally **not** cached: ``load_dotenv`` and operator edits to env vars
must affect the next agent build without requiring a full process restart.
Cost is negligible (reads ``os.environ`` once per call).
"""
return AgentFeatureFlags.from_env()
def reload_for_tests() -> AgentFeatureFlags:
"""Compatibility helper for tests; equivalent to :func:`get_flags`."""
return AgentFeatureFlags.from_env()
__all__ = [ __all__ = [
"AgentFeatureFlags", "AgentFeatureFlags",

View file

@ -33,7 +33,7 @@ from langchain.agents.middleware import AgentMiddleware
from langchain_core.callbacks import adispatch_custom_event from langchain_core.callbacks import adispatch_custom_event
from langchain_core.messages import ToolMessage from langchain_core.messages import ToolMessage
from app.agents.new_chat.feature_flags import get_flags from app.agents.shared.feature_flags import get_flags
from app.agents.new_chat.tools.registry import ToolDefinition from app.agents.new_chat.tools.registry import ToolDefinition
if TYPE_CHECKING: # pragma: no cover - type-only if TYPE_CHECKING: # pragma: no cover - type-only

View file

@ -45,7 +45,7 @@ from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.feature_flags import get_flags from app.agents.shared.feature_flags import get_flags
from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
from app.agents.new_chat.path_resolver import ( from app.agents.new_chat.path_resolver import (

View file

@ -41,7 +41,7 @@ from litellm import token_counter
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select from sqlalchemy import select
from app.agents.new_chat.feature_flags import get_flags from app.agents.shared.feature_flags import get_flags
from app.agents.new_chat.filesystem_selection import FilesystemMode from app.agents.new_chat.filesystem_selection import FilesystemMode
from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState from app.agents.new_chat.filesystem_state import SurfSenseFilesystemState
from app.agents.new_chat.path_resolver import ( from app.agents.new_chat.path_resolver import (

View file

@ -0,0 +1,257 @@
"""
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). 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
middleware is disabled regardless of its individual flag. This gives
operators a single switch to revert to pre-port behavior.
Examples
--------
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=true
SURFSENSE_ENABLE_DOOM_LOOP=true
SURFSENSE_ENABLE_LLM_TOOL_SELECTOR=false # adds a per-turn LLM call
Master kill-switch (overrides everything else):
SURFSENSE_DISABLE_NEW_AGENT_STACK=true
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
logger = logging.getLogger(__name__)
def _env_bool(name: str, default: bool) -> bool:
"""Parse a boolean env var. Accepts ``1``/``true``/``yes``/``on`` (case-insensitive)."""
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in ("1", "true", "yes", "on")
@dataclass(frozen=True)
class AgentFeatureFlags:
"""Resolved feature-flag state for one agent build.
Constructed via :meth:`from_env`. The dataclass is frozen so it can be
safely shared across coroutines.
"""
# Master kill-switch — when true, every flag below resolves to False
# regardless of its env value. Used for rapid rollback.
disable_new_agent_stack: bool = False
# Agent quality — context budget, retry/limits, name-repair, doom-loop
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 = 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 = True
enable_busy_mutex: bool = True
enable_llm_tool_selector: bool = False # Default OFF — adds per-turn LLM cost
# Skills + subagents
enable_skills: bool = True
enable_specialized_subagents: bool = True
enable_kb_planner_runnable: bool = True
# Snapshot / revert
enable_action_log: bool = True
enable_revert_route: bool = True
# Plugins
enable_plugin_loader: bool = False
# Observability — OTel (orthogonal; also requires OTEL_EXPORTER_OTLP_ENDPOINT)
enable_otel: bool = False
# Performance — compiled-agent cache (Phase 1 + Phase 2).
# When ON, ``create_surfsense_deep_agent`` reuses a previously-compiled
# graph if the cache key matches (LLM config + thread + tool surface +
# flags + system prompt + filesystem mode). Cuts per-turn agent-build
# wall clock from ~4-5s to <50µs on cache hits.
#
# SAFETY (Phase 2 unblocked this default-on):
# All connector mutation tools (``tools/notion``, ``tools/gmail``,
# ``tools/google_drive``, ``tools/dropbox``, ``tools/onedrive``,
# ``tools/google_calendar``, ``tools/confluence``, ``tools/discord``,
# ``tools/teams``, ``tools/luma``, ``connected_accounts``,
# ``update_memory``) now acquire fresh
# short-lived ``AsyncSession`` instances per call via
# :data:`async_session_maker`. The factory still accepts ``db_session``
# for registry compatibility but ``del``'s it immediately — see any
# of those files' factory docstrings for the rationale. The ``llm``
# closure is per-(provider, model, config_id) which is already in
# the cache key, so the LLM is safe to share across cached hits of
# the same key. The KB priority middleware reads
# ``mentioned_document_ids`` from ``runtime.context`` (Phase 1.5),
# not its constructor closure, so the same compiled agent serves
# turns with different mention lists correctly.
#
# Rollback: set ``SURFSENSE_ENABLE_AGENT_CACHE=false`` in the
# environment if a regression surfaces. The path is exercised by
# the ``tests/unit/agents/new_chat/test_agent_cache_*`` suite.
enable_agent_cache: bool = True
# Phase 1 (deferred — measure first): pre-build & share the
# general-purpose subagent ``CompiledSubAgent`` across cold-cache
# misses. Only helps when the outer cache MISSES (cache hits already
# reuse the entire SubAgentMiddleware-compiled graph). Off by default
# until we have data showing cold misses are frequent enough to
# justify the extra global state.
enable_agent_cache_share_gp_subagent: bool = False
@classmethod
def from_env(cls) -> AgentFeatureFlags:
"""Read flags from environment.
Master kill-switch is evaluated first; when set, all other flags
force to False.
"""
master_off = _env_bool("SURFSENSE_DISABLE_NEW_AGENT_STACK", False)
if master_off:
logger.info(
"SURFSENSE_DISABLE_NEW_AGENT_STACK is set: every new agent "
"middleware is forced OFF for this build."
)
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_plugin_loader=False,
enable_otel=False,
enable_agent_cache=False,
enable_agent_cache_share_gp_subagent=False,
)
return cls(
disable_new_agent_stack=False,
# Agent quality
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", True
),
enable_tool_call_limit=_env_bool("SURFSENSE_ENABLE_TOOL_CALL_LIMIT", True),
enable_tool_call_repair=_env_bool(
"SURFSENSE_ENABLE_TOOL_CALL_REPAIR", True
),
enable_doom_loop=_env_bool("SURFSENSE_ENABLE_DOOM_LOOP", True),
# Safety
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", True),
enable_specialized_subagents=_env_bool(
"SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS", True
),
enable_kb_planner_runnable=_env_bool(
"SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE", True
),
# Snapshot / revert
enable_action_log=_env_bool("SURFSENSE_ENABLE_ACTION_LOG", True),
enable_revert_route=_env_bool("SURFSENSE_ENABLE_REVERT_ROUTE", True),
# Plugins
enable_plugin_loader=_env_bool("SURFSENSE_ENABLE_PLUGIN_LOADER", False),
# Observability
enable_otel=_env_bool("SURFSENSE_ENABLE_OTEL", False),
# Performance
enable_agent_cache=_env_bool("SURFSENSE_ENABLE_AGENT_CACHE", True),
enable_agent_cache_share_gp_subagent=_env_bool(
"SURFSENSE_ENABLE_AGENT_CACHE_SHARE_GP_SUBAGENT", False
),
)
def any_new_middleware_enabled(self) -> bool:
"""Return True if any new middleware flag is on."""
if self.disable_new_agent_stack:
return False
return any(
(
self.enable_context_editing,
self.enable_compaction_v2,
self.enable_retry_after,
self.enable_model_fallback,
self.enable_model_call_limit,
self.enable_tool_call_limit,
self.enable_tool_call_repair,
self.enable_doom_loop,
self.enable_permission,
self.enable_busy_mutex,
self.enable_llm_tool_selector,
self.enable_skills,
self.enable_specialized_subagents,
self.enable_kb_planner_runnable,
self.enable_action_log,
self.enable_revert_route,
self.enable_plugin_loader,
)
)
def get_flags() -> AgentFeatureFlags:
"""Return the resolved feature-flag state from the **current** process environment.
Intentionally **not** cached: ``load_dotenv`` and operator edits to env vars
must affect the next agent build without requiring a full process restart.
Cost is negligible (reads ``os.environ`` once per call).
"""
return AgentFeatureFlags.from_env()
def reload_for_tests() -> AgentFeatureFlags:
"""Compatibility helper for tests; equivalent to :func:`get_flags`."""
return AgentFeatureFlags.from_env()
__all__ = [
"AgentFeatureFlags",
"get_flags",
"reload_for_tests",
]

View file

@ -28,7 +28,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.feature_flags import get_flags from app.agents.shared.feature_flags import get_flags
from app.db import ( from app.db import (
AgentActionLog, AgentActionLog,
NewChatThread, NewChatThread,

View file

@ -22,7 +22,7 @@ from dataclasses import asdict
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from app.agents.new_chat.feature_flags import AgentFeatureFlags, get_flags from app.agents.shared.feature_flags import AgentFeatureFlags, get_flags
from app.config import config from app.config import config
from app.db import User from app.db import User
from app.users import current_active_user from app.users import current_active_user

View file

@ -30,7 +30,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.feature_flags import get_flags from app.agents.shared.feature_flags import get_flags
from app.db import ( from app.db import (
AgentPermissionRule, AgentPermissionRule,
NewChatThread, NewChatThread,

View file

@ -32,7 +32,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.new_chat.feature_flags import get_flags from app.agents.shared.feature_flags import get_flags
from app.db import ( from app.db import (
AgentActionLog, AgentActionLog,
User, User,

View file

@ -19,7 +19,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions import (
from app.agents.multi_agent_chat.middleware.shared.permissions.ask.payload import ( from app.agents.multi_agent_chat.middleware.shared.permissions.ask.payload import (
build_permission_ask_payload, build_permission_ask_payload,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.permissions import Rule, Ruleset from app.agents.new_chat.permissions import Rule, Ruleset

View file

@ -26,7 +26,7 @@ from typing_extensions import TypedDict
from app.agents.multi_agent_chat.middleware.shared.permissions import ( from app.agents.multi_agent_chat.middleware.shared.permissions import (
build_permission_mw, build_permission_mw,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.permissions import Rule, Ruleset from app.agents.new_chat.permissions import Rule, Ruleset

View file

@ -17,7 +17,7 @@ from typing_extensions import TypedDict
from app.agents.multi_agent_chat.middleware.shared.permissions import ( from app.agents.multi_agent_chat.middleware.shared.permissions import (
build_permission_mw, build_permission_mw,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.permissions import Rule, Ruleset from app.agents.new_chat.permissions import Rule, Ruleset

View file

@ -25,7 +25,7 @@ from app.agents.multi_agent_chat.middleware.shared.permissions.middleware.core i
from app.agents.multi_agent_chat.subagents.shared.subagent_builder import ( from app.agents.multi_agent_chat.subagents.shared.subagent_builder import (
pack_subagent, pack_subagent,
) )
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.permissions import Rule, Ruleset, evaluate from app.agents.new_chat.permissions import Rule, Ruleset, evaluate

View file

@ -10,7 +10,7 @@ import pytest
from langchain_core.messages import ToolMessage from langchain_core.messages import ToolMessage
from langchain_core.tools import tool from langchain_core.tools import tool
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.agents.new_chat.middleware.action_log import ActionLogMiddleware from app.agents.new_chat.middleware.action_log import ActionLogMiddleware
from app.agents.new_chat.tools.registry import ToolDefinition from app.agents.new_chat.tools.registry import ToolDefinition

View file

@ -4,7 +4,7 @@ from __future__ import annotations
import pytest import pytest
from app.agents.new_chat.feature_flags import ( from app.agents.shared.feature_flags import (
AgentFeatureFlags, AgentFeatureFlags,
reload_for_tests, reload_for_tests,
) )

View file

@ -18,7 +18,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from app.agents.new_chat.feature_flags import AgentFeatureFlags from app.agents.shared.feature_flags import AgentFeatureFlags
from app.routes import agent_revert_route from app.routes import agent_revert_route
from app.services.revert_service import RevertOutcome from app.services.revert_service import RevertOutcome