From fd3ffcd60bb4fc5515ff8a33528b9c49bc58aa46 Mon Sep 17 00:00:00 2001 From: Guido Berning <30648231+SaJaToGu@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:08:28 +0200 Subject: [PATCH 1/2] feat(workers): add optional Metis local-first lookup for the solver role Adds workers/metis_local_first.py, a fail-soft client for the Metis cluster's read-only MCP server (github.com/SaJaToGu/metis). Before a solver-role issue falls back to its configured cloud provider, callers can ask Metis whether a home-lab worker currently has a live, reachable Ollama endpoint for a matching capability (default: "local-llm"). Reuses the existing AiderAdapter(provider="ollama") as-is - no new adapter class needed. build_ollama_run_config() returns (model="ollama", model_name=, {"OLLAMA_HOST": base_url}), which is exactly the shape AiderAdapter._build_aider_env already expects for the ollama provider. Design decisions (from a grilling session, recorded in metis/docs/ecosystem-coordination-2026-07-20.md): - Metis standardizes worker serving on Ollama, not raw llama.cpp llama-server, so this reuses AIS's existing local-model hook instead of inventing a new protocol. - find_execution_options on the Metis side live-pings each candidate's Ollama endpoint rather than trusting static registry data, so a stale/offline worker never gets returned as a candidate. - Not wired into solve_issues.py's main() yet - that CLI's per-role model resolution needs more investigation before a safe edit there. This commit ships the standalone, tested client; wiring it into the actual dispatch path is tracked as follow-up work. Fails soft everywhere: unreachable Metis, missing mcp package, no live candidate, or malformed response all return None so callers keep working exactly as before if this optimization isn't available. Co-Authored-By: Claude Sonnet 5 --- requirements.txt | 4 ++ tests/test_metis_local_first.py | 110 ++++++++++++++++++++++++++++++++ workers/metis_local_first.py | 89 ++++++++++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 tests/test_metis_local_first.py create mode 100644 workers/metis_local_first.py diff --git a/requirements.txt b/requirements.txt index 167ae79..0a6315e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,7 @@ requests>=2.31.0 # YAML parsing (role_routing.yaml) pyyaml>=6.0 + +# Optional Metis local-first lookup (workers/metis_local_first.py). Fails +# soft to cloud providers if unreachable or not installed. +mcp>=1.28.0 diff --git a/tests/test_metis_local_first.py b/tests/test_metis_local_first.py new file mode 100644 index 0000000..1c3ccd9 --- /dev/null +++ b/tests/test_metis_local_first.py @@ -0,0 +1,110 @@ +""" +Tests für die optionale Metis-Local-First-Anbindung (workers/metis_local_first.py). + +Abgedeckte Szenarien: +- Erreichbarer Fake-Metis-MCP-Server liefert einen Ollama-Kandidaten zurück. +- Kein Kandidat (ollama_reachable=False) -> None, kein Fehler. +- Unerreichbarer Metis-Endpunkt (falscher Port) -> None, kein Fehler. +- build_ollama_run_config() erzeugt die exakte Form, die + AiderAdapter(provider="ollama") bereits erwartet (config["OLLAMA_HOST"], + model_name als Ollama-Tag). + +Startet einen echten lokalen MCP-Server (FastMCP, Streamable HTTP) als +Test-Double, damit der komplette Client-Pfad (Session-Init, Tool-Call, +JSON-Parsing) real ausgeführt wird statt gemockt. +""" + +from __future__ import annotations + +import socket +import sys +import threading +import time +import unittest +from contextlib import closing +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from workers.metis_local_first import build_ollama_run_config, find_local_solver_candidate + + +def _free_port() -> int: + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _run_fake_metis_server(port: int, candidates: list[dict], ready: threading.Event) -> None: + from mcp.server.fastmcp import FastMCP + + app = FastMCP("fake-metis", host="127.0.0.1", port=port, stateless_http=True) + + @app.tool() + def find_execution_options(capabilities: list[str], min_memory_gb: float | None = None) -> dict: + return {"capabilities_requested": capabilities, "candidates": candidates} + + ready.set() + app.run(transport="streamable-http") + + +class MetisLocalFirstTests(unittest.TestCase): + def _start_fake_server(self, candidates: list[dict]) -> str: + port = _free_port() + ready = threading.Event() + thread = threading.Thread( + target=_run_fake_metis_server, args=(port, candidates, ready), daemon=True + ) + thread.start() + ready.wait(timeout=5) + time.sleep(0.3) # uvicorn needs a moment to actually bind after app.run() starts + self.addCleanup(lambda: None) # daemon thread dies with the test process + return f"http://127.0.0.1:{port}/mcp" + + def test_finds_reachable_ollama_candidate(self): + candidates = [ + { + "worker": {"id": "mac-imac-2", "ollama": {"base_url": "http://192.168.178.43:11434"}}, + "ollama_reachable": True, + "ollama_live_models": ["qwen2.5-coder:3b"], + } + ] + url = self._start_fake_server(candidates) + + candidate = find_local_solver_candidate(capabilities=["local-llm"], metis_url=url) + + self.assertIsNotNone(candidate) + self.assertEqual(candidate["worker"]["id"], "mac-imac-2") + + model, model_name, config = build_ollama_run_config(candidate) + self.assertEqual(model, "ollama") + self.assertEqual(model_name, "qwen2.5-coder:3b") + self.assertEqual(config, {"OLLAMA_HOST": "http://192.168.178.43:11434"}) + + def test_no_reachable_candidate_returns_none(self): + candidates = [ + { + "worker": {"id": "gaming-pc", "ollama": None}, + "ollama_reachable": False, + "ollama_live_models": [], + } + ] + url = self._start_fake_server(candidates) + + candidate = find_local_solver_candidate(capabilities=["local-llm"], metis_url=url) + + self.assertIsNone(candidate) + + def test_unreachable_metis_returns_none_without_raising(self): + # Nothing listening on this port. + port = _free_port() + candidate = find_local_solver_candidate( + capabilities=["local-llm"], metis_url=f"http://127.0.0.1:{port}/mcp" + ) + self.assertIsNone(candidate) + + +if __name__ == "__main__": + unittest.main() diff --git a/workers/metis_local_first.py b/workers/metis_local_first.py new file mode 100644 index 0000000..7a14c00 --- /dev/null +++ b/workers/metis_local_first.py @@ -0,0 +1,89 @@ +"""Optional local-first lookup against the Metis cluster MCP server. + +Metis (github.com/SaJaToGu/metis) tracks the home-lab worker cluster and +exposes a read-only MCP server describing which workers currently have a +live, reachable Ollama endpoint. This module is a thin, fail-soft client: +if Metis is unreachable or has no matching candidate, callers fall back to +their normally configured (cloud) provider — nothing here should ever raise +for an unreachable Metis instance, since it is an optional optimization, not +a hard dependency for solving an issue. + +Usage (aider's existing "ollama" provider already supports this shape, see +workers/aider_adapter.py's AIDER_MODEL_CONFIGS["ollama"] and _build_aider_env): + + candidate = find_local_solver_candidate() + if candidate is not None: + model, model_name, extra_config = build_ollama_run_config(candidate) + # model == "ollama", model_name == e.g. "qwen2.5-coder:3b" + # extra_config == {"OLLAMA_HOST": "http://192.168.178.43:11434"} +""" + +from __future__ import annotations + +import asyncio +import os +from typing import Any + +DEFAULT_METIS_MCP_URL = os.environ.get("METIS_MCP_URL", "http://192.168.178.40:8770/mcp") + +# Capabilities the "solver" role asks Metis for by default. Kept narrow and +# specific on purpose: this is only used for the low-risk, high-volume +# solver role (see the ecosystem-coordination decision in the Metis repo), +# not for planner/architecture work where cloud model quality matters more. +DEFAULT_SOLVER_CAPABILITIES = ["local-llm"] + + +async def _find_candidate_async(capabilities: list[str], metis_url: str) -> dict[str, Any] | None: + try: + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + except ImportError: + return None + + try: + async with asyncio.timeout(5): + async with streamablehttp_client(metis_url) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool( + "find_execution_options", {"capabilities": capabilities} + ) + except Exception: + # Metis unreachable, MCP handshake failed, timeout, etc. - fall back silently. + return None + + import json + + try: + data = json.loads(result.content[0].text) + except (IndexError, AttributeError, ValueError): + return None + + for candidate in data.get("candidates", []): + if candidate.get("ollama_reachable") and candidate.get("ollama_live_models"): + return candidate + return None + + +def find_local_solver_candidate( + capabilities: list[str] | None = None, + metis_url: str = DEFAULT_METIS_MCP_URL, +) -> dict[str, Any] | None: + """Return the first live, reachable Ollama candidate for these capabilities. + + Returns None (never raises) if Metis is unreachable, has no candidate, + or the `mcp` package is not installed - all fail-soft fallback cases. + """ + caps = capabilities if capabilities is not None else DEFAULT_SOLVER_CAPABILITIES + return asyncio.run(_find_candidate_async(caps, metis_url)) + + +def build_ollama_run_config(candidate: dict[str, Any]) -> tuple[str, str, dict[str, str]]: + """Turn a Metis candidate into (model, model_name, config) for get_worker_adapter/AiderAdapter. + + model is always "ollama" - this reuses the existing AIDER_MODEL_CONFIGS["ollama"] + provider as-is, so no new adapter class is needed. + """ + ollama = candidate["worker"]["ollama"] + model_name = candidate["ollama_live_models"][0] + return "ollama", model_name, {"OLLAMA_HOST": ollama["base_url"]} From db867c8fc2cae039b9714ed31684792ec1e196ab Mon Sep 17 00:00:00 2001 From: Guido Berning <30648231+SaJaToGu@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:35:13 +0200 Subject: [PATCH 2/2] feat(workers): wire Metis local-first lookup into solve_issues.py dispatch Adds an opt-in --local-first flag that queries Metis for a live cluster Ollama worker before each solver-role issue, falling back to the CLI-specified --model on any miss (unreachable Metis, no candidate, ensemble mode, or dry-run). Extracts the override logic into resolve_local_first_dispatch() so it's unit-testable independent of main()'s CLI wiring. Co-Authored-By: Claude Sonnet 5 --- scripts/solve_issues.py | 64 +++++++++++++++++++++++++++++++-- tests/test_solve_issues.py | 74 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 3 deletions(-) diff --git a/scripts/solve_issues.py b/scripts/solve_issues.py index 6c39b4b..ee79fac 100644 --- a/scripts/solve_issues.py +++ b/scripts/solve_issues.py @@ -1853,6 +1853,38 @@ def get_worker_adapter(model: str): raise ValueError(f"Unbekannter Worker-Provider: '{model}'") +def resolve_local_first_dispatch( + local_first: bool, + model: str, + model_name: str, + cfg: dict, + user: str, + solver_config: dict, +) -> tuple[str, str, dict]: + """Try a live Metis cluster Ollama worker before falling back to `model`. + + Solver-role only (see role_routing.yaml / ecosystem-coordination decision): + the caller is responsible for only setting `local_first=True` outside + ensemble/dry-run runs. Fail-soft — find_local_solver_candidate() never + raises, so any Metis/candidate unavailability just returns the unchanged + (model, model_name, solver_config) the caller already had. + """ + if not local_first: + return model, model_name, solver_config + + from workers.metis_local_first import build_ollama_run_config, find_local_solver_candidate + + candidate = find_local_solver_candidate() + if candidate is None: + return model, model_name, solver_config + + local_model, local_model_name, ollama_config = build_ollama_run_config(candidate) + local_config = {"owner": user, "config": {**cfg, **ollama_config}} + worker_name = candidate.get("worker", {}).get("id", "?") + print(f" 🏠 Metis local-first: {local_model_name} auf {worker_name}") + return local_model, local_model_name, local_config + + def build_worker_command( model: str, model_name: str, @@ -4063,6 +4095,15 @@ def main(): action="store_true", help="Modell automatisch basierend auf Issue-Typ, Risiko und Kosten auswählen" ) + parser.add_argument( + "--local-first", + action="store_true", + help=( + "Vor --model erst einen lebenden, lokalen Ollama-Worker im Metis-Cluster " + "anfragen (nur solver-Rolle); ohne Treffer wird --model wie gewohnt genutzt. " + "Erfordert aider (der lokale Pfad läuft ueber den ollama-Aider-Provider)." + ), + ) parser.add_argument( "--max-cost", choices=["cheap", "medium", "expensive"], @@ -4356,6 +4397,14 @@ def main(): print(" → Mehr Infos: docs/SETUP_AIDER.md") sys.exit(1) + if args.local_first and args.model in ("codex", "mistral-vibe", "opencode") and not check_aider_installed() and not args.dry_run: + # --local-first kann args.model fuer diesen Lauf durch den ollama-Aider-Provider + # ersetzen, auch wenn das urspruenglich gewaehlte Modell kein aider-Provider ist. + print_err("aider ist nicht installiert, wird aber fuer --local-first benoetigt!") + print(" → Installieren mit: pip install aider-chat") + print(" → Mehr Infos: docs/SETUP_AIDER.md") + sys.exit(1) + # Modellauswahl if args.auto_model: from model_selection import select_model_for_issue @@ -4566,13 +4615,22 @@ def main(): if budget_msg: print_warn(budget_msg) + issue_model, issue_model_name, issue_config = resolve_local_first_dispatch( + local_first=args.local_first and not args.dry_run and args.ensemble == 0, + model=args.model, + model_name=model_name, + cfg=cfg, + user=user, + solver_config=solver_config, + ) + ok = solve_issue( client=client, issue=issue, repo=repo_name, - model=args.model, - model_name=model_name, - config=solver_config, + model=issue_model, + model_name=issue_model_name, + config=issue_config, token=token, dry_run=args.dry_run, base_branch=base_branch, diff --git a/tests/test_solve_issues.py b/tests/test_solve_issues.py index cf978a7..e42c626 100644 --- a/tests/test_solve_issues.py +++ b/tests/test_solve_issues.py @@ -69,6 +69,7 @@ should_surface_worker_line, sleep_until_codex_reset, main as solve_issues_main, + resolve_local_first_dispatch, validate_worker_changes, solve_issue, write_run_report, @@ -3187,5 +3188,78 @@ def test_multiple_flags_combined(self): ) +class TestResolveLocalFirstDispatch(unittest.TestCase): + """solve_issues.resolve_local_first_dispatch — the per-issue Metis local-first + override wired into main()'s dispatch (ai-issue-solver PR #489 follow-up).""" + + def test_disabled_returns_original_model_unchanged(self): + solver_config = {"owner": "u", "config": {"OPENROUTER_API_KEY": "x"}} + with patch("workers.metis_local_first.find_local_solver_candidate") as mock_find: + model, model_name, config = resolve_local_first_dispatch( + local_first=False, + model="openrouter", + model_name="openrouter/openai/gpt-4o-mini", + cfg={"OPENROUTER_API_KEY": "x"}, + user="u", + solver_config=solver_config, + ) + mock_find.assert_not_called() + self.assertEqual(model, "openrouter") + self.assertEqual(model_name, "openrouter/openai/gpt-4o-mini") + self.assertIs(config, solver_config) + + def test_no_candidate_falls_back_to_original_model(self): + solver_config = {"owner": "u", "config": {"OPENROUTER_API_KEY": "x"}} + with patch( + "workers.metis_local_first.find_local_solver_candidate", return_value=None + ): + model, model_name, config = resolve_local_first_dispatch( + local_first=True, + model="openrouter", + model_name="openrouter/openai/gpt-4o-mini", + cfg={"OPENROUTER_API_KEY": "x"}, + user="u", + solver_config=solver_config, + ) + self.assertEqual(model, "openrouter") + self.assertEqual(model_name, "openrouter/openai/gpt-4o-mini") + self.assertIs(config, solver_config) + + def test_live_candidate_overrides_model_and_injects_ollama_host(self): + solver_config = {"owner": "u", "config": {"OPENROUTER_API_KEY": "x"}} + fake_candidate = { + "worker": {"id": "mac-imac-2", "ollama": {"base_url": "http://192.168.178.43:11434"}}, + "ollama_reachable": True, + "ollama_live_models": ["qwen2.5-coder:3b"], + } + with patch( + "workers.metis_local_first.find_local_solver_candidate", + return_value=fake_candidate, + ): + model, model_name, config = resolve_local_first_dispatch( + local_first=True, + model="openrouter", + model_name="openrouter/openai/gpt-4o-mini", + cfg={"OPENROUTER_API_KEY": "x"}, + user="u", + solver_config=solver_config, + ) + self.assertEqual(model, "ollama") + self.assertEqual(model_name, "qwen2.5-coder:3b") + # Original cloud key preserved alongside the injected OLLAMA_HOST, and + # the caller's solver_config dict must not be mutated in place. + self.assertEqual( + config, + { + "owner": "u", + "config": { + "OPENROUTER_API_KEY": "x", + "OLLAMA_HOST": "http://192.168.178.43:11434", + }, + }, + ) + self.assertEqual(solver_config["config"], {"OPENROUTER_API_KEY": "x"}) + + if __name__ == "__main__": unittest.main()