Skip to content

Commit 044263a

Browse files
idryzhovCopilot
andcommitted
Surface Pydantic ValidationError to LLM in tool arg validation
Tool-argument validation failures raised as pydantic ValidationError are now returned to the model as a clean, actionable message built from each error's loc and msg, instead of the generic redacted text. All other exceptions stay fully redacted. The ValidationError handler is scoped to only the ptype.model_validate(args) call that deserializes the tool arguments, so a ValidationError raised from within a handler body is not surfaced and stays redacted by the broad fallback like any other exception. Safe to surface: the invalid values are arguments the LLM itself supplied, and validator messages are authored by tool developers. The user-facing text is assembled from only loc + msg; the full str(exc) (including raw input) is kept in the debug-only error field. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1d89120 commit 044263a

2 files changed

Lines changed: 102 additions & 3 deletions

File tree

python/copilot/tools.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from dataclasses import dataclass, field
1414
from typing import Any, Literal, TypeVar, get_type_hints, overload
1515

16-
from pydantic import BaseModel
16+
from pydantic import BaseModel, ValidationError
1717

1818
ToolResultType = Literal["success", "failure", "rejected", "denied", "timeout"]
1919

@@ -211,7 +211,21 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult:
211211
if takes_params:
212212
args = invocation.arguments or {}
213213
if ptype is not None and _is_pydantic_model(ptype):
214-
call_args.append(ptype.model_validate(args))
214+
try:
215+
call_args.append(ptype.model_validate(args))
216+
except ValidationError as exc:
217+
# Highlight input validation problems to the LLM.
218+
parts = []
219+
for err in exc.errors():
220+
loc = ".".join(map(str, err["loc"]))
221+
msg = err["msg"]
222+
parts.append(f"{loc}: {msg}" if loc else msg)
223+
return ToolResult(
224+
text_result_for_llm="Invalid tool arguments:\n" + "\n".join(parts),
225+
result_type="failure",
226+
error=str(exc),
227+
tool_telemetry={},
228+
)
215229
else:
216230
call_args.append(args)
217231
if takes_invocation:

python/test_tools.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import json
44

55
import pytest
6-
from pydantic import BaseModel, Field
6+
from pydantic import BaseModel, ConfigDict, Field, field_validator
77

88
from copilot import define_tool
99
from copilot.tools import (
@@ -197,6 +197,91 @@ def failing_tool(params: Params, invocation: ToolInvocation) -> str:
197197
# But the actual error is stored internally
198198
assert result.error == "secret error message"
199199

200+
async def test_validation_error_is_surfaced_to_llm(self):
201+
class Params(BaseModel):
202+
username: str
203+
204+
@field_validator("username")
205+
@classmethod
206+
def check_username(cls, v: str) -> str:
207+
if v == "admin":
208+
raise ValueError("username 'admin' is reserved")
209+
return v
210+
211+
@define_tool("validate", description="A validating tool")
212+
def validating_tool(params: Params) -> str:
213+
return "ok"
214+
215+
invocation = ToolInvocation(
216+
session_id="s1",
217+
tool_call_id="c1",
218+
tool_name="validate",
219+
arguments={"username": "admin"},
220+
)
221+
222+
result = await validating_tool.handler(invocation)
223+
224+
assert result.result_type == "failure"
225+
assert result.text_result_for_llm.startswith("Invalid tool arguments:")
226+
assert "username 'admin' is reserved" in result.text_result_for_llm
227+
# Full detail is retained in the debug field.
228+
assert result.error is not None
229+
230+
async def test_validation_error_extra_forbid_includes_field_name(self):
231+
class Params(BaseModel):
232+
model_config = ConfigDict(extra="forbid")
233+
234+
request: str
235+
236+
@define_tool("strict", description="A strict tool")
237+
def strict_tool(params: Params) -> str:
238+
return "ok"
239+
240+
invocation = ToolInvocation(
241+
session_id="s1",
242+
tool_call_id="c1",
243+
tool_name="strict",
244+
arguments={"request": "ok", "extra_field": "unexpected"},
245+
)
246+
247+
result = await strict_tool.handler(invocation)
248+
249+
assert result.result_type == "failure"
250+
assert result.text_result_for_llm.startswith("Invalid tool arguments:")
251+
# The offending key name is carried in `loc` even though the generic
252+
# message is "Extra inputs are not permitted".
253+
assert "extra_field" in result.text_result_for_llm
254+
assert result.error is not None
255+
256+
async def test_validation_error_from_handler_body_is_redacted(self):
257+
class Params(BaseModel):
258+
pass
259+
260+
class Internal(BaseModel):
261+
count: int
262+
263+
@define_tool("body", description="A tool that validates internally")
264+
def body_tool(params: Params) -> str:
265+
Internal.model_validate({"count": "secret-not-an-int"})
266+
return "ok"
267+
268+
invocation = ToolInvocation(
269+
session_id="s1",
270+
tool_call_id="c1",
271+
tool_name="body",
272+
arguments={},
273+
)
274+
275+
result = await body_tool.handler(invocation)
276+
277+
assert result.result_type == "failure"
278+
# A ValidationError from the handler body must not be surfaced as an
279+
# argument-validation error; it stays redacted like any other exception.
280+
assert not result.text_result_for_llm.startswith("Invalid tool arguments:")
281+
assert "secret-not-an-int" not in result.text_result_for_llm
282+
assert "error" in result.text_result_for_llm.lower()
283+
assert result.error is not None
284+
200285
async def test_function_style_api(self):
201286
class Params(BaseModel):
202287
value: str

0 commit comments

Comments
 (0)