[eric] agents: typed AgentManagerState mixin base re-enables pyright attr-access + fixes 2 broken outputs imports (pyright caught them)

This commit is contained in:
ciregenz
2026-06-25 00:32:16 -07:00
parent d29076df93
commit d0dd5e799b
15 changed files with 94 additions and 15 deletions
+2 -1
View File
@@ -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
@@ -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,
@@ -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: ...
@@ -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,
@@ -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."""
@@ -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,
@@ -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."""
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -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
@@ -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:
@@ -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."""
+10 -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) 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"
]
}
+1 -1
View File
@@ -14,7 +14,7 @@
"extraPaths": ["../.."],
"reportMissingTypeStubs": false,
"typeCheckingMode": "off",
"reportAttributeAccessIssue": "none",
"reportAttributeAccessIssue": "error",
"reportUndefinedVariable": "error",
"reportMissingImports": "error"
}