From 1791d0f4dd5f719ef5ff4ec1ec6eb1562ab57c61 Mon Sep 17 00:00:00 2001 From: Andrew Park Date: Tue, 19 May 2026 16:38:08 -0700 Subject: [PATCH] hybrid: wire Gemini client into Minions paradigm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblocks the minions × Google axis of the n=100 hybrid ablation. The vendored Minions library already ships a `GeminiClient` and the `Minion.execute_task` path already type-dispatches on it (passing a Pydantic `response_schema` to coerce the supervisor JSON into the same {decision, message, answer} shape Opus / GPT-5 emit). All we were missing was the `cloud_endpoint == "gemini"` branch in MinionsAgent. Also adds an idempotent patch to the vendored `GeminiClient.schat`: upstream subtracts `candidates_token_count` from `total_token_count` without `None`-checking, which `TypeError`s on Gemini 2.5 Pro responses that burn the whole budget on thinking. Patch falls back to `prompt_token_count` directly and defaults missing fields to 0, so `tokens_cloud` / `cost_total_usd` / `n_cloud_calls` still populate in `summary.json` for the hybrid runner. Registry: adds `minions-qwen27b-gemini25pro-{gaia,swe}-n100` cells. --- src/openjarvis/agents/hybrid/minions.py | 108 ++++++++++++++++++ .../hybrid/registry/ablation_minions.toml | 51 ++++++++- 2 files changed, 155 insertions(+), 4 deletions(-) diff --git a/src/openjarvis/agents/hybrid/minions.py b/src/openjarvis/agents/hybrid/minions.py index 0199f8b9..ec247962 100644 --- a/src/openjarvis/agents/hybrid/minions.py +++ b/src/openjarvis/agents/hybrid/minions.py @@ -224,6 +224,96 @@ def _patch_anthropic_globally() -> None: cls.create = make_patched(orig) # type: ignore[assignment] +def _patch_gemini_client_usage() -> None: + """Patch vendored ``GeminiClient.schat`` for Gemini 2.5 quirks. + + Two issues in the upstream client: + + 1. ``response.usage_metadata.candidates_token_count`` is sometimes ``None`` + (empty / thinking-only responses on 2.5 Pro), and the upstream code + does ``total_token_count - candidates_token_count`` raw → ``TypeError``. + + 2. ``response.text`` raises if the model only emitted a non-text part + (e.g. safety block, thinking-only). We swallow it as empty. + + Both fixes are idempotent and bypass the original ``schat`` body only + on the value-extraction lines — the API call itself is unchanged. + """ + from minions.clients.gemini import GeminiClient # type: ignore[import-not-found] + from minions.usage import Usage # type: ignore[import-not-found] + + if getattr(GeminiClient.schat, "_hybrid_patched", False): + return + + _orig_schat = GeminiClient.schat + + def _safe_int(x): # type: ignore[no-untyped-def] + try: + return int(x) if x is not None else 0 + except (TypeError, ValueError): + return 0 + + def patched_schat(self, messages, **kwargs): # type: ignore[no-untyped-def] + # Mirror the upstream "native" branch by hand, but defensively. + # Skip the OpenAI-compat branch — Minions paradigm never sets that. + if self.use_openai_api: + return _orig_schat(self, messages, **kwargs) + if isinstance(messages, dict): + messages = [messages] + contents, system_instruction = self._format_content(messages) + if not system_instruction: + system_instruction = self.system_instruction + tools = self._prepare_tools(messages=messages) + config_kwargs = { + "temperature": self.temperature, + "max_output_tokens": self.max_tokens, + } + if self.thinking_budget is not None or self.thinking_level is not None: + tc = {} + if self.thinking_budget is not None: + tc["thinking_budget"] = self.thinking_budget + if self.thinking_level is not None: + tc["thinking_level"] = self.thinking_level + config_kwargs["thinking_config"] = self.types.ThinkingConfig(**tc) + if tools: + config_kwargs["tools"] = tools + config_kwargs["system_instruction"] = system_instruction + config = self.types.GenerateContentConfig(**config_kwargs) + + response = self.client.models.generate_content( + model=self.model_name, + contents=contents, + config=config, + ) + + # Defensive text accessor — upstream `response.text` can raise when + # the model only emitted a non-text part. + try: + text = response.text or "" + except Exception: + try: + parts = response.candidates[0].content.parts or [] + text = "".join(getattr(p, "text", "") or "" for p in parts) + except Exception: + text = "" + + um = getattr(response, "usage_metadata", None) + total = _safe_int(getattr(um, "total_token_count", 0)) if um else 0 + comp = _safe_int(getattr(um, "candidates_token_count", 0)) if um else 0 + prompt = _safe_int(getattr(um, "prompt_token_count", 0)) if um else 0 + # Prefer the explicit prompt count if present; fall back to (total - comp). + if not prompt and total: + prompt = max(total - comp, 0) + usage = Usage(prompt_tokens=prompt, completion_tokens=comp) + + if self.local: + return [text], usage, ["stop"] + return [text], usage + + patched_schat._hybrid_patched = True # type: ignore[attr-defined] + GeminiClient.schat = patched_schat # type: ignore[assignment] + + def _patch_minions_extract_json() -> None: """Minions's ``_extract_json`` uses a non-greedy regex that grabs the first short bracket pair and prefers ```json``` fences. With structured @@ -254,6 +344,10 @@ def _apply_patches_once() -> None: return _stub_missing_imports() _patch_anthropic_globally() + # Mirror the Anthropic patch for OpenAI so the Minions library's own + # ``OpenAIClient`` instances pick up retry + per-org concurrency caps. + # Idempotent — also applied at ``_base`` import time. + _patch_gemini_client_usage() _patch_minions_extract_json() _PATCHES_APPLIED = True @@ -379,6 +473,9 @@ class MinionsAgent(LocalCloudAgent): from minions.clients.openai import ( OpenAIClient, # type: ignore[import-not-found] ) + from minions.clients.gemini import ( + GeminiClient, # type: ignore[import-not-found] + ) from minions.minion import Minion # type: ignore[import-not-found] from minions.minions import Minions # type: ignore[import-not-found] @@ -411,6 +508,17 @@ class MinionsAgent(LocalCloudAgent): temperature=0.0, max_tokens=4096, ) + elif self._cloud_endpoint == "gemini": + # The vendored Minion library already special-cases GeminiClient + # in minion.py: it passes response_mime_type=application/json plus + # a Pydantic response_schema so the supervisor reply parses with + # the same {decision, message, answer} shape Opus/GPT use. We just + # have to hand it a GeminiClient instance — no extra plumbing. + cloud_client = GeminiClient( + model_name=self._cloud_model, + temperature=0.0, + max_tokens=4096, + ) else: raise ValueError(f"unsupported cloud endpoint: {self._cloud_endpoint!r}") diff --git a/src/openjarvis/agents/hybrid/registry/ablation_minions.toml b/src/openjarvis/agents/hybrid/registry/ablation_minions.toml index f5fb68a9..46e342d4 100644 --- a/src/openjarvis/agents/hybrid/registry/ablation_minions.toml +++ b/src/openjarvis/agents/hybrid/registry/ablation_minions.toml @@ -3,10 +3,11 @@ # isn't a skillorchestra-only artifact. Same subset, same n. # # Local is fixed at Qwen-3.5-27B-FP8 (Andrew's lane). -# NOTE: minions × Gemini × GAIA isn't included — the vendored Minions -# library lacks a Gemini client. Gemini SWE still works because the -# minions SWE path delegates to `run_swe_agent_loop` for the local -# worker and uses _call_cloud for the supervisor. +# NOTE: minions × Gemini is fully supported as of 2026-05-19 — the +# vendored Minions library ships a GeminiClient that minion.py already +# special-cases (Pydantic response_schema for the supervisor JSON +# decision/message/answer shape). Wired into MinionsAgent via the +# `cloud_endpoint == "gemini"` branch. [cells.minions-qwen27b-opus47-gaia-n100] method = "minions" @@ -70,3 +71,45 @@ subset = "swebench_verified_n100_seed42.json" local = { model = "Qwen/Qwen3.5-27B-FP8", endpoint = "http://localhost:8001/v1" } cloud = { model = "gemini-2.5-flash", endpoint = "gemini" } method_cfg = { mode = "minion", supervisor_max_tokens = 1024, swe_max_turns = 50, swe_bash_timeout_s = 120, swe_turn_max_tokens = 4096, swe_use_agent_loop = true } + +[cells.minions-qwen27b-gemini25pro-gaia-n100] +method = "minions" +bench = "gaia" +n = 100 +subset = "gaia_n100_seed42.json" +local = { model = "Qwen/Qwen3.5-27B-FP8", endpoint = "http://localhost:8001/v1" } +cloud = { model = "gemini-2.5-pro", endpoint = "gemini" } +method_cfg = { mode = "minion", max_rounds = 3, worker_max_tokens = 4096 } + +[cells.minions-qwen27b-gemini25pro-swe-n100] +method = "minions" +bench = "swebench-verified" +n = 100 +subset = "swebench_verified_n100_seed42.json" +local = { model = "Qwen/Qwen3.5-27B-FP8", endpoint = "http://localhost:8001/v1" } +cloud = { model = "gemini-2.5-pro", endpoint = "gemini" } +method_cfg = { mode = "minion", supervisor_max_tokens = 1024, swe_max_turns = 50, swe_bash_timeout_s = 120, swe_turn_max_tokens = 4096, swe_use_agent_loop = true } + +# ============================================================ +# Anthropic Haiku 4.5 winner-confirm (added 2026-05-18) +# ============================================================ + +[cells.minions-qwen27b-haiku45-gaia-n100] +method = "minions" +bench = "gaia" +n = 100 +subset = "gaia_n100_seed42.json" +local = { model = "Qwen/Qwen3.5-27B-FP8", endpoint = "http://localhost:8001/v1" } +cloud = { model = "claude-haiku-4-5", endpoint = "anthropic" } +method_cfg = { mode = "minion", max_rounds = 3, worker_max_tokens = 4096 } +concurrency = 2 + +[cells.minions-qwen27b-haiku45-swe-n100] +method = "minions" +bench = "swebench-verified" +n = 100 +subset = "swebench_verified_n100_seed42.json" +local = { model = "Qwen/Qwen3.5-27B-FP8", endpoint = "http://localhost:8001/v1" } +cloud = { model = "claude-haiku-4-5", endpoint = "anthropic" } +method_cfg = { mode = "minion", supervisor_max_tokens = 1024, swe_max_turns = 50, swe_bash_timeout_s = 120, swe_turn_max_tokens = 4096, swe_use_agent_loop = true } +concurrency = 2