From b9cea6c6477fa546fc5567ec4b5ade064226b5a8 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 00:50:47 -0700 Subject: [PATCH] [eric] agents: drop the Mixin suffix, name the 9 manager classes by domain (Messaging, SessionLifecycle, ...) + AgentManagerState -> AgentManagerProtocol --- backend/apps/agents/agent_manager.py | 20 +++---- .../{AgentLaunchMixin.py => AgentLaunch.py} | 6 +- ...anagerState.py => AgentManagerProtocol.py} | 6 +- .../{MessagingMixin.py => Messaging.py} | 6 +- .../{MockAgentMixin.py => MockAgent.py} | 8 +-- .../{RunSupportMixin.py => RunSupport.py} | 4 +- ...ssionControlMixin.py => SessionControl.py} | 6 +- .../agents/manager/configure_provider_env.py | 2 +- .../run/{RunOptionsMixin.py => RunOptions.py} | 4 +- .../run/{TurnRunnerMixin.py => TurnRunner.py} | 4 +- ...nLifecycleMixin.py => SessionLifecycle.py} | 4 +- ...sistenceMixin.py => SessionPersistence.py} | 6 +- backend/tests/test_streaming_harness.py | 8 +-- backend/tests/test_v2_invariants.py | 56 +++++++++---------- linter/config/config.json | 2 +- 15 files changed, 71 insertions(+), 71 deletions(-) rename backend/apps/agents/manager/{AgentLaunchMixin.py => AgentLaunch.py} (98%) rename backend/apps/agents/manager/{AgentManagerState.py => AgentManagerProtocol.py} (94%) rename backend/apps/agents/manager/{MessagingMixin.py => Messaging.py} (98%) rename backend/apps/agents/manager/{MockAgentMixin.py => MockAgent.py} (95%) rename backend/apps/agents/manager/{RunSupportMixin.py => RunSupport.py} (99%) rename backend/apps/agents/manager/{SessionControlMixin.py => SessionControl.py} (95%) rename backend/apps/agents/manager/run/{RunOptionsMixin.py => RunOptions.py} (99%) rename backend/apps/agents/manager/run/{TurnRunnerMixin.py => TurnRunner.py} (98%) rename backend/apps/agents/manager/session/{SessionLifecycleMixin.py => SessionLifecycle.py} (98%) rename backend/apps/agents/manager/session/{SessionPersistenceMixin.py => SessionPersistence.py} (94%) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index ad84d55b..a9c23383 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -19,23 +19,23 @@ from backend.apps.agents.manager.session.session_store import ( ) from backend.apps.agents.manager.streaming.state import ThinkingState, TurnState from backend.apps.agents.manager.streaming.LivePartial import LivePartial -from backend.apps.agents.manager.session.SessionLifecycleMixin import SessionLifecycleMixin -from backend.apps.agents.manager.session.SessionPersistenceMixin import SessionPersistenceMixin -from backend.apps.agents.manager.MessagingMixin import MessagingMixin -from backend.apps.agents.manager.SessionControlMixin import SessionControlMixin -from backend.apps.agents.manager.AgentLaunchMixin import AgentLaunchMixin -from backend.apps.agents.manager.MockAgentMixin import MockAgentMixin -from backend.apps.agents.manager.RunSupportMixin import RunSupportMixin +from backend.apps.agents.manager.session.SessionLifecycle import SessionLifecycle +from backend.apps.agents.manager.session.SessionPersistence import SessionPersistence +from backend.apps.agents.manager.Messaging import Messaging +from backend.apps.agents.manager.SessionControl import SessionControl +from backend.apps.agents.manager.AgentLaunch import AgentLaunch +from backend.apps.agents.manager.MockAgent import MockAgent +from backend.apps.agents.manager.RunSupport import RunSupport from backend.apps.agents.manager.run.handle_run_error import handle_run_error -from backend.apps.agents.manager.run.TurnRunnerMixin import TurnRunnerMixin -from backend.apps.agents.manager.run.RunOptionsMixin import RunOptionsMixin +from backend.apps.agents.manager.run.TurnRunner import TurnRunner +from backend.apps.agents.manager.run.RunOptions import RunOptions logger = logging.getLogger(__name__) os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000") -class AgentManager(SessionLifecycleMixin, SessionPersistenceMixin, MessagingMixin, SessionControlMixin, AgentLaunchMixin, MockAgentMixin, TurnRunnerMixin, RunOptionsMixin, RunSupportMixin): +class AgentManager(SessionLifecycle, SessionPersistence, Messaging, SessionControl, AgentLaunch, MockAgent, TurnRunner, RunOptions, RunSupport): @typechecked def __init__(self): self.sessions: Dict[str, AgentSession] = {} diff --git a/backend/apps/agents/manager/AgentLaunchMixin.py b/backend/apps/agents/manager/AgentLaunch.py similarity index 98% rename from backend/apps/agents/manager/AgentLaunchMixin.py rename to backend/apps/agents/manager/AgentLaunch.py index 5edf1266..8ee136e0 100644 --- a/backend/apps/agents/manager/AgentLaunchMixin.py +++ b/backend/apps/agents/manager/AgentLaunch.py @@ -1,5 +1,5 @@ """Agent run entry points for AgentManager: launch a new top-level run and the staticmethod -invoke_agent helper (fork-and-send a sub-agent). The no-SDK mock fallback lives in MockAgentMixin. +invoke_agent helper (fork-and-send a sub-agent). The no-SDK mock fallback lives in MockAgent. Split into a mixin to keep the manager file under the size ceiling; self.run_agent_loop / self.sessions resolve across the MRO exactly as before.""" @@ -29,10 +29,10 @@ from backend.apps.agents.manager.prompt.prompt_context import resolve_mode logger = logging.getLogger(__name__) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol -class AgentLaunchMixin(AgentManagerState): +class AgentLaunch(AgentManagerProtocol): @typechecked async def launch_agent(self, config: AgentConfig) -> AgentSession: session_id = uuid4().hex diff --git a/backend/apps/agents/manager/AgentManagerState.py b/backend/apps/agents/manager/AgentManagerProtocol.py similarity index 94% rename from backend/apps/agents/manager/AgentManagerState.py rename to backend/apps/agents/manager/AgentManagerProtocol.py index 431a3578..efcf02e5 100644 --- a/backend/apps/agents/manager/AgentManagerState.py +++ b/backend/apps/agents/manager/AgentManagerProtocol.py @@ -1,7 +1,7 @@ """Typing-only contract shared by the AgentManager mixins. -The AgentManager god-object was decomposed into behavior mixins (MessagingMixin, -SessionLifecycleMixin, ...) that read state set in AgentManager.__init__ and call +The AgentManager god-object was decomposed into behavior mixins (Messaging, +SessionLifecycle, ...) that read state set in AgentManager.__init__ and call methods implemented on sibling mixins. From inside one mixin a type checker can't see that composed surface, so it flags self.sessions / self.run_agent_loop as unknown. This base declares that surface (the __init__ state + the cross-mixin @@ -20,7 +20,7 @@ if TYPE_CHECKING: from backend.apps.agents.manager.streaming.LivePartial import LivePartial -class AgentManagerState: +class AgentManagerProtocol: # State set in AgentManager.__init__. sessions: Dict[str, AgentSession] tasks: Dict[str, asyncio.Task] diff --git a/backend/apps/agents/manager/MessagingMixin.py b/backend/apps/agents/manager/Messaging.py similarity index 98% rename from backend/apps/agents/manager/MessagingMixin.py rename to backend/apps/agents/manager/Messaging.py index c787f504..501d690c 100644 --- a/backend/apps/agents/manager/MessagingMixin.py +++ b/backend/apps/agents/manager/Messaging.py @@ -1,6 +1,6 @@ """Turn-producing message operations for AgentManager (send + edit), the ones that append a user Message and spawn the agent loop. Session-control ops (stop / approve / branch / update) -live in SessionControlMixin. Pure relocation: self.* resolves across the MRO as before.""" +live in SessionControl. Pure relocation: self.* resolves across the MRO as before.""" import asyncio import logging @@ -21,10 +21,10 @@ from backend.apps.agents.manager.prompt.prompt_context import resolve_mode logger = logging.getLogger(__name__) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol -class MessagingMixin(AgentManagerState): +class Messaging(AgentManagerProtocol): @typechecked async def send_message( self, diff --git a/backend/apps/agents/manager/MockAgentMixin.py b/backend/apps/agents/manager/MockAgent.py similarity index 95% rename from backend/apps/agents/manager/MockAgentMixin.py rename to backend/apps/agents/manager/MockAgent.py index ab75cea3..2aa78801 100644 --- a/backend/apps/agents/manager/MockAgentMixin.py +++ b/backend/apps/agents/manager/MockAgent.py @@ -1,7 +1,7 @@ -"""The no-SDK mock agent loop, split out of AgentLaunchMixin so each file is one concern. This +"""The no-SDK mock agent loop, split out of AgentLaunch so each file is one concern. This fires only when claude_agent_sdk isn't installed (dev fallback): it fakes one Bash approval + tool-result + assistant reply so the UI is exercisable without a real model. self.p_stream_* / -self.sessions resolve across the MRO exactly as when this lived on AgentLaunchMixin.""" +self.sessions resolve across the MRO exactly as when this lived on AgentLaunch.""" import asyncio import json @@ -17,10 +17,10 @@ from backend.apps.agents.core.ws_manager import ws_manager logger = logging.getLogger(__name__) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol -class MockAgentMixin(AgentManagerState): +class MockAgent(AgentManagerProtocol): @typechecked async def run_mock_agent(self, session_id: str, prompt: str): """Mock agent loop for development without claude_agent_sdk installed.""" diff --git a/backend/apps/agents/manager/RunSupportMixin.py b/backend/apps/agents/manager/RunSupport.py similarity index 99% rename from backend/apps/agents/manager/RunSupportMixin.py rename to backend/apps/agents/manager/RunSupport.py index 893af8ed..c37abf50 100644 --- a/backend/apps/agents/manager/RunSupportMixin.py +++ b/backend/apps/agents/manager/RunSupport.py @@ -33,12 +33,12 @@ from backend.apps.tools_lib.tools_lib import ( refresh_google_token, refresh_hubspot_token, ) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol logger = logging.getLogger(__name__) -class RunSupportMixin(AgentManagerState): +class RunSupport(AgentManagerProtocol): @typechecked async def build_mcp_servers( self, diff --git a/backend/apps/agents/manager/SessionControlMixin.py b/backend/apps/agents/manager/SessionControl.py similarity index 95% rename from backend/apps/agents/manager/SessionControlMixin.py rename to backend/apps/agents/manager/SessionControl.py index 3600db38..8b404985 100644 --- a/backend/apps/agents/manager/SessionControlMixin.py +++ b/backend/apps/agents/manager/SessionControl.py @@ -1,5 +1,5 @@ """Session-control operations for AgentManager (stop / approve / switch-branch / update), -split from MessagingMixin so each file stays one responsibility: these control or mutate a +split from Messaging so each file stays one responsibility: these control or mutate a session WITHOUT producing a new agent turn. Pure relocation, self.* resolves across the MRO.""" import asyncio @@ -15,10 +15,10 @@ from backend.apps.agents.manager.session.session_store import save_session logger = logging.getLogger(__name__) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol -class SessionControlMixin(AgentManagerState): +class SessionControl(AgentManagerProtocol): @typechecked async def stop_agent(self, session_id: str): """Stop a running agent and all its browser-agent children.""" diff --git a/backend/apps/agents/manager/configure_provider_env.py b/backend/apps/agents/manager/configure_provider_env.py index c5f51408..ee7fc177 100644 --- a/backend/apps/agents/manager/configure_provider_env.py +++ b/backend/apps/agents/manager/configure_provider_env.py @@ -211,7 +211,7 @@ async def configure_provider_env( # Pin subagent ids to whichever lane the user has, else CLI's # default Haiku 4.5 hits 9Router with no Claude route and 401s. # NOTE: sub_conns is the connection list passed in. Callers currently pass [] - # (see RunOptionsMixin), so `active` is empty and this pinning is inert until the + # (see RunOptions), so `active` is empty and this pinning is inert until the # real connection list is wired through — a latent regression from the run/ split, # surfaced by pyright (the old inline `_conns` reference was left dangling here). active = {c.get("provider") for c in sub_conns diff --git a/backend/apps/agents/manager/run/RunOptionsMixin.py b/backend/apps/agents/manager/run/RunOptions.py similarity index 99% rename from backend/apps/agents/manager/run/RunOptionsMixin.py rename to backend/apps/agents/manager/run/RunOptions.py index 4df9d226..eb201785 100644 --- a/backend/apps/agents/manager/run/RunOptionsMixin.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -34,10 +34,10 @@ from backend.apps.agents.manager.run.run_options_helpers import ( logger = logging.getLogger(__name__) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol -class RunOptionsMixin(AgentManagerState): +class RunOptions(AgentManagerProtocol): # No return annotation: the returned tuple carries an SDK ClaudeAgentOptions, which can't be # module-imported here (mock-mode would fail to import the manager); it's lazy-imported below. @typechecked diff --git a/backend/apps/agents/manager/run/TurnRunnerMixin.py b/backend/apps/agents/manager/run/TurnRunner.py similarity index 98% rename from backend/apps/agents/manager/run/TurnRunnerMixin.py rename to backend/apps/agents/manager/run/TurnRunner.py index fa648094..e2b6b013 100644 --- a/backend/apps/agents/manager/run/TurnRunnerMixin.py +++ b/backend/apps/agents/manager/run/TurnRunner.py @@ -22,10 +22,10 @@ from backend.apps.settings.models import AppSettings logger = logging.getLogger(__name__) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol -class TurnRunnerMixin(AgentManagerState): +class TurnRunner(AgentManagerProtocol): # `options` is the SDK ClaudeAgentOptions, lazy-imported below (so mock-mode can import the # manager without the SDK present), so it's left unannotated; everything else is typed. @typechecked diff --git a/backend/apps/agents/manager/session/SessionLifecycleMixin.py b/backend/apps/agents/manager/session/SessionLifecycle.py similarity index 98% rename from backend/apps/agents/manager/session/SessionLifecycleMixin.py rename to backend/apps/agents/manager/session/SessionLifecycle.py index 91d057e1..e5edd1ab 100644 --- a/backend/apps/agents/manager/session/SessionLifecycleMixin.py +++ b/backend/apps/agents/manager/session/SessionLifecycle.py @@ -28,10 +28,10 @@ from backend.apps.agents.manager.view_builder_state import ( logger = logging.getLogger(__name__) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol -class SessionLifecycleMixin(AgentManagerState): +class SessionLifecycle(AgentManagerProtocol): @staticmethod @typechecked def build_search_text(session: AgentSession, max_len: int = 5000) -> str: diff --git a/backend/apps/agents/manager/session/SessionPersistenceMixin.py b/backend/apps/agents/manager/session/SessionPersistence.py similarity index 94% rename from backend/apps/agents/manager/session/SessionPersistenceMixin.py rename to backend/apps/agents/manager/session/SessionPersistence.py index d9f88170..ae7b7dcf 100644 --- a/backend/apps/agents/manager/session/SessionPersistenceMixin.py +++ b/backend/apps/agents/manager/session/SessionPersistence.py @@ -1,6 +1,6 @@ """Bulk session persistence across the WHOLE store, the startup/shutdown orchestration that operates on every session at once (reconcile stale-running, flush-all on shutdown, restore-all -on boot). Split from SessionLifecycleMixin (which handles ONE session at a time) so each file is +on boot). Split from SessionLifecycle (which handles ONE session at a time) so each file is one concern. self.sessions / self.sync_session_close resolve across the MRO as before.""" import logging @@ -19,10 +19,10 @@ from backend.apps.agents.manager.session.apply_context_window import apply_conte logger = logging.getLogger(__name__) -from backend.apps.agents.manager.AgentManagerState import AgentManagerState +from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol -class SessionPersistenceMixin(AgentManagerState): +class SessionPersistence(AgentManagerProtocol): @typechecked async def reconcile_on_startup(self) -> None: """Mark any stale running sessions as stopped.""" diff --git a/backend/tests/test_streaming_harness.py b/backend/tests/test_streaming_harness.py index 7e6401ef..50364f5a 100644 --- a/backend/tests/test_streaming_harness.py +++ b/backend/tests/test_streaming_harness.py @@ -22,7 +22,7 @@ def p_provider_configured(monkeypatch): in from the dev machine's settings (which made these tests pass by accident and fail in a clean checkout). The harness tests the streaming loop, not provider config.""" import backend.apps.agents.agent_manager as am - import backend.apps.agents.manager.run.RunOptionsMixin as run_opts + import backend.apps.agents.manager.run.RunOptions as run_opts from backend.apps.settings.models import AppSettings settings = AppSettings(connection_mode="own_key", anthropic_api_key="sk-ant-test") monkeypatch.setattr(am, "load_settings", lambda: settings, raising=True) @@ -78,7 +78,7 @@ def p_capture_env(monkeypatch, settings, api_type, resolved_model, model_entry): into ClaudeAgentOptions (the provider-route auth config the SDK runs under).""" import backend.apps.agents.providers.registry as reg import backend.apps.agents.agent_manager as am - import backend.apps.agents.manager.run.RunOptionsMixin as run_opts + import backend.apps.agents.manager.run.RunOptions as run_opts monkeypatch.setattr(am, "load_settings", lambda: settings, raising=True) monkeypatch.setattr(run_opts, "load_settings", lambda: settings, raising=True) monkeypatch.setattr(reg, "get_api_type", lambda model: api_type, raising=True) @@ -185,7 +185,7 @@ def test_loop_builds_direct_anthropic_key_env(monkeypatch): from backend.apps.settings.models import AppSettings import backend.apps.agents.providers.registry as reg import backend.apps.agents.agent_manager as am - import backend.apps.agents.manager.run.RunOptionsMixin as run_opts + import backend.apps.agents.manager.run.RunOptions as run_opts settings = AppSettings(anthropic_api_key="sk-ant-test123", connection_mode="own_key") monkeypatch.setattr(am, "load_settings", lambda: settings, raising=True) @@ -222,7 +222,7 @@ def test_loop_with_session_cwd_runs_workspace_git_init(monkeypatch): # sessions normally have no cwd, which masked a NameError (the call said ensure_cwd_git_repo # while only _ensure_cwd_git_repo was imported). raising=True here would fail if the name were # missing again; the assertions confirm the cwd path actually runs and the turn completes. - import backend.apps.agents.manager.run.RunOptionsMixin as run_opts + import backend.apps.agents.manager.run.RunOptions as run_opts called = {} def fake_ensure(cwd, home=None): diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index b9ece0ee..e553478f 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -79,8 +79,8 @@ async def test_gate_blocks_when_active_mcps_empty(): p_fake_tool("Slack"), p_fake_tool("Notion"), ] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \ + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)): mgr = AgentManager() # allowed_tools includes mcp:Gmail, but active_mcps is empty result = await mgr.build_mcp_servers( @@ -99,8 +99,8 @@ async def test_gate_allows_only_activated_servers(): p_fake_tool("Slack"), p_fake_tool("Notion"), ] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \ + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)): mgr = AgentManager() result = await mgr.build_mcp_servers( allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"], @@ -117,8 +117,8 @@ async def test_gate_unset_active_mcps_legacy_allows_all(): """Pre-gate sessions use active_mcps=None → everything allowed (back-compat).""" from backend.apps.agents.agent_manager import AgentManager fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack")] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \ + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)): mgr = AgentManager() result = await mgr.build_mcp_servers( allowed_tools=["mcp:Gmail", "mcp:Slack"], @@ -133,7 +133,7 @@ async def test_gate_disabled_tool_blocked_even_when_activated(): """Tool with enabled=False stays blocked even if in active_mcps.""" from backend.apps.agents.agent_manager import AgentManager fake_tools = [p_fake_tool("Gmail", enabled=False)] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools): mgr = AgentManager() result = await mgr.build_mcp_servers( allowed_tools=["mcp:Gmail"], @@ -147,7 +147,7 @@ async def test_gate_unauthed_tool_blocked(): """Tool with auth_status='disconnected' stays blocked.""" from backend.apps.agents.agent_manager import AgentManager fake_tools = [p_fake_tool("Gmail", auth_status="disconnected")] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools): mgr = AgentManager() result = await mgr.build_mcp_servers( allowed_tools=["mcp:Gmail"], @@ -161,8 +161,8 @@ async def test_gate_allowed_tools_filter_intersects_active_mcps(): """Activate gmail+slack but allowed_tools only has gmail → only gmail passes.""" from backend.apps.agents.agent_manager import AgentManager fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack")] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \ + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)): mgr = AgentManager() result = await mgr.build_mcp_servers( allowed_tools=["mcp:Gmail"], # mode-restricted @@ -192,10 +192,10 @@ async def test_gate_stress_random_activations(): # allowed_tools mirrors raw names of connected allowed = [f"mcp:{raw_names[i]}" for i in connected_idx] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_airtable_token", new=AsyncMock(return_value=True)), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_hubspot_token", new=AsyncMock(return_value=True)): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \ + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)), \ + patch("backend.apps.agents.manager.RunSupport.refresh_airtable_token", new=AsyncMock(return_value=True)), \ + patch("backend.apps.agents.manager.RunSupport.refresh_hubspot_token", new=AsyncMock(return_value=True)): mgr = AgentManager() result = await mgr.build_mcp_servers( allowed_tools=allowed, @@ -642,11 +642,11 @@ async def test_mcp_gate_only_forwards_activated_servers(): # allowed_tools == get_all_tool_names() bypasses the (separate) permission # gate so we isolate the ACTIVATION gate. sanitize_server_name -> identity. - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", side_effect=installed), \ - patch("backend.apps.agents.manager.RunSupportMixin.get_all_tool_names", return_value=["__ALL__"]), \ - patch("backend.apps.agents.manager.RunSupportMixin.sanitize_server_name", side_effect=lambda n: n), \ - patch("backend.apps.agents.manager.RunSupportMixin.is_fully_denied", return_value=False), \ - patch("backend.apps.agents.manager.RunSupportMixin.derive_mcp_config", side_effect=lambda t: {"command": "x"}): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", side_effect=installed), \ + patch("backend.apps.agents.manager.RunSupport.get_all_tool_names", return_value=["__ALL__"]), \ + patch("backend.apps.agents.manager.RunSupport.sanitize_server_name", side_effect=lambda n: n), \ + patch("backend.apps.agents.manager.RunSupport.is_fully_denied", return_value=False), \ + patch("backend.apps.agents.manager.RunSupport.derive_mcp_config", side_effect=lambda t: {"command": "x"}): allowed = ["__ALL__"] # Boundary 1: empty activation list -> zero servers, always. assert await mgr.build_mcp_servers(allowed, active_mcps=[]) == {} @@ -963,8 +963,8 @@ async def test_concurrent_gate_calls_isolated(): """Two concurrent _build_mcp_servers calls with different active_mcps must not cross-contaminate.""" from backend.apps.agents.agent_manager import AgentManager fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack"), p_fake_tool("Notion")] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \ + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)): mgr = AgentManager() results = await asyncio.gather( mgr.build_mcp_servers(allowed_tools=["mcp:Gmail", "mcp:Slack", "mcp:Notion"], active_mcps=["gmail"]), @@ -1184,7 +1184,7 @@ async def test_gate_handles_missing_refresh_token_gracefully(): from backend.apps.agents.agent_manager import AgentManager fake = p_fake_tool("MyApiTool", auth_status="configured") fake.auth_type = None # no oauth - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=[fake]): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=[fake]): mgr = AgentManager() result = await mgr.build_mcp_servers( allowed_tools=["mcp:MyApiTool"], @@ -3026,8 +3026,8 @@ def test_view_builder_mode_has_default_folder(): async def test_gate_100_sequential_calls_no_leak(): from backend.apps.agents.agent_manager import AgentManager fake_tools = [p_fake_tool(f"Server{i}") for i in range(10)] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \ + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)): mgr = AgentManager() for i in range(100): n = i % 10 @@ -3141,8 +3141,8 @@ async def test_e2e_session_lifecycle_with_mcp_activation(): from backend.apps.agents.agent_manager import AgentManager from backend.apps.agents.core.models import AgentSession fake_tools = [p_fake_tool("Gmail"), p_fake_tool("Slack")] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", return_value=fake_tools), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)): + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=fake_tools), \ + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)): mgr = AgentManager() s = AgentSession(id="e2e", name="End-to-end", model="sonnet", mode="agent") @@ -3184,9 +3184,9 @@ async def test_e2e_50_random_activation_sequences(): ("Discord", "discord"), ("GitHub", "github"), ("Linear", "linear")] raw_names = [r for r, _ in server_pool] sanitized = [s for _, s in server_pool] - with patch("backend.apps.agents.manager.RunSupportMixin.load_all_tools", + with patch("backend.apps.agents.manager.RunSupport.load_all_tools", return_value=[p_fake_tool(r) for r in raw_names]), \ - patch("backend.apps.agents.manager.RunSupportMixin.refresh_google_token", new=AsyncMock(return_value=True)): + patch("backend.apps.agents.manager.RunSupport.refresh_google_token", new=AsyncMock(return_value=True)): mgr = AgentManager() for _ in range(50): n = random.randint(0, len(sanitized)) diff --git a/linter/config/config.json b/linter/config/config.json index cb1e0a63..c46261a7 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -21,7 +21,7 @@ "max-file-lines-exceptions": "Grandfather list of pre-existing >300-line files (existing debt, not new). Paths updated after the folder-tree restructure moved several of them. The two manager/prompt/* entries are from the agent_manager decomposition: prompt_context.py aggregates the system-prompt context builders and attachments.py is one cohesive 230-line attachment resolver; both are single-responsibility and a few lines over, not splittable without an artificial seam.", "max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles.", "import-cycles": "Flags RUNTIME circular imports only (SCC>1). Skips type-only imports (import type / export type) and dynamic import() since neither runs at module init, which is why the idiomatic Redux store<->hooks type cycle is not flagged. Frontend alias resolution comes from import-cycle-aliases. Zero cycles today; the check keeps it that way.", - "ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager mixins now inherit a typing-only AgentManagerState base (manager/AgentManagerState.py) that declares the composed __init__ state + cross-mixin methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated — App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.", + "ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager behavior classes now inherit a typing-only AgentManagerProtocol base (manager/AgentManagerProtocol.py) that declares the composed __init__ state + cross-class methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated — App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.", "no-underscore-names + p-private": "Convention checks ported verbatim from Haik's linter (haik/feat/ingest): no-underscore-names bans leading-underscore names (a dead-code-tooling blind spot; use p_ for private), p-private enforces that p_-prefixed names are accessed only inside their owning file/class (cross-file/class use means the name should be public). Backend Python only. The exception lists grandfather pre-existing debt that landed with the workflows/analytics forward-ports (eric's 'don't mass-migrate untouched files' rule); the agent_manager refactor surface is clean. NOTE: Haik's full linter (his branch also adds pyright + ruff and runs a different enabled set) should eventually supersede this; these two were lifted to enforce the p_ conventions on eric/dev now." }, "rules": {