fix(image): gate image tools on the models' declared abilities - #1294
fix(image): gate image tools on the models' declared abilities#1294bluefish-08 wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces capability gating for image tools, dynamically registering the generate_image and edit_image tools only when configured models support those capabilities. It also refines error messages and default model resolution to respect these abilities. The review feedback correctly identifies that the description for list_image_models contains a note referencing generate_image, which could be misleading if generate_image is omitted due to the new gating logic, and suggests removing it.
rogercloud
left a comment
There was a problem hiding this comment.
Summary
This PR moves image-tool availability from prose to schema. ImageGenerationTool.get_tools() now consults has_generate_capable_model() / has_edit_capable_model() and registers only the tools that can actually succeed: no generate-capable model means no generate_image, no edit-capable model means no edit_image, and neither means an empty tool list. It also adds an explicit ability check to the previously-unchecked default_generate_model / default_edit_model branches of _get_model() / _get_edit_model() (via _denies_ability), and replaces the two flat "no available image models" strings with self-correctable errors that enumerate the configured models and their abilities.
Approach verdict
Acceptable with reservations — gating at the schema rather than in description text is the right fix for the observed failure (8 blind edit_image retries followed by 16 execute_python_code fallbacks), and reusing _get_model() / _get_edit_model() as both the runtime resolver and the registration gate is a genuine single source of truth: the adapter and the core cannot disagree about what is available.
The reservation is that it closes the front door and leaves a side door guarded only by prose — the exact pattern the issue's own reasoning says does not work:
-
The
images=path ongenerate_imageis still reachable when editing is unavailable.generate_imagedelegates toedit_image()as soon asimages is not None, before any of its own model checks. In the commoncan_generate=True, can_edit=Falsedeployment,generate_imageis registered, and a model that passesimages=lands in the edit path. The only guard is the prepended "IMAGE EDITING IS UNAVAILABLE" prose in the description — the same class of mitigation this PR argues is insufficient. To be fair, this degrades gracefully (a structured_no_edit_model_error()tool result, not a crash or a silent wrong answer), so it is not a repeat of the original bug — but the recommendation text it returns is wrong in this state (see the first line-level finding), and this whole path is untested for the generate-only case. -
src/xagent/web/api/tools.py:120-133becomes unreachable in exactly the deployments this PR targets._create_tool_info()has anelif tool_name == "edit_image":branch that independently re-derives "does any model have theeditability" and, when not, marks the toolstatus: "missing_capability"with a remediation message for the admin. That branch only fires if a tool object literally namededit_imageis present in whatToolFactory.create_all_tools()returns — which this PR now guarantees it is not, whenever the gate trips. Net effect: the admin tool listing loses the "add an image model with editing support" explanation entirely, andedit_imagejust silently disappears from the list. That file is untouched here and no test covers the interaction. Either the branch should be updated to render a synthetic disabled row, or (cleaner) the adapter should expose the reason so the web layer stops re-deriving the same predicate. At minimum this deserves a follow-up issue rather than being left as dead code. -
Two opposite leniency defaults for the same edge case live in the same file.
_denies_ability(used on the default-model branches) treats a model with nohas_abilitymethod as trusted; the explicit-model_idbranches treat the same missing method as not having the ability. Verified this is not a live risk — every real model class (DashScopeImageModel,XinferenceImageModel,OpenAIImageModel,GeminiImageModel,ImageModelAdapter) implementshas_abilityconcretely, so only a non-conforming test double can hit it. Hygiene/consistency point, not a blocker, but pick one default and document it. -
Open design question (not a defect): dropping
list_image_modelsalong with the others when neither ability exists is deliberate and tested, per the PR description. But the stated rationale — "models blind-retry a doomed tool" — only justifies removing tools that fail.list_image_modelsis read-only and cannot fail; it is the one tool that could let the agent (or a human reading the trace) answer "why is there nothing here". Was keeping it considered and rejected for a specific reason?
Line-level findings
Moderate
-
src/xagent/core/tools/core/image_tool.py:393-394— the remediation text is circular in its most likely trigger state._no_edit_model_error()unconditionally says "render what you need from a text prompt withgenerate_imageinstead of retryingedit_image". The realistic trigger is not "both abilities missing" (there,edit_imageis unreachable via the schema anyway) — it iscan_generate=True, can_edit=False, wheregenerate_image(images=...)delegates intoedit_image(). The model then receives "usegenerate_imageinstead" as the result of agenerate_imagecall. The message should branch onhas_generate_capable_model(): when generate is available, tell the model to retrygenerate_imagewithoutimages; otherwise omit the suggestion. No test coversgenerate_image(..., images=...)in the generate-only state (the existing test that exercises the delegation configures a model with both abilities). -
src/xagent/core/tools/core/image_tool.py:187-199— the broadexcept Exceptionaroundcreate_image_tool()now swallows a much more consequential failure.create_image_tools_from_configwraps the whole construction — which now includesget_tools()and therefore everymodel.has_ability(...)call — inexcept Exception as e: logger.warning(...); return []. A model whosehas_abilityraises now silently removes all image tools, including the always-safelist_image_models, leaving only a warning line. Meanwhile the directcreate_image_tool()path has no exception handling at all and propagates the same error raw. Same failure, two opposite outcomes depending on entry point, and neither is tested. (Out of the diff's line range, so no inline comment — but it is a direct consequence of moving ability checks into the registration path.)
Minor
-
src/xagent/core/tools/core/image_tool.py:399— "No available image models configured." is factually wrong when models are configured but lack thegenerateability. The PR's own new testtest_generate_error_lists_abilities(tests/core/tools/test_image_tool.py:887-897) configures a realmodel1withabilities=["edit"]and produces exactly this misleading lead sentence, immediately contradicted by the enumeration that follows it. -
src/xagent/core/tools/core/image_tool.py:385— an explicitly emptyabilitieslist renders as"unknown".", ".join(...) or "unknown"collapses "this model declares zero abilities" (a known fact) into "we could not determine its abilities" (an unknown). Render"none"forabilities == []. Untested — the neither-ability test only asserts the tool list is[], never the error string. -
src/xagent/core/tools/core/image_tool.py:383-384— non-listabilitiesis silently coerced to[]. A malformed model class then reports "unknown" instead of surfacing the misconfiguration. Currently unreachable in production (theBaseImageModel.abilitiescontract returns a real sequence), so purely defensive-code hygiene — but alogger.warningwould be cheap. -
src/xagent/core/tools/core/image_tool.py:366-369—_denies_ability's "missing method ⇒ trusted" default contradicts the explicit-model_idbranches (lines ~320, ~334, ~345, ~360), which treat the same case as "lacks the ability". See the body note above; consistency nit only.
Test quality
-
tests/core/tools/test_image_tool.py:471—assert "No image models" in image_tool._model_info_textpins dead internal state. On this path_model_info_textis never read: it is only consumed inside theif can_generate:branch, which this scenario by construction never enters. The behavioral assertion on the previous line (get_tools() == []) is the real test; this one would keep passing even if the text became meaningless. -
tests/core/tools/test_image_tool.py:848—assert "generate_image" in result["error"]is tautological. That string is a hardcoded literal in_no_edit_model_error()regardless of whethergenerate_imageis actually registered; the assertion passes identically in the state where the recommendation is invalid. Combined with finding 1, this test gives false confidence that the recommendation is validated. -
tests/core/tools/core/test_image_tool_core.py:226andtests/core/tools/test_image_tool.py:241— loosening==toindrops regression coverage of the new tail. The enumerated-models list and the remediation hint are now completely unasserted on these two paths. Adding a second assertion (e.g.assert "Configured image models:" in result["error"]) restores it without re-pinning the exact string. -
Missing symmetric coverage for the generate side.
test_default_edit_model_that_denies_edit_is_not_trusted(tests/core/tools/test_image_tool.py:850-861) covers a generate-only model configured asdefault_edit_model, but there is no counterpart for an edit-only model configured asdefault_generate_model—has_generate_capable_model()combined with a denyingdefault_generate_modelhas zero references in either test file, even though_get_modeland_get_edit_modelwere changed identically. -
Style: the new tests use
tool.name(tests/core/tools/test_image_tool.py:816,831,871) while the rest of the file usestool.metadata.name. Both are correct; worth matching the surrounding file.
Out of scope, but worth a follow-up issue
src/xagent/skills/builtin/static-visual-design/SKILL.md (~lines 190-193 and ~326) and references/static-ad-art-direction.md (~line 296) still instruct the agent unconditionally to call edit_image or to pass reference images via images=. That skill text is injected into context verbatim with no awareness of which tools were registered, so on a generate-only deployment the agent is now told to use a tool that is not in its schema. Different subsystem, correctly not touched here — but it undercuts part of the win, and the PR description's "Out of scope" section does not mention it. Suggest filing a follow-up.
Simplification opportunities
src/xagent/core/tools/core/image_tool.pyL371-377: shrink —has_generate_capable_model()/has_edit_capable_model()are one-lineis not Nonewrappers with exactly one caller each (src/xagent/core/tools/adapters/vibe/image_tool.py:72-73); inline them at the call site. (Do not collapse them into a single_has_capable_model(ability)— the dispatch costs more than it saves.)- Considered and rejected: merging
_no_edit_model_error/_no_generate_model_error(L389-403). Their opening sentences differ structurally, not by an ability-name substitution, so a template needs a third parameter and just relocates the duplication — and both exact strings are asserted verbatim in tests.
net: -6 lines possible
Prior review
gemini-code-assist[bot]'s inline comment on src/xagent/core/tools/adapters/vibe/image_tool.py:113 is still open: list_image_models's description retains "(Note: model information is already provided in the generate_image tool description)", which becomes misleading precisely in the new no-generate-capable-model state where generate_image is not registered. Not re-filing it inline — flagging it here as carried forward and unaddressed in the current head.
Note on verification: CI is the source of truth for test execution; nothing above is based on a local test run.
|
Thanks for the depth here — this caught a class of problem the original PR reasoned about in one direction only. All 13 inline threads are addressed and resolved; below is what changed, what I found while fixing, and the two things I deliberately left alone. AddressedError messages —
Admin listing — Simplification — Tests — dead Found while fixingYour first finding had a second trigger the fix initially missed.
— self-contradicting, and reachable in exactly the deployments where Not changed
Follow-upAgreed on |
Closes #1295. Sub-issue of #1289.
Background
A production ad-creative task called
edit_image8 times in a row, each failinginstantly with
No available image models with edit capabilities, then workedaround it by editing images through
execute_python_code16 more times. Thatdeployment has a single image model configured with
abilities = ["generate"].The framework problem is that
edit_imageis registered unconditionally, with noregard for whether any configured model can serve it. Worth recording: the
description already said so —
_edit_model_info_textfalls back to the literalstring
No image models with edit capabilities availableand that text isformatted into the tool description. Production shows the model ignored that
passive note and blind-retried anyway, so rewording is not a fix; the tool has to
leave the schema.
Those ~24 doomed and detour calls each cost a full LLM round trip (the whole
history plus the tool schemas re-sent), which is where the bulk of that task's
token spend and its 28-minute wall time came from.
Changes
vibe/image_tool.pyget_tools) — no model withgeneratemeansgenerate_imageis not registered; no model witheditmeans
edit_imageis not registered; neither means an empty tool list. Whenediting is unavailable,
generate_imagegets a leading prohibition in itsdescription, because
generate_image(images=...)delegates to the edit pathand is the side door into the same failure.
core/image_tool.py) — both "no usable model"errors now enumerate the configured models with their abilities and state what
to do next. The original wording is kept as the prefix so existing log
searches still match.
_get_modeland_get_edit_model— the explicitmodel_idbranch verifiedhas_ability, butthe
_default_*_modelbranch trusted its input unconditionally. Agenerate-only model configured as
default_edit_modeltherefore bypassed thecheck, reached the provider, and raised
RuntimeErrorthere instead ofreturning the friendly error. The check is deliberately lenient: it rejects
only a model that explicitly reports lacking the ability, and still trusts one
without a
has_abilitymethod.Why the long description template is left alone
GENERATE_IMAGE_DESCRIPTIONteaches theimagesparameter in three separateplaces. Trimming each would mean adding placeholders to the template for a much
wider diff, and existing tests assert against the class constant directly.
Instead, editing-unavailable deployments get one leading prohibition, backed by
edit_imagebeing absent from the schema and by the self-correctable error. Whenediting is available the description is byte-identical to before.
Out of scope
register an edit-capable model. This change only guarantees that a tool nothing
can serve is no longer offered to the model.
create_retry_wrapper, redundant fields in the tool-result observation,serial media tool execution, planner missing the deliverable type) are not in
this PR.
Verification
TestImageToolCapabilityGatingwith 7 cases:edit_imageabsent and theprohibition present when nothing can edit; both tools present and the
description unchanged when a model can edit;
generate_imageabsent when onlyediting is available; empty list when neither is available; a
default_edit_modelthat denieseditis not trusted; both error stringsenumerate abilities.
models; two exact error-string comparisons relaxed to substring).
tests/core/tools/passes (exit 0).test_command_path_guard_bash.pyandtest_command_executor.pyhave pre-existing failures, reproduced onorigin/mainwith these changes stashed to confirm they are unrelated.ruff check,ruff format,mypy, and the full pre-commit hook set pass.