Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24816,7 +24816,7 @@ export interface components {
help?: components["schemas"]["HelpContent"] | null;
/**
* id
* @description Unique identifier for the tool. Should be all lower-case and should not include whitespace.
* @description Unique identifier for the tool. Lowercase, must start with a letter, may contain letters, digits, '_' and '-'.
* @example my-cool-tool
*/
id?: string | null;
Expand Down Expand Up @@ -24915,7 +24915,7 @@ export interface components {
help?: components["schemas"]["HelpContent"] | null;
/**
* id
* @description Unique identifier for the tool. Should be all lower-case and should not include whitespace.
* @description Unique identifier for the tool. Lowercase, must start with a letter, may contain letters, digits, '_' and '-'.
* @example my-cool-tool
*/
id?: string | null;
Expand Down Expand Up @@ -26622,7 +26622,7 @@ export interface components {
help?: components["schemas"]["HelpContent"] | null;
/**
* id
* @description Unique identifier for the tool. Should be all lower-case and should not include whitespace.
* @description Unique identifier for the tool. Lowercase, must start with a letter, may contain letters, digits, '_' and '-'.
* @example my-cool-tool
*/
id?: string | null;
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Tool/ToolSourceSchema.json

Large diffs are not rendered by default.

61 changes: 60 additions & 1 deletion lib/galaxy/agents/custom_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@
)

import yaml
from pydantic import ValidationError
from pydantic_ai import Agent
from pydantic_ai.exceptions import (
ModelHTTPError,
UnexpectedModelBehavior,
)

from galaxy.schema.agents import ConfidenceLevel
from galaxy.tool_util_models import UserToolSource
from galaxy.tool_util.lint import lint_user_tool_source
from galaxy.tool_util_models import (
format_validation_errors,
UserToolSource,
)
from .base import (
ActionSuggestion,
ActionType,
Expand All @@ -32,6 +37,22 @@
log = logging.getLogger(__name__)


def _find_validation_error(exc: BaseException) -> Optional[ValidationError]:
"""Walk the exception cause chain looking for a pydantic ValidationError.

pydantic-ai wraps validation failures inside UnexpectedModelBehavior after
exhausting retries; the original ValidationError surfaces via __cause__.
"""
seen: set[int] = set()
current: Optional[BaseException] = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
if isinstance(current, ValidationError):
return current
current = current.__cause__ or current.__context__
return None


class CustomToolAgent(BaseGalaxyAgent):
"""Agent that creates custom Galaxy tools using UserToolSource schema.

Expand Down Expand Up @@ -98,6 +119,23 @@ async def process(self, query: str, context: Optional[dict[str, Any]] = None) ->
error="invalid_structured_output",
)

lint_errors = lint_user_tool_source(tool)
if lint_errors:
log.debug("CustomToolAgent lint failure: %s", lint_errors)
bullet_text = "\n".join(f"- {issue}" for issue in lint_errors)
return self._build_response(
content=(
"The model produced a tool definition, but it has problems "
"that need to be fixed before it can be saved:\n\n"
f"{bullet_text}"
),
confidence=ConfidenceLevel.LOW,
method="lint_error",
query=query,
error="lint_failed",
agent_data={"lint_errors": lint_errors},
)

tool_dict = tool.model_dump(by_alias=True, exclude_none=True)
tool_yaml = yaml.dump(tool_dict, default_flow_style=False, sort_keys=False)

Expand Down Expand Up @@ -178,6 +216,27 @@ async def process(self, query: str, context: Optional[dict[str, Any]] = None) ->
)
raise
except UnexpectedModelBehavior as e:
pydantic_error = _find_validation_error(e)
if pydantic_error is not None:
# Expected path: the user is editing the prompt iteratively and
# the LLM produced a tool definition that fails one of the
# UserToolSource validators. Surface the friendly bullet list
# rather than a generic "model misbehaved" message.
bullets = format_validation_errors(pydantic_error)
log.debug("CustomToolAgent validation failure: %s", bullets)
bullet_text = "\n".join(f"- {issue}" for issue in bullets)
return self._build_response(
content=(
"The model produced a tool definition, but it has problems "
"that need to be fixed before it can be saved:\n\n"
f"{bullet_text}"
),
confidence=ConfidenceLevel.LOW,
method="validation_error",
query=query,
error="validation_failed",
agent_data={"validation_errors": bullets},
)
log.warning(f"Model failed to produce valid tool definition: {e}")
model = self._get_agent_config("model", "unknown")
return self._build_response(
Expand Down
4 changes: 4 additions & 0 deletions lib/galaxy/managers/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
UserDynamicToolAssociation,
)
from galaxy.tool_util.cwl import tool_proxy
from galaxy.tool_util.lint import lint_user_tool_source
from galaxy.tool_util.parser.yaml import YamlToolSource
from galaxy.tool_util.toolbox import AbstractToolBox
from galaxy.tool_util_models.dynamic_tool_models import (
Expand Down Expand Up @@ -194,6 +195,9 @@ def create_unprivileged_tool(
"Set 'enable_beta_tool_formats' in Galaxy config to create dynamic tools."
)
self.ensure_can_use_unprivileged_tool(user)
lint_errors = lint_user_tool_source(tool_payload.representation)
if lint_errors:
raise exceptions.RequestParameterInvalidException("Tool failed lint checks: " + "; ".join(lint_errors))
dynamic_tool = self.create(
tool_format=tool_payload.representation.class_,
tool_id=tool_payload.representation.id,
Expand Down
19 changes: 19 additions & 0 deletions lib/galaxy/tool_util/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@

import galaxy.tool_util.linters
from galaxy.tool_util.parser import get_tool_source
from galaxy.tool_util.parser.yaml import YamlToolSource
from galaxy.util import (
Element,
submodules,
)

if TYPE_CHECKING:
from galaxy.tool_util.parser.interface import ToolSource
from galaxy.tool_util_models import UserToolSource


class LintLevel(IntEnum):
Expand Down Expand Up @@ -308,6 +310,23 @@ def failed(self, fail_level: Union[LintLevel, str]) -> bool:
return lint_fail


def lint_user_tool_source(user_tool_source: "UserToolSource") -> List[str]:
"""Run the lint pipeline against a ``UserToolSource`` pydantic value.

Returns a list of formatted ``"<linter>: <message>"`` bullets at WARN
level or above, suitable for surfacing through ``format_validation_errors``-
style consumers (the agent's bullet list, an API 4xx body).
"""
root_dict = user_tool_source.model_dump(by_alias=True, exclude_none=True)
tool_source = YamlToolSource(root_dict)
lint_context = get_lint_context_for_tool_source(tool_source)
bullets: List[str] = []
for message in lint_context.error_messages + lint_context.warn_messages:
prefix = f"{message.linter}: " if message.linter else ""
bullets.append(f"{prefix}{message.message}")
return bullets


def lint_tool_source(
tool_source, level=LintLevel.ALL, fail_level=LintLevel.WARN, extra_modules=None, skip_types=None, name=None
) -> bool:
Expand Down
54 changes: 54 additions & 0 deletions lib/galaxy/tool_util/linters/containers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Linter rules covering container references on a tool source."""

import re
from typing import (
Iterator,
Tuple,
TYPE_CHECKING,
)

from galaxy.tool_util.lint import Linter

if TYPE_CHECKING:
from galaxy.tool_util.lint import LintContext
from galaxy.tool_util.parser.interface import ToolSource


lint_tool_types = ["*"]


CONTAINER_PREFIXES: Tuple[str, ...] = ("quay.io/biocontainers/", "docker://", "oras://")
DOCKER_IMAGE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*(/[a-zA-Z0-9._-]+)*(:[\w][\w.-]*)?$")


def _iter_container_identifiers(tool_source: "ToolSource") -> Iterator[str]:
try:
_, containers, _, _, _ = tool_source.parse_requirements()
except Exception:
return
for container in containers or ():
identifier = getattr(container, "identifier", None)
if identifier:
yield identifier


class ContainerImageShape(Linter):
"""Container identifiers should match a recognized shape.

Recognized: a `quay.io/biocontainers/...`, `docker://...`, or `oras://...`
prefix; or a Docker-Hub-style `<image>[:<tag>]` reference.
"""

@classmethod
def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"):
for identifier in _iter_container_identifiers(tool_source):
stripped = identifier.strip()
if stripped.startswith(CONTAINER_PREFIXES):
continue
if DOCKER_IMAGE_RE.match(stripped):
continue
lint_ctx.warn(
f"container '{identifier}' does not match a recognized shape "
"(quay.io/biocontainers/..., docker://..., oras://..., or <image>[:<tag>])",
linter=cls.name(),
)
Loading
Loading