tools/chat: return shape errors for mis-typed ask-family arguments

This commit is contained in:
2026-09-19 09:37:25 +08:00
parent 55b25b5068
commit 86d90bfeb1
5 changed files with 181 additions and 3 deletions
+7
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from plyngent.agent import ToolTag, tool
from plyngent.prompting import NonInteractiveError, ask_async
from plyngent.tools.chat.shape import first_error, string_error
@tool(name="ask_user_line", tags=ToolTag.LOCAL | ToolTag.READ_ONLY)
@@ -12,6 +13,12 @@ async def ask_user(question: str, default: str = "") -> str:
or any input that is not a fixed menu. Optional ``default`` is used if the
user submits empty input (and in non-interactive mode when provided).
"""
shape_error = first_error(
string_error("ask_user_line", "question", question),
string_error("ask_user_line", "default", default),
)
if shape_error is not None:
return f"error: {shape_error}"
try:
return await ask_async(question, default=default or None)
except NonInteractiveError as exc:
+14
View File
@@ -4,6 +4,7 @@ from typing import cast
from plyngent.agent import ToolTag, tool
from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async
from plyngent.tools.chat.shape import first_error, list_error, string_error
def parse_options(options: list[object]) -> list[ChoiceOption]:
@@ -51,6 +52,19 @@ async def choose_user(
When ``allow_custom`` is true (default), free-text answers are accepted.
Returns the chosen option value (or custom text).
"""
shape_error = first_error(
string_error("ask_user_choice", "question", question),
list_error(
"ask_user_choice",
"options",
options,
items="strings or objects with a label",
example='["alpha", "beta"]',
),
string_error("ask_user_choice", "default", default),
)
if shape_error is not None:
return f"error: {shape_error}"
try:
parsed = parse_options([cast("object", o) for o in options])
except (TypeError, ValueError) as exc:
+22 -3
View File
@@ -1,21 +1,28 @@
from __future__ import annotations
import json
from typing import cast
from typing import TYPE_CHECKING, cast
from plyngent.agent import ToolTag, tool
from plyngent.prompting import FormField, NonInteractiveError, form_async
from plyngent.tools.chat.choose import parse_options
from plyngent.tools.chat.shape import first_error, list_error, string_error
if TYPE_CHECKING:
from collections.abc import Sequence
def parse_fields(fields: list[dict[str, object]]) -> list[FormField]:
def parse_fields(fields: Sequence[object]) -> list[FormField]:
"""Normalize a list of field dicts into ``FormField`` list."""
if not fields:
msg = "fields must be a non-empty list"
raise ValueError(msg)
out: list[FormField] = []
for item_obj in fields:
raw_map = {str(key): value for key, value in item_obj.items()}
if not isinstance(item_obj, dict):
msg = "each field must be an object with name/prompt"
raise TypeError(msg)
raw_map = {str(key): value for key, value in cast("dict[object, object]", item_obj).items()}
name = raw_map.get("name")
prompt = raw_map.get("prompt")
if not isinstance(name, str) or not name:
@@ -61,6 +68,18 @@ async def form_user(
optional ``allow_custom`` (default true).
When ``confirm_submit`` is true, the human reviews a summary before submit.
"""
shape_error = first_error(
string_error("ask_user_form", "title", title),
list_error(
"ask_user_form",
"fields",
fields,
items="objects",
example='[{"name": "port", "prompt": "Which port?"}]',
),
)
if shape_error is not None:
return f"error: {shape_error}"
try:
parsed = parse_fields(fields)
except (TypeError, ValueError) as exc:
+56
View File
@@ -0,0 +1,56 @@
"""Shape checks for ask-family tool arguments.
Tool arguments arrive as decoded JSON, so a parameter declared ``list[str]`` can
still be a plain string at runtime. Duck-typing then misleads instead of failing:
``ask_user_choice(options="yes/no")`` iterates the characters and shows a menu of
letters, and a string ``fields`` dies inside the form parser with an
``AttributeError``. Each ask-family tool checks its container arguments up front
and answers with a model-facing error that also shows the expected shape (same
idea as :func:`plyngent.tools.workspace.argv_shape_error`).
Every check takes ``object``, so the isinstance calls stay meaningful and the
tools themselves stay free of unnecessary-isinstance warnings.
"""
from __future__ import annotations
_SHAPES: dict[type, str] = {
str: "a string",
list: "an array",
dict: "an object",
bool: "a boolean",
int: "a number",
float: "a number",
type(None): "null",
}
def shape_of(value: object) -> str:
"""Model-facing name for a decoded JSON value (``a string``, ``an array``…)."""
return _SHAPES.get(type(value), type(value).__name__)
def first_error(*errors: str | None) -> str | None:
"""First non-``None`` error (``None`` when every shape was acceptable)."""
return next((error for error in errors if error), None)
def string_error(tool: str, field: str, value: object) -> str | None:
"""Error when *value* must be a string but is not; ``None`` when it is."""
if isinstance(value, str):
return None
return f"`{tool}` expects `{field}` as a string, got {shape_of(value)}"
def list_error(tool: str, field: str, value: object, *, items: str, example: str) -> str | None:
"""Error when *value* must be a JSON array of *items* but is not; else ``None``.
Only the container is checked: item-level validation stays in the tool's own
parser (``parse_options`` / ``parse_fields``), which already reports what is
wrong with a bad entry.
"""
if isinstance(value, str):
return f"`{tool}` expects `{field}` as a JSON array of {items}, not a string; pass e.g. {example}"
if not isinstance(value, list):
return f"`{tool}` expects `{field}` as a JSON array of {items}, got {shape_of(value)}; pass e.g. {example}"
return None