From 48cf2903e27b94c74eabbf227e0273fcaa5acb8e Mon Sep 17 00:00:00 2001 From: yashvanthange Date: Sun, 30 Aug 2026 13:44:23 +0530 Subject: [PATCH 1/5] Python: make tool argument-validation errors self-correcting for the model The default argument-validation error returned to the model was the generic "Error: Argument parsing failed.", naming neither the tool nor the offending/missing parameter keys - a model that made a systematic shape error had nothing to correct against and could retry the identical call indefinitely. The default message now names the tool and the offending/missing keys, built from the schema-validation TypeError or a structured summary of the pydantic ValidationError, without raw exception text or an echo of the submitted values. include_detailed_errors keeps its existing behavior of appending the full exception text on top. Fixes #7222 --- .../packages/core/agent_framework/_tools.py | 28 +++++++- .../core/test_function_invocation_logic.py | 72 ++++++++++++++++--- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 1e4089808a..303b64dc34 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1409,6 +1409,32 @@ def normalize_function_invocation_configuration( return normalized +def _format_argument_validation_error(exception: TypeError | ValidationError, tool_name: str) -> str: + """Build a model-oriented summary of an argument-validation failure. + + Unlike a tool-execution exception, the failing data here is the model's own + output and the schema is already in its context as the tool declaration, so the + offending/missing parameter names are safe to surface by default (see #7222): + naming them gives a model that made a systematic shape error something to + correct against, instead of a livelock of identical retries. For the + ``ValidationError`` case this intentionally stays short and structured - no raw + exception repr, no echo of the submitted argument values - so it stays distinct + from ``include_detailed_errors``, which is about developer-oriented detail for + arbitrary tool-execution exceptions. The ``TypeError`` case forwards + ``_validate_arguments_against_schema``'s own message, which already names the + tool and the offending/missing keys (and, for an enum mismatch, the submitted + value - that message is pre-existing and out of scope here). + """ + if isinstance(exception, ValidationError): + offenses = [ + f"{'.'.join(str(segment) for segment in error['loc']) or tool_name} ({error['msg']})" + for error in exception.errors(include_url=False, include_context=False, include_input=False) + ] + detail = "; ".join(offenses) if offenses else str(exception) + return f"Error: invalid arguments for tool '{tool_name}': {detail}." + return f"Error: {exception}" + + def _function_execution_error_result( function_call: Content, tool_name: str, @@ -1532,7 +1558,7 @@ async def _auto_invoke_function( tool_name=tool.name, ) except (TypeError, ValidationError) as exc: - message = "Error: Argument parsing failed." + message = _format_argument_validation_error(exc, tool.name) if config.get("include_detailed_errors", False): message = f"{message} Exception: {exc}" return Content.from_function_result( diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 1d6c70fb39..89ab12a2db 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -2510,12 +2510,13 @@ def typed_func(arg1: int) -> str: # Expects int, not str ) assert error_result.result is not None assert error_result.exception is not None - assert "Argument parsing failed" in error_result.result + assert "invalid arguments for tool 'typed_function'" in error_result.result + assert "arg1" in error_result.result # Offending key named assert "Exception:" in error_result.result # Detailed error included async def test_argument_validation_error_without_detailed_errors(chat_client_base: SupportsChatGetResponse): - """Test that argument validation errors are generic when include_detailed_errors=False.""" + """Test that argument validation errors name the offending key by default, without raw exception text.""" @tool(name="typed_function", approval_mode="never_require") def typed_func(arg1: int) -> str: # Expects int, not str @@ -2540,16 +2541,66 @@ def typed_func(arg1: int) -> str: # Expects int, not str [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [typed_func]} ) - # Should have generic validation error + # Should name the offending key without leaking raw exception text error_result = next( content for msg in response.messages for content in msg.contents if content.type == "function_result" ) assert error_result.result is not None assert error_result.exception is not None - assert "Argument parsing failed" in error_result.result + assert "invalid arguments for tool 'typed_function'" in error_result.result + assert "arg1" in error_result.result # Offending key named even without detailed errors assert "Exception:" not in error_result.result # No detailed error +async def test_schema_supplied_tool_unexpected_key_names_the_key(chat_client_base: SupportsChatGetResponse): + """Regression for #7222: a model that mimics the wrong shape gets a key name to correct against. + + A schema-supplied tool with a missing required field fails argument validation with a ``TypeError`` + (not a pydantic ``ValidationError``), a distinct code path from the coercion-failure tests above. The + default result must still name the tool and the missing key, without requiring + ``include_detailed_errors``, matching the live trace in the issue where the model sent + ``{"items": [...]}`` for a tool declaring ``{"todos": [...]}`` and retried the identical call four + times against the previous generic ``Error: Argument parsing failed.`` message. + """ + + json_schema = { + "type": "object", + "properties": {"todos": {"type": "array"}}, + "required": ["todos"], + "additionalProperties": False, + } + + @tool(name="todos_add", description="Add todos", schema=json_schema, approval_mode="never_require") + def todos_add(todos: list[Any]) -> str: + return f"added {len(todos)}" + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call(call_id="1", name="todos_add", arguments='{"items": [{"id": 4}]}') + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + # Default configuration: include_detailed_errors is False. + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [todos_add]} + ) + + error_result = next( + content for msg in response.messages for content in msg.contents if content.type == "function_result" + ) + assert error_result.result is not None + assert error_result.exception is not None + assert "todos_add" in error_result.result + assert "todos" in error_result.result # missing key named, not just "parsing failed" + assert "Exception:" not in error_result.result # no raw exception text by default + + async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetResponse): """Test handling of approval responses for hosted tools (tools not in tool_map).""" @@ -3471,7 +3522,8 @@ def typed_func(arg1: int) -> str: # Expects int, not str ) assert error_result is not None assert error_result.result is not None - assert "Argument parsing failed" in error_result.result + assert "invalid arguments for tool 'typed_func'" in error_result.result + assert "arg1" in error_result.result # Offending key named async def test_approved_function_call_successful_execution(chat_client_base: SupportsChatGetResponse): @@ -4763,12 +4815,13 @@ def typed_func(arg1: int) -> str: # Expects int, not str ) assert error_result.result is not None assert error_result.exception is not None - assert "Argument parsing failed" in error_result.result + assert "invalid arguments for tool 'typed_function'" in error_result.result + assert "arg1" in error_result.result # Offending key named assert "Exception:" in error_result.result # Detailed error included async def test_streaming_argument_validation_error_without_detailed_errors(chat_client_base: SupportsChatGetResponse): - """Test that argument validation errors are generic when include_detailed_errors=False in streaming mode.""" + """Test that argument validation errors name the offending key by default in streaming mode too.""" @tool(name="typed_function", approval_mode="never_require") def typed_func(arg1: int) -> str: # Expects int, not str @@ -4797,13 +4850,14 @@ def typed_func(arg1: int) -> str: # Expects int, not str ): updates.append(update) - # Should have generic validation error + # Should name the offending key without leaking raw exception text error_result = next( content for update in updates for content in update.contents if content.type == "function_result" ) assert error_result.result is not None assert error_result.exception is not None - assert "Argument parsing failed" in error_result.result + assert "invalid arguments for tool 'typed_function'" in error_result.result + assert "arg1" in error_result.result # Offending key named even without detailed errors assert "Exception:" not in error_result.result # No detailed error From d25df85b398392af72710e420f6c72a46a639f87 Mon Sep 17 00:00:00 2001 From: yashvanthange Date: Sun, 30 Aug 2026 14:04:20 +0530 Subject: [PATCH 2/5] Python: keep validation-error default message value-free and bounded Two follow-ups from Copilot's review on PR #7953: - The schema-supplied TypeError path's enum-mismatch message named the submitted value in its default text, quietly bypassing the "no value echo by default" guarantee the rest of the message does honor. _validate_arguments_against_schema now raises a small _ToolArgumentValidationError carrying a value-free safe_message alongside the full detailed message, and the default result uses the former; include_detailed_errors still surfaces the latter unchanged. - A pydantic ValidationError can carry one entry per invalid item in a large submitted container, so the per-field summary is now capped at 5 entries with an omitted-count suffix, instead of growing unbounded with the size of the model's mistake. --- .../packages/core/agent_framework/_tools.py | 60 ++++++--- .../core/test_function_invocation_logic.py | 118 ++++++++++++++++++ 2 files changed, 163 insertions(+), 15 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 303b64dc34..9af8c51bd6 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1082,6 +1082,21 @@ def _matches_json_schema_type(value: Any, schema_type: str) -> bool: return True +class _ToolArgumentValidationError(TypeError): + """A schema-validation failure with a value-free default message. + + ``str(exception)`` (used when ``include_detailed_errors`` is set) may include the + submitted value for debugging; ``safe_message`` is what a caller shows by default + and never echoes it - see the enum-mismatch case below, the only one of these + checks whose full message names a submitted value rather than only field/tool + names and the schema's own declared constraints. + """ + + def __init__(self, safe_message: str, detailed_message: str | None = None) -> None: + super().__init__(detailed_message or safe_message) + self.safe_message = safe_message + + def _validate_arguments_against_schema( *, arguments: Mapping[str, Any], @@ -1094,13 +1109,17 @@ def _validate_arguments_against_schema( required_fields = [field for field in schema.get("required", []) if isinstance(field, str)] missing_fields = [field for field in required_fields if field not in parsed_arguments] if missing_fields: - raise TypeError(f"Missing required argument(s) for '{tool_name}': {', '.join(sorted(missing_fields))}") + raise _ToolArgumentValidationError( + f"Missing required argument(s) for '{tool_name}': {', '.join(sorted(missing_fields))}" + ) properties: Mapping[str, Any] = schema.get("properties", {}) if schema.get("additionalProperties") is False: unexpected_fields = sorted(field for field in parsed_arguments if field not in properties) if unexpected_fields: - raise TypeError(f"Unexpected argument(s) for '{tool_name}': {', '.join(unexpected_fields)}") + raise _ToolArgumentValidationError( + f"Unexpected argument(s) for '{tool_name}': {', '.join(unexpected_fields)}" + ) for field_name, field_value in parsed_arguments.items(): if not isinstance(properties.get(field_name), dict): @@ -1108,14 +1127,15 @@ def _validate_arguments_against_schema( enum_values = properties.get(field_name, {}).get("enum") if isinstance(enum_values, list) and enum_values and field_value not in enum_values: - raise TypeError( - f"Invalid value for '{field_name}' in '{tool_name}': {field_value!r} is not in {enum_values!r}" + raise _ToolArgumentValidationError( + f"Invalid value for '{field_name}' in '{tool_name}': not one of {enum_values!r}", + f"Invalid value for '{field_name}' in '{tool_name}': {field_value!r} is not in {enum_values!r}", ) schema_type = properties.get(field_name, {}).get("type") if isinstance(schema_type, str): if not _matches_json_schema_type(field_value, schema_type): - raise TypeError( + raise _ToolArgumentValidationError( f"Invalid type for '{field_name}' in '{tool_name}': " f"expected {schema_type}, got {type(field_value).__name__}" ) @@ -1124,7 +1144,7 @@ def _validate_arguments_against_schema( if isinstance(schema_type, list): allowed_types: list[str] = [item for item in schema_type if isinstance(item, str)] # type: ignore[reportUnknownVariableType] if allowed_types and not any(_matches_json_schema_type(field_value, item) for item in allowed_types): - raise TypeError( + raise _ToolArgumentValidationError( f"Invalid type for '{field_name}' in '{tool_name}': expected one of " f"{allowed_types}, got {type(field_value).__name__}" ) @@ -1409,6 +1429,9 @@ def normalize_function_invocation_configuration( return normalized +_MAX_VALIDATION_ERROR_DETAILS = 5 + + def _format_argument_validation_error(exception: TypeError | ValidationError, tool_name: str) -> str: """Build a model-oriented summary of an argument-validation failure. @@ -1416,22 +1439,29 @@ def _format_argument_validation_error(exception: TypeError | ValidationError, to output and the schema is already in its context as the tool declaration, so the offending/missing parameter names are safe to surface by default (see #7222): naming them gives a model that made a systematic shape error something to - correct against, instead of a livelock of identical retries. For the - ``ValidationError`` case this intentionally stays short and structured - no raw - exception repr, no echo of the submitted argument values - so it stays distinct - from ``include_detailed_errors``, which is about developer-oriented detail for - arbitrary tool-execution exceptions. The ``TypeError`` case forwards - ``_validate_arguments_against_schema``'s own message, which already names the - tool and the offending/missing keys (and, for an enum mismatch, the submitted - value - that message is pre-existing and out of scope here). + correct against, instead of a livelock of identical retries. This intentionally + stays short and structured - no raw exception repr, and no echo of the submitted + argument values - so it stays distinct from ``include_detailed_errors``, which is + about developer-oriented detail (including, e.g., the offending value for an + enum mismatch) for arbitrary tool-execution exceptions. + + A ``ValidationError`` can carry one entry per invalid item in a large submitted + container, so the per-field detail is capped to keep this message from itself + becoming an unbounded addition to the next request's context. """ if isinstance(exception, ValidationError): + raw_errors = exception.errors(include_url=False, include_context=False, include_input=False) offenses = [ f"{'.'.join(str(segment) for segment in error['loc']) or tool_name} ({error['msg']})" - for error in exception.errors(include_url=False, include_context=False, include_input=False) + for error in raw_errors[:_MAX_VALIDATION_ERROR_DETAILS] ] detail = "; ".join(offenses) if offenses else str(exception) + omitted = len(raw_errors) - len(offenses) + if omitted > 0: + detail = f"{detail}; ({omitted} more error{'s' if omitted != 1 else ''} omitted)" return f"Error: invalid arguments for tool '{tool_name}': {detail}." + if isinstance(exception, _ToolArgumentValidationError): + return f"Error: {exception.safe_message}" return f"Error: {exception}" diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 89ab12a2db..8bb2b81eed 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -1,10 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import json from collections.abc import AsyncIterable, Awaitable, Callable, Sequence from typing import Any, Literal import pytest +from pydantic import BaseModel from agent_framework import ( Agent, @@ -2601,6 +2603,122 @@ def todos_add(todos: list[Any]) -> str: assert "Exception:" not in error_result.result # no raw exception text by default +async def test_schema_supplied_tool_enum_mismatch_does_not_echo_value_by_default( + chat_client_base: SupportsChatGetResponse, +): + """An enum-mismatch is the one schema-validation failure whose full message names the submitted + value (to say what it wasn't) rather than only field/tool names and the tool's own declared + constraints. That value must stay behind include_detailed_errors, not appear in the default message. + """ + + json_schema = { + "type": "object", + "properties": {"priority": {"type": "string", "enum": ["low", "medium", "high"]}}, + "required": ["priority"], + } + + @tool(name="set_priority", description="Set priority", schema=json_schema, approval_mode="never_require") + def set_priority(priority: str) -> str: + return priority + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="1", name="set_priority", arguments='{"priority": "super-secret-value"}' + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [set_priority]} + ) + + error_result = next( + content for msg in response.messages for content in msg.contents if content.type == "function_result" + ) + assert error_result.result is not None + assert "set_priority" in error_result.result + assert "priority" in error_result.result + assert "super-secret-value" not in error_result.result # submitted value not echoed by default + + # With include_detailed_errors=True, the full message (including the submitted value) is available. + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="1", name="set_priority", arguments='{"priority": "super-secret-value"}' + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + chat_client_base.function_invocation_configuration["include_detailed_errors"] = True # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + + detailed_response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [set_priority]} + ) + detailed_error_result = next( + content for msg in detailed_response.messages for content in msg.contents if content.type == "function_result" + ) + assert detailed_error_result.result is not None + assert "super-secret-value" in detailed_error_result.result + + +async def test_pydantic_validation_error_caps_reported_field_count(chat_client_base: SupportsChatGetResponse): + """Regression: a large invalid container must not turn the error result into an unbounded message. + + A pydantic ``ValidationError`` can carry one entry per invalid item, so an oversized submitted list + must not be echoed back to the model error-by-error - the summary is capped and reports an omitted + count instead of growing without bound. + """ + + class Item(BaseModel): + title: str + + @tool(name="typed_list_tool", approval_mode="never_require") + def typed_list_tool(items: list[Item]) -> str: + return f"got {len(items)}" + + # More invalid items than the cap, each missing the required 'title' field. + bad_items = [{"wrong_key": i} for i in range(20)] + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="1", name="typed_list_tool", arguments=json.dumps({"items": bad_items}) + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [typed_list_tool]} + ) + + error_result = next( + content for msg in response.messages for content in msg.contents if content.type == "function_result" + ) + assert error_result.result is not None + assert "typed_list_tool" in error_result.result + assert "more error" in error_result.result # omitted-count marker present + # The message should not grow one entry per invalid item; it stays well under a per-item accounting + # of 20 items. + assert error_result.result.count("title") < 10 + + async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetResponse): """Test handling of approval responses for hosted tools (tools not in tool_map).""" From d8a6b15316c78ec519555ae0be0b6c4c99112410 Mon Sep 17 00:00:00 2001 From: yashvanthange Date: Sun, 30 Aug 2026 22:57:08 +0530 Subject: [PATCH 3/5] Map the argument-validation-error fix into the function-loop spec Per python/AGENTS.md, changes to the function-calling loop's error paths must be represented in docs/specs/004-python-function-calling-loop.md's scenario matrix. Adds the "Actionable validation-error message" row covering the new value-safe, capped default message and its regression tests, and lists #7222 under related issues. --- docs/specs/004-python-function-calling-loop.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 85e9e0c657..4b7d337cee 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -520,6 +520,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Rejected execution | Rejection is a normal terminal result, not an exception to the caller. | `test_unapproved_tool_execution_raises_exception` | | Approved tool exception | Generic and detailed error modes preserve one result and one execution. | `test_approved_function_call_with_error_without_detailed_errors`, `test_approved_function_call_with_error_with_detailed_errors` | | Approved validation error | Validation failure returns one result without invoking the function body. | `test_approved_function_call_with_validation_error` | +| Actionable validation-error message | The default (non-detailed) argument-validation error names the tool and the offending/missing parameter key(s) — from the schema-validation `TypeError` or a structured `ValidationError` summary — without raw exception text or an echo of the submitted argument value; `include_detailed_errors` still appends the full exception text on top. A `ValidationError`'s per-field detail is capped with an omitted-count suffix so a large invalid container cannot produce an unbounded message. | `test_argument_validation_error_with_detailed_errors`, `test_argument_validation_error_without_detailed_errors`, `test_streaming_argument_validation_error_with_detailed_errors`, `test_streaming_argument_validation_error_without_detailed_errors`, `test_schema_supplied_tool_unexpected_key_names_the_key`, `test_schema_supplied_tool_enum_mismatch_does_not_echo_value_by_default`, `test_pydantic_validation_error_caps_reported_field_count` | | Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` | | Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` | | Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents | @@ -627,5 +628,6 @@ Before accepting an update, reviewers must confirm: - #6851 — duplicate side effects after approval continuation - #7383 — bind approval responses to framework-issued requests after this foundation merges - #6963 / #7095 — opaque reasoning-signature replay +- #7222 — actionable, value-safe default argument-validation error message - #6074 / #7233 — reasoning-paired tool-call replay - #6450 / #6794 — provider message and tool-result serialization From 231f1242ab5c5d172eafc0e1f4e810cc779ad089 Mon Sep 17 00:00:00 2001 From: yashvanthange Date: Mon, 31 Aug 2026 09:46:04 +0530 Subject: [PATCH 4/5] Python: cap unexpected/missing key lists, drop unvetted TypeError text Two follow-ups from PR #7953 review: - @moonbox3: a schema-supplied tool's missing/unexpected key list had no cap, so a model submitting an object with many unexpected keys could make the default error result itself unbounded. _validate_arguments_ against_schema now formats both lists through a shared _format_field_list helper with the same omitted-count cap already applied to pydantic ValidationError details. - Copilot (suppressed finding): the fallback branch in _format_argument_validation_error forwarded str(exception) for any TypeError that isn't the framework's own vetted _ToolArgumentValidationError, which would leak unvetted exception text by default if one ever reached that point. It now returns a generic, tool-named message instead; the raw text still surfaces via include_detailed_errors like any other exception. Spec doc and its scenario-to-test mapping updated to match. --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 31 +++++++++--- .../core/test_function_invocation_logic.py | 47 +++++++++++++++++++ python/packages/core/tests/core/test_tools.py | 17 +++++++ 4 files changed, 90 insertions(+), 7 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 4b7d337cee..250fa2b5e7 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -520,7 +520,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Rejected execution | Rejection is a normal terminal result, not an exception to the caller. | `test_unapproved_tool_execution_raises_exception` | | Approved tool exception | Generic and detailed error modes preserve one result and one execution. | `test_approved_function_call_with_error_without_detailed_errors`, `test_approved_function_call_with_error_with_detailed_errors` | | Approved validation error | Validation failure returns one result without invoking the function body. | `test_approved_function_call_with_validation_error` | -| Actionable validation-error message | The default (non-detailed) argument-validation error names the tool and the offending/missing parameter key(s) — from the schema-validation `TypeError` or a structured `ValidationError` summary — without raw exception text or an echo of the submitted argument value; `include_detailed_errors` still appends the full exception text on top. A `ValidationError`'s per-field detail is capped with an omitted-count suffix so a large invalid container cannot produce an unbounded message. | `test_argument_validation_error_with_detailed_errors`, `test_argument_validation_error_without_detailed_errors`, `test_streaming_argument_validation_error_with_detailed_errors`, `test_streaming_argument_validation_error_without_detailed_errors`, `test_schema_supplied_tool_unexpected_key_names_the_key`, `test_schema_supplied_tool_enum_mismatch_does_not_echo_value_by_default`, `test_pydantic_validation_error_caps_reported_field_count` | +| Actionable validation-error message | The default (non-detailed) argument-validation error names the tool and the offending/missing parameter key(s) — from the schema-validation `TypeError` or a structured `ValidationError` summary — without raw exception text or an echo of the submitted argument value; `include_detailed_errors` still appends the full exception text on top. A `ValidationError`'s per-field detail and a schema-supplied tool's missing/unexpected key list are each capped with an omitted-count suffix, so neither a large invalid container nor a submission with many unexpected keys can produce an unbounded message. An unrecognized `TypeError` (anything other than the framework's own vetted validation error) falls back to a generic, tool-named default rather than forwarding unvetted exception text. | `test_argument_validation_error_with_detailed_errors`, `test_argument_validation_error_without_detailed_errors`, `test_streaming_argument_validation_error_with_detailed_errors`, `test_streaming_argument_validation_error_without_detailed_errors`, `test_schema_supplied_tool_unexpected_key_names_the_key`, `test_schema_supplied_tool_enum_mismatch_does_not_echo_value_by_default`, `test_pydantic_validation_error_caps_reported_field_count`, `test_schema_supplied_tool_caps_unexpected_key_count`, `test_format_argument_validation_error_unvetted_type_error_falls_back_to_generic_message` | | Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` | | Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` | | Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 9af8c51bd6..0d830ba745 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1082,6 +1082,9 @@ def _matches_json_schema_type(value: Any, schema_type: str) -> bool: return True +_MAX_VALIDATION_ERROR_DETAILS = 5 + + class _ToolArgumentValidationError(TypeError): """A schema-validation failure with a value-free default message. @@ -1097,6 +1100,19 @@ def __init__(self, safe_message: str, detailed_message: str | None = None) -> No self.safe_message = safe_message +def _format_field_list(fields: Sequence[str], *, limit: int = _MAX_VALIDATION_ERROR_DETAILS) -> str: + """Join field names for a default error message, capped against a model submitting arbitrarily many. + + Field/key *names* are safe to list in full (they are structural, not the value being validated), but + their *count* is attacker/model-controlled: a submitted object with thousands of unexpected keys must + not turn the error result itself into an unbounded addition to the next request's context. + """ + shown = fields[:limit] + text = ", ".join(shown) + omitted = len(fields) - len(shown) + return f"{text} (+{omitted} more)" if omitted > 0 else text + + def _validate_arguments_against_schema( *, arguments: Mapping[str, Any], @@ -1110,7 +1126,7 @@ def _validate_arguments_against_schema( missing_fields = [field for field in required_fields if field not in parsed_arguments] if missing_fields: raise _ToolArgumentValidationError( - f"Missing required argument(s) for '{tool_name}': {', '.join(sorted(missing_fields))}" + f"Missing required argument(s) for '{tool_name}': {_format_field_list(sorted(missing_fields))}" ) properties: Mapping[str, Any] = schema.get("properties", {}) @@ -1118,7 +1134,7 @@ def _validate_arguments_against_schema( unexpected_fields = sorted(field for field in parsed_arguments if field not in properties) if unexpected_fields: raise _ToolArgumentValidationError( - f"Unexpected argument(s) for '{tool_name}': {', '.join(unexpected_fields)}" + f"Unexpected argument(s) for '{tool_name}': {_format_field_list(unexpected_fields)}" ) for field_name, field_value in parsed_arguments.items(): @@ -1429,9 +1445,6 @@ def normalize_function_invocation_configuration( return normalized -_MAX_VALIDATION_ERROR_DETAILS = 5 - - def _format_argument_validation_error(exception: TypeError | ValidationError, tool_name: str) -> str: """Build a model-oriented summary of an argument-validation failure. @@ -1448,6 +1461,12 @@ def _format_argument_validation_error(exception: TypeError | ValidationError, to A ``ValidationError`` can carry one entry per invalid item in a large submitted container, so the per-field detail is capped to keep this message from itself becoming an unbounded addition to the next request's context. + + Only ``_ToolArgumentValidationError`` has a message vetted to be value-free; a + plain ``TypeError`` reaching this point is not one this function recognizes; its + text is not known to be safe, so the default falls back to a generic, tool-named + summary and the raw text stays behind ``include_detailed_errors`` like any other + unvetted exception. """ if isinstance(exception, ValidationError): raw_errors = exception.errors(include_url=False, include_context=False, include_input=False) @@ -1462,7 +1481,7 @@ def _format_argument_validation_error(exception: TypeError | ValidationError, to return f"Error: invalid arguments for tool '{tool_name}': {detail}." if isinstance(exception, _ToolArgumentValidationError): return f"Error: {exception.safe_message}" - return f"Error: {exception}" + return f"Error: invalid arguments for tool '{tool_name}'." def _function_execution_error_result( diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 8bb2b81eed..e4f5111c02 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -2719,6 +2719,53 @@ def typed_list_tool(items: list[Item]) -> str: assert error_result.result.count("title") < 10 +async def test_schema_supplied_tool_caps_unexpected_key_count(chat_client_base: SupportsChatGetResponse): + """Regression: a model submitting thousands of unexpected keys must not produce an unbounded result. + + Unlike the pydantic-error cap above, this exercises the schema-supplied TypeError path + (`additionalProperties: False`), reported by @moonbox3 on PR #7953: `_validate_arguments_against_schema` + joined every unexpected key into the default message with no cap, so a large hallucinated object could + exceed the next request's context budget instead of giving the model a short summary to correct against. + """ + + json_schema = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": False, + } + + @tool(name="search", description="Search tool", schema=json_schema, approval_mode="never_require") + def search(query: str) -> str: + return query + + bad_arguments = {"query": "hello", **{f"extra_key_{i}": i for i in range(50)}} + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[Content.from_function_call(call_id="1", name="search", arguments=json.dumps(bad_arguments))], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [search]} + ) + + error_result = next( + content for msg in response.messages for content in msg.contents if content.type == "function_result" + ) + assert error_result.result is not None + assert "search" in error_result.result + assert "extra_key_0" in error_result.result + assert "more" in error_result.result # omitted-count marker present + # The message should not grow one entry per unexpected key; it stays well under a per-key accounting + # of 50 keys. + assert error_result.result.count("extra_key_") < 10 + + async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetResponse): """Test handling of approval responses for hosted tools (tools not in tool_map).""" diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 33fad82ddc..d68d2e42af 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -18,6 +18,7 @@ from agent_framework._middleware import FunctionInvocationContext from agent_framework._tools import ( _auto_invoke_function, + _format_argument_validation_error, _parse_annotation, _parse_inputs, normalize_function_invocation_configuration, @@ -153,6 +154,22 @@ def search(query: str) -> str: await search.invoke(arguments={}) +def test_format_argument_validation_error_unvetted_type_error_falls_back_to_generic_message(): + """A plain TypeError (not _ToolArgumentValidationError) has no message vetted as value-free. + + _format_argument_validation_error only trusts _ToolArgumentValidationError.safe_message and the + structured ValidationError summary to be free of submitted data; any other TypeError reaching it must + get a generic, tool-named default instead of forwarding str(exception) verbatim, since that text's + origin (and therefore its safety) is unknown here. + """ + exc = TypeError("some internal detail: submitted-secret-value") + + message = _format_argument_validation_error(exc, "mytool") + + assert message == "Error: invalid arguments for tool 'mytool'." + assert "submitted-secret-value" not in message + + async def test_invoke_preserves_explicit_null_argument(): """A required nullable argument the model sets to null must reach the function. From 90eae5301402d135b83a6be97739fe4e671ac537 Mon Sep 17 00:00:00 2001 From: yashvanthange Date: Mon, 31 Aug 2026 15:09:53 +0530 Subject: [PATCH 5/5] Python: redact caller-chosen dict keys from validation-error loc Fixes a real value leak in the shipped code, not just a correction of something already said: a dict-typed field's invalid entry puts the caller's own key verbatim in pydantic's ValidationError.loc (confirmed: {"mapping": {"secret-abc": "bad"}} puts 'secret-abc' straight into loc), and the default message rendered loc as-is. In review I'd argued this was "the same category as the unexpected/missing field names this PR already surfaces by design" - that was wrong. A dict key is data the caller chose; a declared field name is static shape from the tool's own schema. Confirmed end to end: before this fix, the default (non-detailed) message echoed the submitted key; after, it shows a fixed placeholder. _redact_dynamic_loc_segments walks a ValidationError's loc against the tool's own JSON schema (resolving $ref/$defs for nested models): a segment is named only when the schema declares it as a properties key or a list index; a segment indexing into a dict-typed field's additionalProperties is replaced with a placeholder instead of echoed. Declared field names, including nested and list-indexed ones, are unaffected - verified with a companion test so the fix doesn't over-redact the tool's own static shape, which is what makes the default message actionable in the first place. --- .../specs/004-python-function-calling-loop.md | 2 +- .../packages/core/agent_framework/_tools.py | 51 +++++++++- .../core/test_function_invocation_logic.py | 98 +++++++++++++++++++ python/packages/core/tests/core/test_tools.py | 2 +- 4 files changed, 147 insertions(+), 6 deletions(-) diff --git a/docs/specs/004-python-function-calling-loop.md b/docs/specs/004-python-function-calling-loop.md index 250fa2b5e7..408187d9a4 100644 --- a/docs/specs/004-python-function-calling-loop.md +++ b/docs/specs/004-python-function-calling-loop.md @@ -520,7 +520,7 @@ that manually replay messages own the equivalent rule: do not resend an approval | Rejected execution | Rejection is a normal terminal result, not an exception to the caller. | `test_unapproved_tool_execution_raises_exception` | | Approved tool exception | Generic and detailed error modes preserve one result and one execution. | `test_approved_function_call_with_error_without_detailed_errors`, `test_approved_function_call_with_error_with_detailed_errors` | | Approved validation error | Validation failure returns one result without invoking the function body. | `test_approved_function_call_with_validation_error` | -| Actionable validation-error message | The default (non-detailed) argument-validation error names the tool and the offending/missing parameter key(s) — from the schema-validation `TypeError` or a structured `ValidationError` summary — without raw exception text or an echo of the submitted argument value; `include_detailed_errors` still appends the full exception text on top. A `ValidationError`'s per-field detail and a schema-supplied tool's missing/unexpected key list are each capped with an omitted-count suffix, so neither a large invalid container nor a submission with many unexpected keys can produce an unbounded message. An unrecognized `TypeError` (anything other than the framework's own vetted validation error) falls back to a generic, tool-named default rather than forwarding unvetted exception text. | `test_argument_validation_error_with_detailed_errors`, `test_argument_validation_error_without_detailed_errors`, `test_streaming_argument_validation_error_with_detailed_errors`, `test_streaming_argument_validation_error_without_detailed_errors`, `test_schema_supplied_tool_unexpected_key_names_the_key`, `test_schema_supplied_tool_enum_mismatch_does_not_echo_value_by_default`, `test_pydantic_validation_error_caps_reported_field_count`, `test_schema_supplied_tool_caps_unexpected_key_count`, `test_format_argument_validation_error_unvetted_type_error_falls_back_to_generic_message` | +| Actionable validation-error message | The default (non-detailed) argument-validation error names the tool and the offending/missing parameter key(s) — from the schema-validation `TypeError` or a structured `ValidationError` summary — without raw exception text or an echo of the submitted argument value; `include_detailed_errors` still appends the full exception text on top. A `ValidationError`'s per-field detail and a schema-supplied tool's missing/unexpected key list are each capped with an omitted-count suffix, so neither a large invalid container nor a submission with many unexpected keys can produce an unbounded message. An unrecognized `TypeError` (anything other than the framework's own vetted validation error) falls back to a generic, tool-named default rather than forwarding unvetted exception text. A `ValidationError`'s `loc` is resolved against the tool's own schema (`$ref`/`$defs` included): a segment is named only when the schema declares it as a field or list index; a segment indexing into a dict-typed field is data the caller chose, not part of the tool's declared shape, and is redacted to a fixed placeholder rather than echoed. | `test_argument_validation_error_with_detailed_errors`, `test_argument_validation_error_without_detailed_errors`, `test_streaming_argument_validation_error_with_detailed_errors`, `test_streaming_argument_validation_error_without_detailed_errors`, `test_schema_supplied_tool_unexpected_key_names_the_key`, `test_schema_supplied_tool_enum_mismatch_does_not_echo_value_by_default`, `test_pydantic_validation_error_caps_reported_field_count`, `test_schema_supplied_tool_caps_unexpected_key_count`, `test_format_argument_validation_error_unvetted_type_error_falls_back_to_generic_message`, `test_pydantic_validation_error_does_not_echo_a_dict_key`, `test_pydantic_validation_error_still_names_nested_and_list_field_paths` | | Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` | | Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` | | Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 0d830ba745..8f629a85c4 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -1445,7 +1445,47 @@ def normalize_function_invocation_configuration( return normalized -def _format_argument_validation_error(exception: TypeError | ValidationError, tool_name: str) -> str: +def _redact_dynamic_loc_segments(loc: tuple[int | str, ...], schema: Mapping[str, Any]) -> str: + """Render a pydantic error ``loc`` as a path, without echoing a caller-chosen dict key. + + A ``loc`` segment is safe to show only when the tool's own schema names it: a declared + ``properties`` key, or a list index (positional, not data). A segment that instead indexes into a + dict-typed field (``additionalProperties``) is a key the caller supplied - the exact kind of value + this default message exists to never echo (see #7222 and the review on #7953) - so it is replaced + with a fixed placeholder instead of the submitted string. ``$ref``/``$defs`` are resolved so a + nested model's own declared field names are still recognized and shown, not just top-level ones. + """ + defs: dict[str, Any] = schema.get("$defs", {}) if isinstance(schema, dict) else {} + + def resolve(node: Any) -> dict[str, Any]: + if not isinstance(node, dict): + # A schema fragment can legitimately be a non-dict, e.g. `additionalProperties: False`; + # treat anything not shaped like a schema object as having no further declared fields. + return {} + typed_node = cast(dict[str, Any], node) + ref = typed_node.get("$ref") + return resolve(defs.get(ref.rsplit("/", 1)[-1], {})) if isinstance(ref, str) else typed_node + + node: dict[str, Any] = resolve(schema) + parts: list[str] = [] + for segment in loc: + if isinstance(segment, int): + parts.append(f"[{segment}]") + node = resolve(node.get("items")) + continue + properties: dict[str, Any] = node.get("properties", {}) if isinstance(node.get("properties"), dict) else {} + if segment in properties: + parts.append(f".{segment}" if parts else str(segment)) + node = resolve(properties[segment]) + else: + parts.append("." if parts else "") + node = resolve(node.get("additionalProperties")) + return "".join(parts) if parts else "value" + + +def _format_argument_validation_error( + exception: TypeError | ValidationError, tool_name: str, schema: Mapping[str, Any] +) -> str: """Build a model-oriented summary of an argument-validation failure. Unlike a tool-execution exception, the failing data here is the model's own @@ -1460,7 +1500,10 @@ def _format_argument_validation_error(exception: TypeError | ValidationError, to A ``ValidationError`` can carry one entry per invalid item in a large submitted container, so the per-field detail is capped to keep this message from itself - becoming an unbounded addition to the next request's context. + becoming an unbounded addition to the next request's context. Each entry's + location is rendered through :func:`_redact_dynamic_loc_segments`, which shows a + field name only when the schema itself declares it - a dict-typed field's key is + the caller's own data, not part of the tool's declared shape, and is redacted. Only ``_ToolArgumentValidationError`` has a message vetted to be value-free; a plain ``TypeError`` reaching this point is not one this function recognizes; its @@ -1471,7 +1514,7 @@ def _format_argument_validation_error(exception: TypeError | ValidationError, to if isinstance(exception, ValidationError): raw_errors = exception.errors(include_url=False, include_context=False, include_input=False) offenses = [ - f"{'.'.join(str(segment) for segment in error['loc']) or tool_name} ({error['msg']})" + f"{_redact_dynamic_loc_segments(error['loc'], schema) or tool_name} ({error['msg']})" for error in raw_errors[:_MAX_VALIDATION_ERROR_DETAILS] ] detail = "; ".join(offenses) if offenses else str(exception) @@ -1607,7 +1650,7 @@ async def _auto_invoke_function( tool_name=tool.name, ) except (TypeError, ValidationError) as exc: - message = _format_argument_validation_error(exc, tool.name) + message = _format_argument_validation_error(exc, tool.name, tool.parameters()) if config.get("include_detailed_errors", False): message = f"{message} Exception: {exc}" return Content.from_function_result( diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index e4f5111c02..a25a873bb9 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -2766,6 +2766,104 @@ def search(query: str) -> str: assert error_result.result.count("extra_key_") < 10 +async def test_pydantic_validation_error_does_not_echo_a_dict_key(chat_client_base: SupportsChatGetResponse): + """Regression for the review on #7953: a dict-typed field's key is caller data, not a field name. + + Copilot flagged that `include_input=False` only strips pydantic's `input` field - it does not stop + a dict-typed field's own key from appearing in `loc`, since pydantic reports the key as part of the + error's location rather than its input. A model submitting `{"mapping": {"": "bad"}}` would + put `` straight into the default (non-detailed) message. This was dismissed in review as + "the same category as the unexpected/missing field names this PR already surfaces by design" - + that reasoning was wrong: a dict key is data the caller chose, not a field name the tool declared. + """ + + class MappingInput(BaseModel): + mapping: dict[str, int] + + @tool(name="store_tool", schema=MappingInput, approval_mode="never_require") + def store_tool(mapping: dict[str, int]) -> str: + return str(mapping) + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="1", + name="store_tool", + arguments=json.dumps({"mapping": {"super-secret-api-key-abc123": "not-an-int"}}), + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [store_tool]} + ) + + error_result = next( + content for msg in response.messages for content in msg.contents if content.type == "function_result" + ) + assert error_result.result is not None + assert "super-secret-api-key-abc123" not in error_result.result # the caller-chosen key is not echoed + assert "store_tool" in error_result.result + assert "mapping" in error_result.result # the declared field name is still named + + +async def test_pydantic_validation_error_still_names_nested_and_list_field_paths( + chat_client_base: SupportsChatGetResponse, +): + """Companion to the dict-key redaction above: declared field names, at any depth, must still show. + + Redacting a caller-chosen dict key must not turn into over-redaction of the tool's own declared + shape - a nested model's field name and a list index are static structure from the schema, not + caller data, and are exactly what makes the default message actionable in the first place. + """ + + class Item(BaseModel): + title: str + + class NestedInput(BaseModel): + item: Item + items: list[Item] + + @tool(name="nested_tool", schema=NestedInput, approval_mode="never_require") + def nested_tool(item: Item, items: list[Item]) -> str: + return "ok" + + chat_client_base.run_responses = [ # type: ignore[attr-defined] # ty: ignore[unresolved-attribute] + ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="1", + name="nested_tool", + arguments=json.dumps({"item": {"title": 123}, "items": [{"title": 456}]}), + ) + ], + ) + ), + ChatResponse(messages=Message(role="assistant", contents=["done"])), + ] + + response = await chat_client_base.get_response( + [Message(role="user", contents=["hello"])], options={"tool_choice": "auto", "tools": [nested_tool]} + ) + + error_result = next( + content for msg in response.messages for content in msg.contents if content.type == "function_result" + ) + assert error_result.result is not None + # Both the nested field's own path and the list-indexed field's path are declared shape, not + # caller-chosen data, and must still be named rather than redacted to "". + assert "item.title" in error_result.result + assert "items[0].title" in error_result.result + + async def test_hosted_tool_approval_response(chat_client_base: SupportsChatGetResponse): """Test handling of approval responses for hosted tools (tools not in tool_map).""" diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index d68d2e42af..81fad7dadf 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -164,7 +164,7 @@ def test_format_argument_validation_error_unvetted_type_error_falls_back_to_gene """ exc = TypeError("some internal detail: submitted-secret-value") - message = _format_argument_validation_error(exc, "mytool") + message = _format_argument_validation_error(exc, "mytool", {}) assert message == "Error: invalid arguments for tool 'mytool'." assert "submitted-secret-value" not in message