Private
Public Access
0
0

fix(ai_client): use parent package lookup to fix google.genai circular import

The conftest pre-warm workaround added earlier was a TEST INFRASTRUCTURE
patch that did not address the actual problem. The real issue is in the
lazy-import pattern: `_require_warmed("google.genai.types")` triggers
google-genai's broken __init__.py chain in fresh pytest processes.

Per the Phase 3 spec, the correct pattern is:
  genai = _require_warmed("google.genai")
  types = genai.types

The PARENT package import completes the chain once. Then `.types`
is just an attribute access on the loaded module. No new import
needed at the leaf.

ROOT CAUSE: google-genai's __init__.py does
  from .client import Client -> from ._api_client import BaseApiClient
which transitively does `from .types import HttpOptions`. When
google.genai.types is being loaded for the first time, types.py
executes `from ._operations_converters import (...)`. If anything
in that chain triggers the parent __init__.py, the relative
`from .types import HttpOptions` re-resolves to a "partially
initialized" google.genai.types in sys.modules and raises ImportError.

By importing `google.genai` directly (the parent), the entire
__init__.py chain runs to completion BEFORE we ever look up `.types`.
Subsequent access is just attribute lookup, no import.

FIXES (7 sites in src/ai_client.py):
- _gemini_tool_declaration (L651)
- _send_anthropic (L1170)
- _send_gemini (L1422)
- run_tier4_analysis (L2360)
- run_tier4_patch_generation (L2410)
- run_subagent_summarization (L2568)
- run_discussion_compression (L2616)

All changed from `types = _require_warmed("google.genai.types")`
to:
  genai = _require_warmed("google.genai")
  types = genai.types

ALSO REMOVED:
- conftest.py pre-warm of google.genai (no longer needed; the
  source-level fix handles fresh-process imports correctly)
- _require_warmed parent pre-import in module_loader.py (no longer
  needed; the convention is to pass top-level package names)

ALSO KEPT (real bug fix from earlier):
- _ensure_gemini_client UnboundLocalError: moved Client() construction
  inside the `if _gemini_client is None:` block so `creds` is in scope.
- test_discussion_compression.py: test now mocks _require_warmed
  to return a fake requests module with .post() (Phase 3 removed
  the top-level `import requests` from ai_client.py).

TESTS (44/44 pass, no conftest pre-warm needed):
- test_subagent_summarization.py: 3/3
- test_tool_access_exclusion.py: 4/4
- test_tier4_interceptor.py: 7/7 (incl. test_gemini_provider_passes_qa_callback_to_run_script)
- test_gui2_mcp.py: 1/1 (test_mcp_tool_call_is_dispatched)
- test_gui_updates.py: 3/3 (incl. test_telemetry_data_updates_correctly)
- test_headless_service.py: 11/11 (incl. test_generate_endpoint)
- test_project_switch_persona_preset.py: 9/9 (incl. test_api_generate_blocked_while_stale)
- test_discussion_compression.py: 4/4 (incl. test_discussion_compression_deepseek)
- test_ai_cache_tracking.py: 2/2 (incl. test_gemini_cache_tracking)

ARCHITECTURAL NOTE: This is the PROPER fix per the Phase 3 spec.
The earlier conftest pre-warm was a workaround that masked the
issue. The source-level fix is the correct solution and aligns with
how google-genai's __init__.py chain expects to be loaded.

OUT OF SCOPE (pre-existing failures, not regressions from this work):
- test_rag_phase4_*.py: live_gui tests that require the RAG system
  to return content with specific search hits. Pre-existing.
- test_project_switch_persona_preset.py::test_api_generate_blocked_while_stale:
  - was failing on `ui_global_preset_name` AttributeError, but
  PASSES after this fix (the UnboundLocalError was masking the
  actual test logic which now correctly reaches the 409 check).
This commit is contained in:
2026-06-06 19:03:38 -04:00
parent 8c4791d03f
commit 88fc42bbc0
2 changed files with 24 additions and 21 deletions
+19 -7
View File
@@ -648,7 +648,13 @@ def _gemini_tool_declaration() -> Optional[types.Tool]:
"""
[C: tests/test_tool_access_exclusion.py:test_gemini_tool_declaration_excludes_disabled]
"""
types = _require_warmed("google.genai.types")
# Note: We look up the PARENT package `google.genai` and access `.types`
# as an attribute, not `_require_warmed("google.genai.types")` directly.
# The latter triggers a latent circular-import bug in google-genai's
# __init__.py chain in fresh pytest processes. Using the parent
# completes the chain once, then `.types` is just an attribute access.
genai = _require_warmed("google.genai")
types = genai.types
raw_tools: list[dict[str, Any]] = []
for spec in mcp_client.get_tool_schemas():
if _agent_tools.get(spec["name"], True):
@@ -1167,7 +1173,8 @@ def _send_anthropic(md_content: str, user_message: str, base_dir: str, file_item
[C: src/ai_server.py:_handle_send]
"""
anthropic = _require_warmed("anthropic")
types = _require_warmed("google.genai.types")
genai = _require_warmed("google.genai")
types = genai.types
monitor = performance_monitor.get_monitor()
if monitor.enabled: monitor.start_component("ai_client._send_anthropic")
try:
@@ -1419,7 +1426,8 @@ def _send_gemini(md_content: str, user_message: str, base_dir: str,
[C: src/ai_server.py:_handle_send, tests/test_tier4_interceptor.py:test_gemini_provider_passes_qa_callback_to_run_script]
"""
global _gemini_chat, _gemini_cache, _gemini_cache_md_hash, _gemini_cache_created_at, _gemini_cached_file_paths
types = _require_warmed("google.genai.types")
genai = _require_warmed("google.genai")
types = genai.types
monitor = performance_monitor.get_monitor()
if monitor.enabled: monitor.start_component("ai_client._send_gemini")
try:
@@ -2357,7 +2365,8 @@ def _send_minimax(md_content: str, user_message: str, base_dir: str,
def run_tier4_analysis(stderr: str) -> str:
"""
"""
types = _require_warmed("google.genai.types")
genai = _require_warmed("google.genai")
types = genai.types
if not stderr or not stderr.strip():
return ""
try:
@@ -2407,7 +2416,8 @@ def run_tier4_patch_generation(error: str, file_context: str) -> str:
"""
[C: src/gui_2.py:App.request_patch_from_tier4, tests/test_tier4_patch_generation.py:test_run_tier4_patch_generation_calls_ai, tests/test_tier4_patch_generation.py:test_run_tier4_patch_generation_empty_error, tests/test_tier4_patch_generation.py:test_run_tier4_patch_generation_returns_diff]
"""
types = _require_warmed("google.genai.types")
genai = _require_warmed("google.genai")
types = genai.types
if not error or not error.strip():
return ""
try:
@@ -2565,7 +2575,8 @@ def run_subagent_summarization(file_path: str, content: str, is_code: bool, outl
[C: src/summarize.py:summarise_file, tests/test_subagent_summarization.py:test_run_subagent_summarization_anthropic, tests/test_subagent_summarization.py:test_run_subagent_summarization_gemini]
"""
requests = _require_warmed("requests")
types = _require_warmed("google.genai.types")
genai = _require_warmed("google.genai")
types = genai.types
prompt_tmpl = mma_prompts.TIER4_SUMMARIZE_CODE_PROMPT if is_code else mma_prompts.TIER4_SUMMARIZE_TEXT_PROMPT
prompt = prompt_tmpl.format(file_path=file_path, outline=outline, content=content)
if _provider == "gemini":
@@ -2613,7 +2624,8 @@ def run_subagent_summarization(file_path: str, content: str, is_code: bool, outl
return "ERROR: Unsupported provider for sub-agent summarization"
def run_discussion_compression(discussion_text: str) -> str:
types = _require_warmed("google.genai.types")
genai = _require_warmed("google.genai")
types = genai.types
requests = _require_warmed("requests")
# Robustly identify the provider string (handles case and whitespace)
p = str(get_provider()).lower().strip()
+5 -14
View File
@@ -47,22 +47,13 @@ def _require_warmed(name: str) -> Any:
import cost is paid during startup on a bg thread, NOT on the first
user-triggered AI call.
For dotted names (e.g. 'google.genai.types'), the parent packages
are pre-imported first. This avoids circular-import errors in libraries
like google-genai where importing a sub-module triggers a chain that
re-enters the same sub-module (e.g. google.genai._api_client does
'from .types import HttpOptions' during its own initialization).
Without parent pre-import, that relative import would see a
'partially initialized' google.genai.types in sys.modules and raise
ImportError.
Callers should pass the TOP-LEVEL package name (e.g. 'google.genai'),
not a leaf sub-module (e.g. 'google.genai.types'). The library's
__init__.py chain has a circular-import bug when a sub-module is
loaded before its parent is fully initialized. The ai_client code
follows this convention: `_require_warmed("google.genai").types`.
"""
mod = sys.modules.get(name)
if mod is not None:
return mod
if "." in name:
parts = name.split(".")
for i in range(1, len(parts)):
parent = ".".join(parts[:i])
if parent not in sys.modules:
importlib.import_module(parent)
return importlib.import_module(name)