Skip to content

Commit 596ba50

Browse files
committed
fix(builder): resolve the agent builder's model against what's installed
Build Custom Agent hardcoded model_id="Qwen3.5-35B-A3B-GGUF" and fell back to it even when nothing upstream resolved a real one — so the 35B requirement came back by default on every machine that doesn't have it installed (no `gaia init` profile installs it). - BuilderAgent's registry entry now carries a real preference list (BUILDER_PREFERRED_MODELS: 35B, then the two models actual init profiles install) instead of models=[], so AgentRegistry.resolve_model picks up an installed model before construction. - Added a construction-time safety net (_select_builder_model) so the hardcoded-35B fallback can never resurface even when a caller omits model_id outright (CLI, tests, or the "nothing preferred is installed" path) — it checks Lemonade's live model list and fails loudly, naming what to install, instead of silently defaulting. - Distinguishes "Lemonade unreachable" from "reachable but nothing usable installed" — conflating the two was flagged as a real risk since both previously collapsed to an empty list with no way to tell them apart. - No "model quality may be reduced" messaging: the repo's own eval baselines show the smaller model outperforming the 35B on tool selection, so no such claim is substantiated. Builds on the model-not-found (404) surfacing already on this branch.
1 parent f00b174 commit 596ba50

4 files changed

Lines changed: 122 additions & 33 deletions

File tree

src/gaia/agents/builder/agent.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
from gaia.agents.base.agent import Agent, default_max_steps
2121
from gaia.agents.base.console import AgentConsole
2222
from gaia.agents.base.tools import tool
23+
from gaia.agents.registry import (
24+
BUILDER_PREFERRED_MODELS,
25+
get_lemonade_models,
26+
resolve_preferred_model,
27+
)
28+
from gaia.llm.providers.lemonade import LemonadeError, LemonadeNetworkError
2329
from gaia.logger import get_logger
2430

2531
logger = get_logger(__name__)
@@ -112,6 +118,47 @@ def _normalize_display_name(name: str) -> str:
112118
return " ".join(words)
113119

114120

121+
def _select_builder_model(base_url: str) -> str:
122+
"""Pick an installed model for the builder, or fail loudly.
123+
124+
Runs only when the caller didn't already pin an explicit ``model_id`` —
125+
the normal Agent UI path resolves one upstream via
126+
``AgentRegistry.resolve_model`` before construction. This is the
127+
construction-time safety net: it must not trust that an omitted
128+
``model_id`` means "the 35B default is fine" (that trust is exactly what
129+
caused #2243 — the omission is deliberate per #841, not an endorsement of
130+
the hardcoded fallback).
131+
132+
Distinguishes "Lemonade unreachable" (retryable, connectivity problem)
133+
from "reachable but nothing usable is installed" (not retryable, needs a
134+
model install) — the two need different remediation.
135+
"""
136+
available = get_lemonade_models(base_url)
137+
if available is None:
138+
raise LemonadeNetworkError()
139+
140+
selected = resolve_preferred_model(BUILDER_PREFERRED_MODELS, available)
141+
if selected is None:
142+
candidates = ", ".join(BUILDER_PREFERRED_MODELS)
143+
err = LemonadeError(
144+
user_message=(
145+
"No usable model is installed for the agent builder. Install "
146+
f"one of: {candidates} — for example "
147+
f"`gaia download {BUILDER_PREFERRED_MODELS[-1]}` — or run "
148+
"`gaia init` to set up a profile, then try again."
149+
)
150+
)
151+
logger.warning(
152+
"builder: no preferred model installed (checked %s against %s)",
153+
BUILDER_PREFERRED_MODELS,
154+
available,
155+
)
156+
raise err
157+
158+
logger.info("builder: selected model %s", selected)
159+
return selected
160+
161+
115162
@dataclass
116163
class BuilderAgentConfig:
117164
"""Configuration for BuilderAgent."""
@@ -146,12 +193,19 @@ def __init__(self, config: Optional[BuilderAgentConfig] = None):
146193
config = config or BuilderAgentConfig()
147194
self.config = config
148195

149-
effective_model_id = config.model_id or "Qwen3.5-35B-A3B-GGUF"
150196
effective_base_url = (
151197
config.base_url
152198
if config.base_url is not None
153199
else os.getenv("LEMONADE_BASE_URL", "http://localhost:13305/api/v1")
154200
)
201+
# An explicit model_id (session-resolved upstream, or pinned by a
202+
# caller) is never second-guessed by a live check. Only an omitted
203+
# model_id triggers the construction-time safety net (#2243) — see
204+
# _select_builder_model's docstring for why this can't just default
205+
# to a hardcoded model.
206+
effective_model_id = config.model_id or _select_builder_model(
207+
effective_base_url
208+
)
155209

156210
self.response_mode = "conversational"
157211
super().__init__(

src/gaia/agents/registry.py

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,55 @@
7373
)
7474

7575

76+
# BuilderAgent's model preference, best-to-worst. The first two entries match
77+
# what `gaia init` profiles actually install (`Gemma-4-E4B-it-GGUF` is the
78+
# default-profile model; `gemma4-it-e2b-FLM` is the npu-profile one — see
79+
# `INIT_PROFILES` in `gaia.installer.init_command`); no profile installs the
80+
# 35B, so it must not be the only entry (#2243).
81+
BUILDER_PREFERRED_MODELS: List[str] = [
82+
"Qwen3.5-35B-A3B-GGUF",
83+
"Gemma-4-E4B-it-GGUF",
84+
"gemma4-it-e2b-FLM",
85+
]
86+
87+
88+
def resolve_preferred_model(
89+
preferred_models: List[str], available_models: List[str]
90+
) -> Optional[str]:
91+
"""Return the first of *preferred_models* present in *available_models*.
92+
93+
Pure ordering primitive shared by ``AgentRegistry.resolve_model`` and any
94+
caller (e.g. ``BuilderAgent``) that needs to pick a model from a live
95+
Lemonade catalog without going through a full registry instance.
96+
"""
97+
for model in preferred_models:
98+
if model in available_models:
99+
return model
100+
return None
101+
102+
103+
def get_lemonade_models(base_url: str, timeout: float = 2.0) -> Optional[List[str]]:
104+
"""Query Lemonade's ``/models`` endpoint directly (no cache, no backoff).
105+
106+
Returns the list of installed model ids on a 2xx response — an empty list
107+
means Lemonade is reachable but nothing is loaded/installed. Returns
108+
``None`` when the request could not be completed at all (connection
109+
error, timeout, non-2xx, malformed response). Callers must not conflate
110+
the two: "unreachable" and "reachable but nothing installed" call for
111+
different messages and different remediation.
112+
"""
113+
try:
114+
import requests
115+
116+
resp = requests.get(f"{base_url}/models", timeout=timeout)
117+
if resp.status_code == 200:
118+
data = resp.json()
119+
return [m["id"] for m in data.get("data", [])]
120+
except Exception:
121+
pass
122+
return None
123+
124+
76125
# Session-level kwargs that constrain the agent's effective sandbox. If
77126
# python_factory drops one of these for a class that doesn't declare it, the
78127
# session-intended constraint silently relaxes to the agent's default — log
@@ -628,7 +677,7 @@ def builder_factory(**kwargs):
628677
builder_factory, "builtin:builder"
629678
),
630679
agent_dir=None,
631-
models=[],
680+
models=BUILDER_PREFERRED_MODELS,
632681
hidden=True,
633682
required_connections=[],
634683
namespaced_agent_id="builtin:builder",
@@ -1220,23 +1269,19 @@ def resolve_model(
12201269
if available_models is None:
12211270
available_models = self._get_available_models()
12221271

1223-
for model in preferred_models:
1224-
if model in available_models:
1225-
logger.info(
1226-
"registry: Agent %s: preferred model %s available",
1227-
agent_id,
1228-
model,
1229-
)
1230-
return model
1272+
resolved = resolve_preferred_model(preferred_models, available_models)
1273+
if resolved is not None:
12311274
logger.info(
1232-
"registry: Agent %s: preferred model %s not available, trying next",
1275+
"registry: Agent %s: preferred model %s available",
12331276
agent_id,
1234-
model,
1277+
resolved,
12351278
)
1279+
return resolved
12361280

12371281
logger.warning(
1238-
"registry: Agent %s: no preferred models available, using server default",
1282+
"registry: Agent %s: no preferred models available (%s), using server default",
12391283
agent_id,
1284+
preferred_models,
12401285
)
12411286
return None
12421287

@@ -1258,17 +1303,11 @@ def _get_available_models(self) -> List[str]:
12581303
):
12591304
return []
12601305

1261-
try:
1262-
import requests
1263-
1264-
base_url = os.getenv("LEMONADE_BASE_URL", "http://localhost:13305/api/v1")
1265-
resp = requests.get(f"{base_url}/models", timeout=2)
1266-
if resp.ok:
1267-
data = resp.json()
1268-
self._lemonade_models = [m["id"] for m in data.get("data", [])]
1269-
return self._lemonade_models
1270-
except Exception:
1271-
pass
1306+
base_url = os.getenv("LEMONADE_BASE_URL", "http://localhost:13305/api/v1")
1307+
models = get_lemonade_models(base_url)
1308+
if models is not None:
1309+
self._lemonade_models = models
1310+
return self._lemonade_models
12721311

12731312
# Record failure timestamp; do NOT cache models so we retry after the interval.
12741313
self._lemonade_models_last_fail = time.monotonic()

tests/unit/agents/test_builder_model_selection.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ def test_no_usable_model_installed_raises_actionable_error(self):
8989
_select_builder_model(_BASE_URL)
9090
message = excinfo.value.user_message
9191
# Must name a concrete candidate model so the user knows what to install.
92-
assert any(model in message for model in BUILDER_PREFERRED_MODELS), (
93-
f"expected a concrete candidate model name in the error, got: {message!r}"
94-
)
92+
assert any(
93+
model in message for model in BUILDER_PREFERRED_MODELS
94+
), f"expected a concrete candidate model name in the error, got: {message!r}"
9595
# Must give a remediation command.
9696
assert (
9797
"gaia download" in message or "gaia init" in message
@@ -213,9 +213,7 @@ def test_get_lemonade_models_returns_none_on_connection_error(self):
213213

214214
from gaia.agents.registry import get_lemonade_models
215215

216-
with patch(
217-
"requests.get", side_effect=requests.exceptions.ConnectionError()
218-
):
216+
with patch("requests.get", side_effect=requests.exceptions.ConnectionError()):
219217
result = get_lemonade_models(_BASE_URL)
220218
assert result is None
221219

tests/unit/agents/test_registry.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -879,9 +879,7 @@ def test_get_lemonade_models_none_means_unreachable_not_empty(self):
879879
callers must be able to tell them apart."""
880880
from gaia.agents.registry import get_lemonade_models
881881

882-
mock_response = SimpleNamespace(
883-
status_code=200, json=lambda: {"data": []}
884-
)
882+
mock_response = SimpleNamespace(status_code=200, json=lambda: {"data": []})
885883
with patch("requests.get", return_value=mock_response):
886884
reachable_empty = get_lemonade_models("http://localhost:13305/api/v1")
887885

0 commit comments

Comments
 (0)