mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
tools/chat: return shape errors for mis-typed ask-family arguments
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user