core/tools: change ask_user_choice and ask_user_form params from JSON strings to typed lists

This commit is contained in:
2026-07-29 01:42:00 +08:00
parent f5b566b371
commit ef3594ad62
3 changed files with 57 additions and 48 deletions
+10 -20
View File
@@ -1,27 +1,17 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import cast from typing import cast
from plyngent.agent import ToolTag, tool from plyngent.agent import ToolTag, tool
from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async from plyngent.prompting import ChoiceOption, NonInteractiveError, choose_async
def parse_options(raw: str) -> list[ChoiceOption]: def parse_options(options: list[object]) -> list[ChoiceOption]:
"""Parse options JSON: list of strings or objects with label/description/value.""" """Normalize a list of strings or dicts into ``ChoiceOption`` list."""
text = raw.strip() if not options:
if not text:
return [] return []
try:
data: object = json.loads(text)
except json.JSONDecodeError as exc:
msg = f"options must be JSON: {exc}"
raise ValueError(msg) from exc
if not isinstance(data, list):
msg = "options must be a JSON array"
raise TypeError(msg)
out: list[ChoiceOption] = [] out: list[ChoiceOption] = []
for item_obj in cast("list[object]", data): for item_obj in options:
if isinstance(item_obj, str): if isinstance(item_obj, str):
out.append(ChoiceOption(label=item_obj)) out.append(ChoiceOption(label=item_obj))
continue continue
@@ -41,7 +31,7 @@ def parse_options(raw: str) -> list[ChoiceOption]:
) )
) )
continue continue
msg = "options items must be strings or objects" msg = "options items must be strings or objects with label/description/value"
raise TypeError(msg) raise TypeError(msg)
return out return out
@@ -49,24 +39,24 @@ def parse_options(raw: str) -> list[ChoiceOption]:
@tool(name="ask_user_choice", tags=ToolTag.LOCAL) @tool(name="ask_user_choice", tags=ToolTag.LOCAL)
async def choose_user( async def choose_user(
question: str, question: str,
options: str, options: list[str] | list[dict[str, object]],
default: str = "", default: str = "",
*, *,
allow_custom: bool = True, allow_custom: bool = True,
) -> str: ) -> str:
"""Ask the human to pick from a list of options (or type a custom answer). """Ask the human to pick from a list of options (or type a custom answer).
``options`` is a JSON array of strings, or objects with ``options`` is a list of strings, or objects with
``label``, optional ``description``, optional ``value``. ``label`` (required), optional ``description``, optional ``value``.
When ``allow_custom`` is true (default), free-text answers are accepted. When ``allow_custom`` is true (default), free-text answers are accepted.
Returns the chosen option value (or custom text). Returns the chosen option value (or custom text).
""" """
try: try:
parsed = parse_options(options) parsed = parse_options([cast("object", o) for o in options])
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
return f"error: {exc}" return f"error: {exc}"
if not parsed and not allow_custom: if not parsed and not allow_custom:
return "error: options must be a non-empty JSON array when allow_custom is false" return "error: options must be a non-empty list when allow_custom is false"
try: try:
return await choose_async( return await choose_async(
question, question,
+20 -22
View File
@@ -8,25 +8,14 @@ from plyngent.prompting import FormField, NonInteractiveError, form_async
from plyngent.tools.chat.choose import parse_options from plyngent.tools.chat.choose import parse_options
def parse_fields(raw: str) -> list[FormField]: def parse_fields(fields: list[dict[str, object]]) -> list[FormField]:
"""Parse form fields JSON array of objects.""" """Normalize a list of field dicts into ``FormField`` list."""
text = raw.strip() if not fields:
if not text: msg = "fields must be a non-empty list"
return []
try:
data: object = json.loads(text)
except json.JSONDecodeError as exc:
msg = f"fields must be JSON: {exc}"
raise ValueError(msg) from exc
if not isinstance(data, list) or not data:
msg = "fields must be a non-empty JSON array"
raise ValueError(msg) raise ValueError(msg)
out: list[FormField] = [] out: list[FormField] = []
for item_obj in cast("list[object]", data): for item_obj in fields:
if not isinstance(item_obj, dict): raw_map = {str(key): value for key, value in item_obj.items()}
msg = "each field must be a JSON object"
raise TypeError(msg)
raw_map = {str(key): value for key, value in cast("dict[object, object]", item_obj).items()}
name = raw_map.get("name") name = raw_map.get("name")
prompt = raw_map.get("prompt") prompt = raw_map.get("prompt")
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
@@ -39,7 +28,10 @@ def parse_fields(raw: str) -> list[FormField]:
options_raw = raw_map.get("options") options_raw = raw_map.get("options")
options = None options = None
if options_raw is not None: if options_raw is not None:
options = parse_options(json.dumps(options_raw)) if not isinstance(options_raw, list):
msg = f"field {name!r} options must be a list"
raise TypeError(msg)
options = parse_options(cast("list[object]", options_raw))
allow_custom_obj = raw_map.get("allow_custom", True) allow_custom_obj = raw_map.get("allow_custom", True)
allow_custom = allow_custom_obj if isinstance(allow_custom_obj, bool) else True allow_custom = allow_custom_obj if isinstance(allow_custom_obj, bool) else True
out.append( out.append(
@@ -55,12 +47,18 @@ def parse_fields(raw: str) -> list[FormField]:
@tool(name="ask_user_form", tags=ToolTag.LOCAL) @tool(name="ask_user_form", tags=ToolTag.LOCAL)
async def form_user(title: str, fields: str, *, confirm_submit: bool = True) -> str: async def form_user(
title: str,
fields: list[dict[str, object]],
*,
confirm_submit: bool = True,
) -> str:
"""Run a multi-step form with the human; returns JSON object of answers. """Run a multi-step form with the human; returns JSON object of answers.
``fields`` is a JSON array of objects: ``fields`` is a list of objects:
``name``, ``prompt``, optional ``default``, optional ``options`` (same shape ``name`` (required), ``prompt`` (required), optional ``default``,
as ask_user_choice), optional ``allow_custom`` (default true). optional ``options`` (same shape as ask_user_choice),
optional ``allow_custom`` (default true).
When ``confirm_submit`` is true, the human reviews a summary before submit. When ``confirm_submit`` is true, the human reviews a summary before submit.
""" """
try: try:
+27 -6
View File
@@ -25,7 +25,7 @@ async def test_choose_user_tool_index() -> None:
json.dumps( json.dumps(
{ {
"question": "Pick", "question": "Pick",
"options": json.dumps(["alpha", "beta"]), "options": ["alpha", "beta"],
"allow_custom": False, "allow_custom": False,
} }
), ),
@@ -33,23 +33,44 @@ async def test_choose_user_tool_index() -> None:
assert out == "alpha" assert out == "alpha"
async def test_choose_user_bad_options() -> None: async def test_choose_user_dict_options() -> None:
backend = ScriptedBackend(["1"])
with temporary_backend(backend):
registry = ToolRegistry([choose_user])
out = await registry.execute(
"ask_user_choice",
json.dumps(
{
"question": "Pick",
"options": [{"label": "yes", "description": "affirm"}],
}
),
)
assert out == "yes"
async def test_choose_user_invalid_options() -> None:
registry = ToolRegistry([choose_user]) registry = ToolRegistry([choose_user])
out = await registry.execute( out = await registry.execute(
"ask_user_choice", "ask_user_choice",
json.dumps({"question": "Pick", "options": "not-json"}), json.dumps({"question": "Pick", "options": [42]}),
) )
assert out.startswith("error:") assert "error" in out
async def test_form_user_tool() -> None: async def test_form_user_tool() -> None:
backend = ScriptedBackend(["ncbm"], confirms=[True]) backend = ScriptedBackend(["ncbm"], confirms=[True])
fields = json.dumps([{"name": "user", "prompt": "User?"}])
with temporary_backend(backend): with temporary_backend(backend):
registry = ToolRegistry([form_user]) registry = ToolRegistry([form_user])
out = await registry.execute( out = await registry.execute(
"ask_user_form", "ask_user_form",
json.dumps({"title": "Setup", "fields": fields, "confirm_submit": True}), json.dumps(
{
"title": "Setup",
"fields": [{"name": "user", "prompt": "User?"}],
"confirm_submit": True,
}
),
) )
assert json.loads(out) == {"user": "ncbm"} assert json.loads(out) == {"user": "ncbm"}