[eric] agents: drop the Mixin suffix, name the 9 manager classes by domain (Messaging, SessionLifecycle, ...) + AgentManagerState -> AgentManagerProtocol

This commit is contained in:
ciregenz
2026-06-25 00:50:47 -07:00
parent d0dd5e799b
commit b9cea6c647
15 changed files with 71 additions and 71 deletions
+10 -10
View File
@@ -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] = {}
@@ -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
@@ -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]
@@ -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,
@@ -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."""
@@ -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,
@@ -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."""
@@ -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
@@ -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
@@ -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
@@ -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:
@@ -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."""
+4 -4
View File
@@ -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):
+28 -28
View File
@@ -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))
+1 -1
View File
@@ -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": {