From d0dd5e799b1416a0d1dff96a4ebd4aa389256156 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 25 Jun 2026 00:32:16 -0700 Subject: [PATCH] [eric] agents: typed AgentManagerState mixin base re-enables pyright attr-access + fixes 2 broken outputs imports (pyright caught them) --- backend/apps/agents/agent_manager.py | 3 +- .../apps/agents/manager/AgentLaunchMixin.py | 7 ++- .../apps/agents/manager/AgentManagerState.py | 43 +++++++++++++++++++ backend/apps/agents/manager/MessagingMixin.py | 5 ++- backend/apps/agents/manager/MockAgentMixin.py | 5 ++- .../apps/agents/manager/RunSupportMixin.py | 3 +- .../agents/manager/SessionControlMixin.py | 5 ++- .../agents/manager/configure_provider_env.py | 3 +- backend/apps/agents/manager/metadata.py | 2 +- .../agents/manager/run/RunOptionsMixin.py | 5 ++- .../agents/manager/run/TurnRunnerMixin.py | 5 ++- .../manager/session/SessionLifecycleMixin.py | 5 ++- .../session/SessionPersistenceMixin.py | 5 ++- linter/config/config.json | 11 ++++- linter/config/pyright_check.json | 2 +- 15 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 backend/apps/agents/manager/AgentManagerState.py diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index e79f7fa0..ad84d55b 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -215,7 +215,8 @@ class AgentManager(SessionLifecycleMixin, SessionPersistenceMixin, MessagingMixi # the row's name is still the default placeholder. if session.mode == "view-builder": try: - from backend.apps.outputs.outputs import sync_output_from_meta_json, _load_all as load_outputs + from backend.apps.outputs.outputs import sync_output_from_meta_json + from backend.apps.outputs.workspace_io import load_all as load_outputs if sync_output_from_meta_json(session_id, fallback_name=session.name): # Broadcast the renamed row so the sidebar # flips from "Untitled App" to the real name diff --git a/backend/apps/agents/manager/AgentLaunchMixin.py b/backend/apps/agents/manager/AgentLaunchMixin.py index 3154b3d4..5edf1266 100644 --- a/backend/apps/agents/manager/AgentLaunchMixin.py +++ b/backend/apps/agents/manager/AgentLaunchMixin.py @@ -29,7 +29,10 @@ from backend.apps.agents.manager.prompt.prompt_context import resolve_mode logger = logging.getLogger(__name__) -class AgentLaunchMixin: +from backend.apps.agents.manager.AgentManagerState import AgentManagerState + + +class AgentLaunchMixin(AgentManagerState): @typechecked async def launch_agent(self, config: AgentConfig) -> AgentSession: session_id = uuid4().hex @@ -78,8 +81,8 @@ class AgentLaunchMixin: try: from backend.apps.outputs.outputs import ( ensure_webapp_workspace_seeded_and_registered, - _load as load_output, ) + from backend.apps.outputs.workspace_io import load as load_output output_id = ensure_webapp_workspace_seeded_and_registered( workspace_id=session_id, folder=effective_cwd, diff --git a/backend/apps/agents/manager/AgentManagerState.py b/backend/apps/agents/manager/AgentManagerState.py new file mode 100644 index 00000000..431a3578 --- /dev/null +++ b/backend/apps/agents/manager/AgentManagerState.py @@ -0,0 +1,43 @@ +"""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 +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 +methods) so each mixin inherits a typed view of the whole. It carries NO runtime +behavior: the attribute lines are bare annotations (lazy via __future__) and the +methods live in a TYPE_CHECKING block, so at runtime this is an empty class that +just sits once in the MRO. Re-enables the linter's pyright reportAttributeAccessIssue. +""" +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any, Dict + +if TYPE_CHECKING: + from backend.apps.agents.core.models import AgentSession + from backend.apps.agents.manager.streaming.LivePartial import LivePartial + + +class AgentManagerState: + # State set in AgentManager.__init__. + sessions: Dict[str, AgentSession] + tasks: Dict[str, asyncio.Task] + live_partial: Dict[str, LivePartial] + cancel_events: Dict[str, asyncio.Event] + + if TYPE_CHECKING: + # Methods implemented on sibling mixins / AgentManager itself and called + # cross-mixin. Loose signatures on purpose: typeCheckingMode is off, so this + # only has to assert the names exist, not pin their call shapes. + def run_agent_loop(self, *args: Any, **kwargs: Any) -> Any: ... + def generate_turn_label(self, *args: Any, **kwargs: Any) -> Any: ... + def commit_partial_now(self, *args: Any, **kwargs: Any) -> Any: ... + def stop_agent(self, *args: Any, **kwargs: Any) -> Any: ... + def drain_task(self, *args: Any, **kwargs: Any) -> Any: ... + def sync_session_close(self, *args: Any, **kwargs: Any) -> Any: ... + def build_mcp_servers(self, *args: Any, **kwargs: Any) -> Any: ... + def build_search_text(self, *args: Any, **kwargs: Any) -> Any: ... + def stream_text(self, *args: Any, **kwargs: Any) -> Any: ... + def stream_tool_input(self, *args: Any, **kwargs: Any) -> Any: ... diff --git a/backend/apps/agents/manager/MessagingMixin.py b/backend/apps/agents/manager/MessagingMixin.py index be27e70d..c787f504 100644 --- a/backend/apps/agents/manager/MessagingMixin.py +++ b/backend/apps/agents/manager/MessagingMixin.py @@ -21,7 +21,10 @@ from backend.apps.agents.manager.prompt.prompt_context import resolve_mode logger = logging.getLogger(__name__) -class MessagingMixin: +from backend.apps.agents.manager.AgentManagerState import AgentManagerState + + +class MessagingMixin(AgentManagerState): @typechecked async def send_message( self, diff --git a/backend/apps/agents/manager/MockAgentMixin.py b/backend/apps/agents/manager/MockAgentMixin.py index b8e914f5..ab75cea3 100644 --- a/backend/apps/agents/manager/MockAgentMixin.py +++ b/backend/apps/agents/manager/MockAgentMixin.py @@ -17,7 +17,10 @@ from backend.apps.agents.core.ws_manager import ws_manager logger = logging.getLogger(__name__) -class MockAgentMixin: +from backend.apps.agents.manager.AgentManagerState import AgentManagerState + + +class MockAgentMixin(AgentManagerState): @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/RunSupportMixin.py index 9145c35e..893af8ed 100644 --- a/backend/apps/agents/manager/RunSupportMixin.py +++ b/backend/apps/agents/manager/RunSupportMixin.py @@ -33,11 +33,12 @@ from backend.apps.tools_lib.tools_lib import ( refresh_google_token, refresh_hubspot_token, ) +from backend.apps.agents.manager.AgentManagerState import AgentManagerState logger = logging.getLogger(__name__) -class RunSupportMixin: +class RunSupportMixin(AgentManagerState): @typechecked async def build_mcp_servers( self, diff --git a/backend/apps/agents/manager/SessionControlMixin.py b/backend/apps/agents/manager/SessionControlMixin.py index de283d71..3600db38 100644 --- a/backend/apps/agents/manager/SessionControlMixin.py +++ b/backend/apps/agents/manager/SessionControlMixin.py @@ -15,7 +15,10 @@ from backend.apps.agents.manager.session.session_store import save_session logger = logging.getLogger(__name__) -class SessionControlMixin: +from backend.apps.agents.manager.AgentManagerState import AgentManagerState + + +class SessionControlMixin(AgentManagerState): @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 e16f4775..c5f51408 100644 --- a/backend/apps/agents/manager/configure_provider_env.py +++ b/backend/apps/agents/manager/configure_provider_env.py @@ -10,6 +10,7 @@ from typing import Dict, List, Optional from typeguard import typechecked from backend.apps.agents.core.models import AgentSession +from backend.apps.settings.models import AppSettings from backend.auth import get_auth_token logger = __import__("logging").getLogger(__name__) @@ -21,7 +22,7 @@ async def configure_provider_env( session: AgentSession, resolved_model: object, api_type: Optional[str], - global_settings: object, + global_settings: AppSettings, sub_conns: List, ) -> None: from backend.apps.nine_router import is_running as nine_router_running diff --git a/backend/apps/agents/manager/metadata.py b/backend/apps/agents/manager/metadata.py index 0f3d5c7f..da7fe017 100644 --- a/backend/apps/agents/manager/metadata.py +++ b/backend/apps/agents/manager/metadata.py @@ -187,7 +187,7 @@ async def generate_group_meta( if not session: raise ValueError(f"Session {session_id} not found") - fallback_name = tool_calls[0].get("tool", "Tool calls") if tool_calls else "Tool calls" + fallback_name = str(tool_calls[0].get("tool", "Tool calls")) if tool_calls else "Tool calls" fallback_name = fallback_name.split("__")[-1].replace("_", " ").title() if "__" in fallback_name else fallback_name name = fallback_name diff --git a/backend/apps/agents/manager/run/RunOptionsMixin.py b/backend/apps/agents/manager/run/RunOptionsMixin.py index b7c2992a..4df9d226 100644 --- a/backend/apps/agents/manager/run/RunOptionsMixin.py +++ b/backend/apps/agents/manager/run/RunOptionsMixin.py @@ -34,7 +34,10 @@ from backend.apps.agents.manager.run.run_options_helpers import ( logger = logging.getLogger(__name__) -class RunOptionsMixin: +from backend.apps.agents.manager.AgentManagerState import AgentManagerState + + +class RunOptionsMixin(AgentManagerState): # 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/TurnRunnerMixin.py index fe0bd4ce..fa648094 100644 --- a/backend/apps/agents/manager/run/TurnRunnerMixin.py +++ b/backend/apps/agents/manager/run/TurnRunnerMixin.py @@ -22,7 +22,10 @@ from backend.apps.settings.models import AppSettings logger = logging.getLogger(__name__) -class TurnRunnerMixin: +from backend.apps.agents.manager.AgentManagerState import AgentManagerState + + +class TurnRunnerMixin(AgentManagerState): # `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/SessionLifecycleMixin.py index 9d079550..91d057e1 100644 --- a/backend/apps/agents/manager/session/SessionLifecycleMixin.py +++ b/backend/apps/agents/manager/session/SessionLifecycleMixin.py @@ -28,7 +28,10 @@ from backend.apps.agents.manager.view_builder_state import ( logger = logging.getLogger(__name__) -class SessionLifecycleMixin: +from backend.apps.agents.manager.AgentManagerState import AgentManagerState + + +class SessionLifecycleMixin(AgentManagerState): @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/SessionPersistenceMixin.py index 72cd9a31..d9f88170 100644 --- a/backend/apps/agents/manager/session/SessionPersistenceMixin.py +++ b/backend/apps/agents/manager/session/SessionPersistenceMixin.py @@ -19,7 +19,10 @@ from backend.apps.agents.manager.session.apply_context_window import apply_conte logger = logging.getLogger(__name__) -class SessionPersistenceMixin: +from backend.apps.agents.manager.AgentManagerState import AgentManagerState + + +class SessionPersistenceMixin(AgentManagerState): @typechecked async def reconcile_on_startup(self) -> None: """Mark any stale running sessions as stopped.""" diff --git a/linter/config/config.json b/linter/config/config.json index b66463a4..cb1e0a63 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) but DISABLES reportAttributeAccessIssue: our AgentManager is decomposed into mixins that read attributes defined on the composed class (self.sessions etc.), which that rule can't see without a typed mixin base — 82 false positives. Kept reportUndefinedVariable + reportMissingImports, which caught a real dangling `_conns` ref in configure_provider_env. Re-enabling attribute-access cleanly needs a typed mixin contract (future). 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 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.", "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": { @@ -248,10 +248,19 @@ "backend/tests/test_ws_integration.py" ], "pyright": [ + "backend/apps/agents/agents.py", + "backend/apps/agents/browser_agent_mcp_server.py", + "backend/apps/agents/manager/streaming/handle_assistant_message.py", "backend/apps/google_workspace_mcp_shim/run.py", "backend/apps/health/health.py", "backend/apps/settings/settings.py", + "backend/apps/subscription/free_trial.py", + "backend/apps/swarm/closure.py", + "backend/apps/swarm/entities/modes.py", "backend/apps/web/web.py", + "backend/apps/workflows/executor.py", + "backend/apps/workflows/workflows.py", + "backend/auth.py", "backend/config/Apps.py" ] } diff --git a/linter/config/pyright_check.json b/linter/config/pyright_check.json index df29b177..b1d196e0 100644 --- a/linter/config/pyright_check.json +++ b/linter/config/pyright_check.json @@ -14,7 +14,7 @@ "extraPaths": ["../.."], "reportMissingTypeStubs": false, "typeCheckingMode": "off", - "reportAttributeAccessIssue": "none", + "reportAttributeAccessIssue": "error", "reportUndefinedVariable": "error", "reportMissingImports": "error" }