Skip to content

Commit 713d34b

Browse files
committed
Push UserToolSource semantic validation onto the pydantic model
Move the id pattern, container shape, citation DOI/BibTeX checks, blank required-field checks, undeclared `inputs.<name>` references in `shell_command` / `configfiles[*].content`, and dataset/collection output discovery requirements onto field/model validators on `_DynamicToolSourceBase`, `UserToolSource`, and `Citation`. Doing so benefits API callers of `DynamicUnprivilegedToolCreatePayload`, not just `CustomToolAgent`. Tool ids accept `[a-z][a-z0-9_-]*` (hyphens allowed). Outputs of a tool with `shell_command` must claim their bytes via `from_work_dir` or `discover_datasets`; the prior "name appears in command" heuristic produced both false positives and false negatives. `format_validation_errors` distills `ValidationError` to friendly bullet strings for reuse by the agent and API layers. `CustomToolAgent` walks the cause chain on `UnexpectedModelBehavior` to surface the underlying `ValidationError` as a low-confidence response with bullet list, and logs at debug since validation failure is the expected path while a user is editing.
1 parent 29d11fb commit 713d34b

6 files changed

Lines changed: 396 additions & 7 deletions

File tree

client/src/api/schema/schema.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24919,7 +24919,7 @@ export interface components {
2491924919
help?: components["schemas"]["HelpContent"] | null;
2492024920
/**
2492124921
* id
24922-
* @description Unique identifier for the tool. Should be all lower-case and should not include whitespace.
24922+
* @description Unique identifier for the tool. Lowercase, must start with a letter, may contain letters, digits, '_' and '-'.
2492324923
* @example my-cool-tool
2492424924
*/
2492524925
id?: string | null;
@@ -25018,7 +25018,7 @@ export interface components {
2501825018
help?: components["schemas"]["HelpContent"] | null;
2501925019
/**
2502025020
* id
25021-
* @description Unique identifier for the tool. Should be all lower-case and should not include whitespace.
25021+
* @description Unique identifier for the tool. Lowercase, must start with a letter, may contain letters, digits, '_' and '-'.
2502225022
* @example my-cool-tool
2502325023
*/
2502425024
id?: string | null;
@@ -26725,7 +26725,7 @@ export interface components {
2672526725
help?: components["schemas"]["HelpContent"] | null;
2672626726
/**
2672726727
* id
26728-
* @description Unique identifier for the tool. Should be all lower-case and should not include whitespace.
26728+
* @description Unique identifier for the tool. Lowercase, must start with a letter, may contain letters, digits, '_' and '-'.
2672926729
* @example my-cool-tool
2673026730
*/
2673126731
id?: string | null;

client/src/components/Tool/ToolSourceSchema.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

lib/galaxy/agents/custom_tool.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010
)
1111

1212
import yaml
13+
from pydantic import ValidationError
1314
from pydantic_ai import Agent
1415
from pydantic_ai.exceptions import (
1516
ModelHTTPError,
1617
UnexpectedModelBehavior,
1718
)
1819

1920
from galaxy.schema.agents import ConfidenceLevel
20-
from galaxy.tool_util_models import UserToolSource
21+
from galaxy.tool_util_models import (
22+
format_validation_errors,
23+
UserToolSource,
24+
)
2125
from .base import (
2226
ActionSuggestion,
2327
ActionType,
@@ -32,6 +36,22 @@
3236
log = logging.getLogger(__name__)
3337

3438

39+
def _find_validation_error(exc: BaseException) -> Optional[ValidationError]:
40+
"""Walk the exception cause chain looking for a pydantic ValidationError.
41+
42+
pydantic-ai wraps validation failures inside UnexpectedModelBehavior after
43+
exhausting retries; the original ValidationError surfaces via __cause__.
44+
"""
45+
seen: set[int] = set()
46+
current: Optional[BaseException] = exc
47+
while current is not None and id(current) not in seen:
48+
seen.add(id(current))
49+
if isinstance(current, ValidationError):
50+
return current
51+
current = current.__cause__ or current.__context__
52+
return None
53+
54+
3555
class CustomToolAgent(BaseGalaxyAgent):
3656
"""Agent that creates custom Galaxy tools using UserToolSource schema.
3757
@@ -178,6 +198,27 @@ async def process(self, query: str, context: Optional[dict[str, Any]] = None) ->
178198
)
179199
raise
180200
except UnexpectedModelBehavior as e:
201+
pydantic_error = _find_validation_error(e)
202+
if pydantic_error is not None:
203+
# Expected path: the user is editing the prompt iteratively and
204+
# the LLM produced a tool definition that fails one of the
205+
# UserToolSource validators. Surface the friendly bullet list
206+
# rather than a generic "model misbehaved" message.
207+
bullets = format_validation_errors(pydantic_error)
208+
log.debug("CustomToolAgent validation failure: %s", bullets)
209+
bullet_text = "\n".join(f"- {issue}" for issue in bullets)
210+
return self._build_response(
211+
content=(
212+
"The model produced a tool definition, but it has problems "
213+
"that need to be fixed before it can be saved:\n\n"
214+
f"{bullet_text}"
215+
),
216+
confidence=ConfidenceLevel.LOW,
217+
method="validation_error",
218+
query=query,
219+
error="validation_failed",
220+
agent_data={"validation_errors": bullets},
221+
)
181222
log.warning(f"Model failed to produce valid tool definition: {e}")
182223
model = self._get_agent_config("model", "unknown")
183224
return self._build_response(

lib/galaxy/tool_util_models/__init__.py

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
for reasoning about tool state externally from Galaxy.
55
"""
66

7+
import re
78
from typing import (
89
Any,
910
Dict,
1011
List,
1112
Optional,
13+
Set,
1214
Tuple,
1315
Union,
1416
)
@@ -19,6 +21,7 @@
1921
ConfigDict,
2022
Discriminator,
2123
Field,
24+
field_validator,
2225
model_validator,
2326
RootModel,
2427
Tag,
@@ -41,6 +44,8 @@
4144
from .test_job import Job
4245
from .tool_outputs import (
4346
IncomingToolOutput,
47+
IncomingToolOutputCollection,
48+
IncomingToolOutputDataset,
4449
ToolOutput,
4550
)
4651
from .tool_source import (
@@ -64,6 +69,54 @@ def normalize_dict(values, keys: List[str]):
6469
values[key] = [{"name": k, **v} for k, v in items.items()]
6570

6671

72+
# Tool ID: lowercase, leading letter, letters/digits/'_'/'-'.
73+
_TOOL_ID_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
74+
TOOL_ID_PATTERN = r"^[a-z][a-z0-9_-]*$"
75+
76+
# Recognized container reference shapes. Kept module-level so future
77+
# admin-configurable code can extend them in place. Docker-Hub-style image
78+
# references allow optional registry path segments and an optional tag.
79+
CONTAINER_PREFIXES: Tuple[str, ...] = ("quay.io/biocontainers/", "docker://", "oras://")
80+
DOCKER_IMAGE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*(/[a-zA-Z0-9._-]+)*(:[\w][\w.-]*)?$")
81+
82+
# Templated ecmascript inside `shell_command` / `configfiles[*].content`.
83+
# Pull every $(<expr>) and extract the *leading* 'inputs.<name>' identifier.
84+
# Only the top-level name is checked, so nested references like
85+
# 'inputs.cond.test_parameter' or 'inputs.repeat[0].x' resolve against
86+
# the conditional / repeat / section's own top-level name -- no false
87+
# positives for nested structures. Computed/aliased references (e.g.
88+
# 'var x = inputs; x.foo') are intentionally not parsed; the goal is
89+
# catching obvious typos cheaply, not modelling ecmascript scope.
90+
_TEMPLATE_BLOCK_RE = re.compile(r"\$\((.*?)\)", re.DOTALL)
91+
_INPUTS_REF_RE = re.compile(r"\binputs\.([A-Za-z_][A-Za-z0-9_]*)")
92+
93+
94+
def _command_input_refs(text: Optional[str]) -> Set[str]:
95+
refs: Set[str] = set()
96+
if not text:
97+
return refs
98+
for block in _TEMPLATE_BLOCK_RE.findall(text):
99+
for match in _INPUTS_REF_RE.findall(block):
100+
refs.add(match)
101+
return refs
102+
103+
104+
def format_validation_errors(exc: ValidationError) -> List[str]:
105+
"""Distill a pydantic ValidationError into a human-readable list.
106+
107+
Each entry is `<dotted.location>: <message>`, or just `<message>` for
108+
model-level errors with no location. Suitable for surfacing directly to
109+
a user (in the agent's bullet list, or as an API 4xx body).
110+
"""
111+
lines: List[str] = []
112+
for err in exc.errors():
113+
loc_parts = [str(p) for p in err.get("loc", ()) if p not in ("__root__",)]
114+
loc = ".".join(loc_parts)
115+
msg = err.get("msg", "validation error")
116+
lines.append(f"{loc}: {msg}" if loc else msg)
117+
return lines
118+
119+
67120
class _DynamicToolSourceBase(ToolSourceBaseModel):
68121
# extra="forbid" rejects unknown top-level keys (e.g. a stray `argument:` at
69122
# the tool level), matching the strict-narrow stance on `inputs`.
@@ -75,17 +128,22 @@ class _DynamicToolSourceBase(ToolSourceBaseModel):
75128
id: Annotated[
76129
Optional[str],
77130
Field(
78-
description="Unique identifier for the tool. Should be all lower-case and should not include whitespace.",
131+
description=(
132+
"Unique identifier for the tool. Lowercase, must start with a letter, "
133+
"may contain letters, digits, '_' and '-'."
134+
),
79135
examples=["my-cool-tool"],
80136
min_length=3,
81137
max_length=255,
138+
pattern=TOOL_ID_PATTERN,
82139
),
83140
] = None
84141
version: Annotated[Optional[str], Field(description="Version for the tool.", examples=["0.1.0"])] = None
85142
name: Annotated[
86143
str,
87144
Field(
88-
description="The name of the tool, displayed in the tool menu. This is not the same as the tool id, which is a unique identifier for the tool."
145+
description="The name of the tool, displayed in the tool menu. This is not the same as the tool id, which is a unique identifier for the tool.",
146+
min_length=5,
89147
),
90148
]
91149
description: Annotated[
@@ -135,13 +193,65 @@ def normalize_items(cls, values):
135193
normalize_dict(values, ["inputs", "outputs"])
136194
return values
137195

196+
@field_validator("name", "version", mode="after")
197+
@classmethod
198+
def _reject_blank_strings(cls, v: Optional[str]) -> Optional[str]:
199+
if v is not None and not v.strip():
200+
raise ValueError("must not be empty or whitespace")
201+
return v
202+
203+
@model_validator(mode="after")
204+
def _check_input_refs_and_outputs(self) -> "_DynamicToolSourceBase":
205+
declared_inputs: Set[str] = {param.root.name for param in self.inputs}
206+
207+
referenced: Set[str] = _command_input_refs(self.shell_command)
208+
for configfile in self.configfiles or []:
209+
referenced |= _command_input_refs(configfile.content)
210+
211+
errors: List[str] = []
212+
for name in sorted(referenced - declared_inputs):
213+
errors.append(f"references inputs.{name} but no input named '{name}' is declared")
214+
215+
for output in self.outputs:
216+
if isinstance(output, IncomingToolOutputDataset):
217+
if not output.from_work_dir and not output.discover_datasets:
218+
errors.append(
219+
f"output '{output.name}' must set 'from_work_dir' or 'discover_datasets' "
220+
"(otherwise its bytes will never be claimed from the working directory)"
221+
)
222+
elif isinstance(output, IncomingToolOutputCollection):
223+
if not output.structure.discover_datasets:
224+
errors.append(
225+
f"output collection '{output.name}' must set 'structure.discover_datasets' "
226+
"(otherwise no elements will be claimed from the working directory)"
227+
)
228+
229+
if errors:
230+
raise ValueError("; ".join(errors))
231+
return self
232+
138233

139234
class UserToolSource(_DynamicToolSourceBase):
140235
class_: Annotated[Literal["GalaxyUserTool"], Field(alias="class")]
141236
container: Annotated[
142237
str, Field(description="Container image to use for this tool.", examples=["quay.io/biocontainers/python:3.13"])
143238
]
144239

240+
@field_validator("container", mode="after")
241+
@classmethod
242+
def _check_container_shape(cls, value: str) -> str:
243+
if not value or not value.strip():
244+
raise ValueError("container must not be empty")
245+
stripped = value.strip()
246+
if stripped.startswith(CONTAINER_PREFIXES):
247+
return value
248+
if DOCKER_IMAGE_RE.match(stripped):
249+
return value
250+
raise ValueError(
251+
f"container '{value}' does not match a recognized shape "
252+
"(quay.io/biocontainers/..., docker://..., oras://..., or <image>[:<tag>])"
253+
)
254+
145255

146256
class YamlToolSource(_DynamicToolSourceBase):
147257
class_: Annotated[Literal["GalaxyTool"], Field(alias="class")]

lib/galaxy/tool_util_models/tool_source.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from enum import Enum
23
from typing import (
34
List,
@@ -8,6 +9,7 @@
89
from pydantic import (
910
ConfigDict,
1011
Field,
12+
model_validator,
1113
with_config,
1214
)
1315
from typing_extensions import (
@@ -142,10 +144,37 @@ class YamlTemplateConfigFile(TemplateConfigFile):
142144
eval_engine: Literal["ecmascript"] = "ecmascript"
143145

144146

147+
# DOI: '10.<registrant>/<suffix>' per Crossref's published shape.
148+
DOI_RE = re.compile(r"^10\.\d{4,9}/.+$")
149+
# BibTeX entries open with '@<type>{' -- e.g. '@article{', '@inproceedings{'.
150+
BIBTEX_RE = re.compile(r"^@[a-zA-Z]+\s*\{", re.MULTILINE)
151+
152+
145153
class Citation(ToolSourceBaseModel):
146154
type: str
147155
content: str
148156

157+
@model_validator(mode="after")
158+
def _check_citation_shape(self) -> "Citation":
159+
content = (self.content or "").strip()
160+
if not content:
161+
raise ValueError("citation content must not be empty")
162+
citation_type = (self.type or "").strip().lower()
163+
if citation_type == "doi":
164+
if not DOI_RE.match(content):
165+
raise ValueError(f"declared as DOI but '{content}' does not match DOI shape (^10\\.\\d{{4,9}}/.+$)")
166+
return self
167+
if citation_type == "bibtex":
168+
if not BIBTEX_RE.search(content):
169+
raise ValueError("declared as bibtex but content does not start with '@<type>{'")
170+
return self
171+
# Type wasn't explicitly doi/bibtex -- accept if the content shape is
172+
# one of the two known forms. Lets a slightly mis-typed entry through
173+
# instead of fighting models that emit type='reference' or similar.
174+
if DOI_RE.match(content) or BIBTEX_RE.search(content):
175+
return self
176+
raise ValueError(f"citation (type={self.type!r}) is neither a recognizable DOI nor a BibTeX entry")
177+
149178

150179
class HelpContent(ToolSourceBaseModel):
151180
format: Literal["restructuredtext", "plain_text", "markdown"]

0 commit comments

Comments
 (0)