Skip to content

Commit ecb8e1e

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 except branch lives here because tool arguments are deserialized via ptype.model_validate(args) just above in the same try block, so this is where the ValidationError originates and is the right place to catch it, ahead of the generic redaction fallback. 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 ecb8e1e

2 files changed

Lines changed: 73 additions & 2 deletions

File tree

python/copilot/tools.py

Lines changed: 16 additions & 1 deletion
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

@@ -224,6 +224,21 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult:
224224

225225
return _normalize_result(result)
226226

227+
except ValidationError as exc:
228+
# Safe to surface: the invalid values are arguments the LLM itself
229+
# supplied, and validator messages are authored by tool developers.
230+
parts = []
231+
for err in exc.errors():
232+
loc = ".".join(map(str, err["loc"]))
233+
msg = err["msg"]
234+
parts.append(f"{loc}: {msg}" if loc else msg)
235+
return ToolResult(
236+
text_result_for_llm="Invalid tool arguments:\n" + "\n".join(parts),
237+
result_type="failure",
238+
error=str(exc),
239+
tool_telemetry={},
240+
)
241+
227242
except Exception as exc:
228243
# Don't expose detailed error information to the LLM for security reasons.
229244
# The actual error is stored in the 'error' field for debugging.

python/test_tools.py

Lines changed: 57 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,62 @@ 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+
200256
async def test_function_style_api(self):
201257
class Params(BaseModel):
202258
value: str

0 commit comments

Comments
 (0)