e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
274 lines
9.4 KiB
Python
274 lines
9.4 KiB
Python
"""Build the payload for the ``ask_user`` tool.
|
|
|
|
The tool packages one-to-four structured questions into a payload that
|
|
the chat pipeline interprets as a "pause this same turn until the user
|
|
answers" signal (``ToolResult.pause_for_user``). The frontend reads the
|
|
same payload off ``tool_result.metadata.ask_user`` and renders a card
|
|
that lets the user move between questions and submit answers in one
|
|
batch.
|
|
|
|
The schema is intentionally a list-of-questions even for the common
|
|
single-question case — every call wraps a list so the frontend has one
|
|
code path. Each option is a ``{label, description}`` pair (mirroring
|
|
Claude Code's ``AskUserQuestion``): the label is the short clickable
|
|
choice, the description explains what picking it means. Plain-string
|
|
options are still accepted at the LLM-facing boundary and normalised to
|
|
``{label, description: None}``. The legacy ``{question, options}``
|
|
argument shape is likewise accepted (``build_ask_user_payload``) and
|
|
normalised to a single-element list internally.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
MAX_QUESTIONS = 4
|
|
MAX_OPTIONS = 8
|
|
MAX_OPTION_CHARS = 120 # option label
|
|
MAX_OPTION_DESC_CHARS = 200
|
|
MAX_HEADER_CHARS = 16
|
|
MAX_QUESTION_CHARS = 800
|
|
MAX_INTRO_CHARS = 400
|
|
MAX_PLACEHOLDER_CHARS = 120
|
|
|
|
# Labels the model sometimes adds as its own catch-all option. The card
|
|
# already renders a free-form "Other" row whenever ``allow_free_text``
|
|
# is on, so a model-supplied duplicate is dropped (exact match only —
|
|
# being clever here risks eating legitimate options).
|
|
_REDUNDANT_OTHER_LABELS = frozenset({"other", "其他", "其它"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AskUserOption:
|
|
"""One clickable choice: short label + optional explanation."""
|
|
|
|
label: str
|
|
description: str | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {"label": self.label, "description": self.description}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AskUserQuestion:
|
|
"""A single question rendered as one tab on the ask_user card."""
|
|
|
|
id: str
|
|
prompt: str
|
|
options: tuple[AskUserOption, ...] = ()
|
|
header: str | None = None
|
|
multi_select: bool = False
|
|
allow_free_text: bool = True
|
|
placeholder: str | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"id": self.id,
|
|
"prompt": self.prompt,
|
|
"header": self.header,
|
|
"multi_select": self.multi_select,
|
|
"options": [o.to_dict() for o in self.options],
|
|
"allow_free_text": self.allow_free_text,
|
|
"placeholder": self.placeholder,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AskUserPayload:
|
|
"""Structured payload that travels alongside the tool result.
|
|
|
|
Mirrored on the frontend by ``AskUserOptions.tsx`` which reads the
|
|
same field names off ``tool_result.metadata.ask_user``.
|
|
"""
|
|
|
|
questions: tuple[AskUserQuestion, ...]
|
|
intro: str | None = None
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"intro": self.intro,
|
|
"questions": [q.to_dict() for q in self.questions],
|
|
}
|
|
|
|
@property
|
|
def question_ids(self) -> tuple[str, ...]:
|
|
return tuple(q.id for q in self.questions)
|
|
|
|
|
|
def build_ask_user_payload(
|
|
*,
|
|
questions: Any = None,
|
|
intro: Any = None,
|
|
# Legacy single-question shape — auto-normalised into ``questions``.
|
|
question: Any = None,
|
|
options: Any = None,
|
|
) -> tuple[AskUserPayload | None, str | None]:
|
|
"""Validate + normalise the LLM-provided arguments.
|
|
|
|
Accepts either the v2 ``{questions: [...], intro?}`` shape or the
|
|
legacy ``{question, options?}`` shape (which is wrapped into a
|
|
one-element list). Returns ``(payload, None)`` on success, or
|
|
``(None, error_message)`` if arguments cannot be honoured — errors
|
|
propagate back to the LLM as a tool failure rather than raising.
|
|
"""
|
|
raw_questions = _coerce_questions(questions, question, options)
|
|
if isinstance(raw_questions, str):
|
|
return None, raw_questions
|
|
if not raw_questions:
|
|
return None, "`questions` must contain at least one question."
|
|
if len(raw_questions) > MAX_QUESTIONS:
|
|
raw_questions = raw_questions[:MAX_QUESTIONS]
|
|
|
|
normalised: list[AskUserQuestion] = []
|
|
used_ids: set[str] = set()
|
|
for idx, raw in enumerate(raw_questions):
|
|
q_or_err = _build_question(raw, idx, used_ids)
|
|
if isinstance(q_or_err, str):
|
|
return None, q_or_err
|
|
normalised.append(q_or_err)
|
|
used_ids.add(q_or_err.id)
|
|
|
|
intro_text: str | None = None
|
|
if intro is not None:
|
|
intro_text = _coerce_string(intro).strip() or None
|
|
if intro_text and len(intro_text) > MAX_INTRO_CHARS:
|
|
intro_text = intro_text[:MAX_INTRO_CHARS].rstrip() + "…"
|
|
|
|
return AskUserPayload(questions=tuple(normalised), intro=intro_text), None
|
|
|
|
|
|
def _coerce_questions(questions: Any, question: Any, options: Any) -> list[Any] | str:
|
|
if questions is not None:
|
|
if not isinstance(questions, (list, tuple)):
|
|
return "`questions` must be an array."
|
|
return list(questions)
|
|
if question is not None:
|
|
# Legacy single-question shape.
|
|
return [{"prompt": question, "options": options}]
|
|
return []
|
|
|
|
|
|
def _build_question(raw: Any, idx: int, used_ids: set[str]) -> AskUserQuestion | str:
|
|
if not isinstance(raw, dict):
|
|
return f"Question #{idx + 1} must be an object."
|
|
|
|
# ``prompt`` is the canonical field; accept ``question`` as alias
|
|
# for forgiveness toward older LLM prompts.
|
|
prompt_raw = raw.get("prompt")
|
|
if prompt_raw is None:
|
|
prompt_raw = raw.get("question")
|
|
prompt = _coerce_string(prompt_raw).strip()
|
|
if not prompt:
|
|
return f"Question #{idx + 1}: `prompt` must be a non-empty string."
|
|
if len(prompt) > MAX_QUESTION_CHARS:
|
|
prompt = prompt[:MAX_QUESTION_CHARS].rstrip() + "…"
|
|
|
|
allow_free_text = raw.get("allow_free_text")
|
|
allow_free_text = True if allow_free_text is None else bool(allow_free_text)
|
|
|
|
options_raw = raw.get("options")
|
|
options: tuple[AskUserOption, ...] = ()
|
|
if options_raw is not None:
|
|
if not isinstance(options_raw, (list, tuple)):
|
|
return f"Question #{idx + 1}: `options` must be an array."
|
|
cleaned: list[AskUserOption] = []
|
|
seen_labels: set[str] = set()
|
|
for opt in options_raw:
|
|
normalised = _build_option(opt)
|
|
if normalised is None:
|
|
continue
|
|
# The card auto-renders an "Other" free-text row; drop a
|
|
# model-supplied duplicate so the user never sees two.
|
|
if allow_free_text and normalised.label.lower() in _REDUNDANT_OTHER_LABELS:
|
|
continue
|
|
if normalised.label in seen_labels:
|
|
continue
|
|
seen_labels.add(normalised.label)
|
|
cleaned.append(normalised)
|
|
if len(cleaned) >= MAX_OPTIONS:
|
|
break
|
|
options = tuple(cleaned)
|
|
|
|
# ``multi_select`` is canonical; accept camelCase ``multiSelect``
|
|
# since models trained on Claude Code's tool emit that spelling.
|
|
multi_select_raw = raw.get("multi_select")
|
|
if multi_select_raw is None:
|
|
multi_select_raw = raw.get("multiSelect")
|
|
multi_select = bool(multi_select_raw)
|
|
|
|
header_raw = raw.get("header")
|
|
header: str | None = None
|
|
if header_raw is not None:
|
|
header = _coerce_string(header_raw).strip() or None
|
|
if header and len(header) > MAX_HEADER_CHARS:
|
|
header = header[:MAX_HEADER_CHARS].rstrip()
|
|
|
|
placeholder_raw = raw.get("placeholder")
|
|
placeholder: str | None = None
|
|
if placeholder_raw is not None:
|
|
placeholder = _coerce_string(placeholder_raw).strip() or None
|
|
if placeholder and len(placeholder) > MAX_PLACEHOLDER_CHARS:
|
|
placeholder = placeholder[:MAX_PLACEHOLDER_CHARS].rstrip() + "…"
|
|
|
|
qid = _coerce_string(raw.get("id")).strip()
|
|
if not qid:
|
|
qid = f"q{idx + 1}"
|
|
# Disambiguate duplicate ids deterministically rather than rejecting.
|
|
if qid in used_ids:
|
|
suffix = 2
|
|
while f"{qid}_{suffix}" in used_ids:
|
|
suffix += 1
|
|
qid = f"{qid}_{suffix}"
|
|
|
|
return AskUserQuestion(
|
|
id=qid,
|
|
prompt=prompt,
|
|
options=options,
|
|
header=header,
|
|
multi_select=multi_select,
|
|
allow_free_text=allow_free_text,
|
|
placeholder=placeholder,
|
|
)
|
|
|
|
|
|
def _build_option(raw: Any) -> AskUserOption | None:
|
|
"""Normalise one option: ``{label, description?}`` dict or plain string."""
|
|
if isinstance(raw, dict):
|
|
label = _coerce_string(raw.get("label")).strip()
|
|
description = _coerce_string(raw.get("description")).strip() or None
|
|
else:
|
|
label = _coerce_string(raw).strip()
|
|
description = None
|
|
if not label:
|
|
return None
|
|
if len(label) > MAX_OPTION_CHARS:
|
|
label = label[:MAX_OPTION_CHARS].rstrip() + "…"
|
|
if description and len(description) > MAX_OPTION_DESC_CHARS:
|
|
description = description[:MAX_OPTION_DESC_CHARS].rstrip() + "…"
|
|
return AskUserOption(label=label, description=description)
|
|
|
|
|
|
def _coerce_string(value: Any) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, str):
|
|
return value
|
|
return str(value)
|
|
|
|
|
|
__all__ = [
|
|
"AskUserOption",
|
|
"AskUserPayload",
|
|
"AskUserQuestion",
|
|
"MAX_HEADER_CHARS",
|
|
"MAX_INTRO_CHARS",
|
|
"MAX_OPTION_CHARS",
|
|
"MAX_OPTION_DESC_CHARS",
|
|
"MAX_OPTIONS",
|
|
"MAX_PLACEHOLDER_CHARS",
|
|
"MAX_QUESTION_CHARS",
|
|
"MAX_QUESTIONS",
|
|
"build_ask_user_payload",
|
|
]
|