Skip to content

feat(code): encode multi-select ask_user answers as JSON arrays - #5660

Merged
Mason Daugherty (mdrxy) merged 11 commits into
mainfrom
mdrxy/code/multi-select-json-answers
Aug 20, 2026
Merged

feat(code): encode multi-select ask_user answers as JSON arrays#5660
Mason Daugherty (mdrxy) merged 11 commits into
mainfrom
mdrxy/code/multi-select-json-answers

Conversation

@mdrxy

Copy link
Copy Markdown
Member

A multi_select answer from ask_user is now a JSON array of the selected values (["a", "b"], or [] for an optional question left untouched) instead of the values joined with ", ". Choice values and custom Other text may now contain commas, quotes, and newlines — the previous comma ban is gone. Invalid ask_user payloads (e.g. an empty questions list or a blank choice value) now return a recoverable tool error the model can read and retry against, instead of crashing the turn with a fatal Agent error.


The joined-string encoding existed only for transcript legibility — nothing ever parsed the joined string back into a list — but it forced _validate_choices to reject any value containing a comma, and the TUI to block Other text containing one. Worse, that rejection raised ValueError at the top of the tool body, which ToolNode does not convert into a tool-error message; it re-raised up the graph and killed the turn before the model ever saw what was wrong.

The JSON-in-string encoding keeps the answers: list[str] wire shape (one string per question, positionally matched), so AskUserAnswered, the authorization receipt, and _parse_answers' length/positional checks are untouched. JSON is self-delimiting and exactly invertible via the new encode_multi_select_answer / decode_multi_select_answer helpers, so punctuation in a choice value is safe and malformed input can be rejected loudly rather than silently mis-split.

Validation failures now raise ToolException, and the ask_user tool sets handle_tool_error = True so BaseTool.run converts it into an error ToolMessage (the same pattern mcp_tools uses). Without that flag the ToolException would still re-raise past langgraph's default error handling, which only maps ToolInvocationError. Error messages are unchanged in content — they still name the offending value and the exact problem — so a corrected retry normally succeeds in one attempt.

Two subtleties worth careful review:

  • The TUI's "missing answer" submit check previously read not get_answer().strip(). An untouched multi-select now encodes as the truthy string "[]", which would wrongly pass a required question; the check now goes through a decoding answer_is_empty() helper.
  • The transcript renders the JSON array verbatim on the A: line. It is always a single line, so the blank-line-separated Q:/A: layout is unchanged; text and multiple_choice answers stay raw single strings.
Test plan
  • make lint (ruff + ty) and make format clean.
  • New and updated unit tests cover: encode/decode round-trip with commas, quotes, newlines, and unicode; malformed decode returning None; single-line encoding; transcript rendering the JSON form; an empty [] answer keeping its authorization receipt; Other text containing a comma submitting verbatim; an empty [] still failing a required question; and validation failures raising ToolException.
  • Full libs/code unit suite passes (one pre-existing, unrelated test_server_graph.py failure from ambient local auto_classifier_model config leaking into a mock assertion).
  • Verified end-to-end with a scripted agent run through a real ToolNode: a comma-laden choice (push-to-main — no PR label, always strict) asks, submits, and returns A: ["push-to-main — no PR label, always strict"] with status="success"; an invalid payload returns status="error" with the actionable message and the turn continues.

Multi-select answers were encoded by joining selected values with ", ",
which forced a comma ban on choice values and custom Other text — enforced
by a `ValueError` that escaped `ToolNode` and killed the agent turn. The
answer is now a self-delimiting JSON array (`["a", "b"]`, `[]` when
untouched), so commas/quotes/newlines in values round-trip exactly and the
ban is deleted. Validation failures raise `ToolException` with
`handle_tool_error=True` set on the tool, so a malformed payload comes
back as an error `ToolMessage` the model can act on instead of a fatal
turn error.
@github-actions github-actions Bot added dcode Related to `deepagents-code` feature New feature/enhancement or request for one internal User is a member of the `langchain-ai` GitHub organization size: L 500-999 LOC labels Aug 19, 2026

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Open SWE Review: No issues found

Open SWE reviewed this PR and found no potential bugs to report.

Open in WebView Open SWE trace

`_same_turn_user_answers` tested emptiness with `answer.strip()`. That was
correct when an untouched optional question encoded as `""`, but a
`multi_select` now encodes as the truthy string `[]`, so a question the user
declined to answer entered `same_turn_user_answers` paired with something that
reads like an answer. `_CLASSIFIER_POLICY` tells the classifier that unselected
choices are omitted and grant nothing, which was no longer true.

Two notions of "empty" had drifted apart across the wire: the TUI grew
`answer_is_empty` for exactly this reason while the authorization side kept the
bare `.strip()`. Add `ask_user_answer_is_empty` next to the codec as the single
definition and call it from both sides.

The skip now runs after the question shape guard rather than before it. Those
guards are unreachable today, and taking them first is the fail-closed order.
This also stops a declined question from consuming the
`MAX_ASK_USER_AUTHORIZATION_QUESTION_TOTAL_CHARS` budget, whose overflow
discards every authorization row for the turn.
Deleting `_ask_user.handle_tool_error = True` left the whole suite green. The
existing tool tests call `ask_tool.func` directly, which bypasses
`BaseTool.run` and so never reaches the error handling, and the type assertion
on `_validate_questions` pins only the raise site.

Drive a real `ToolNode` in a compiled graph and assert the rejected call comes
back as an error `ToolMessage` instead of escaping the node. Both new tests
fail with the attribute removed.

Also correct the docstring on `test_validation_errors_are_tool_exceptions`: the
conversion happens in `BaseTool.run`, not in `ToolNode`, whose default handler
converts only `ToolInvocationError`.
The inline question widget is unmounted once answered, so that row is the only
place the answers stay visible. It renders the transcript literally, so JSON
encoding put the wire format on screen: `A: ["Boston, MA", "Austin"]` where the
row used to read `A: Boston, MA, Austin`, and a multi-line custom answer
collapsed to a literal `\n` on one line. The comma ban this PR removed was
justified as buying legibility of the joined answer, so that cost needed
somewhere to go.

Add `render_ask_user_transcript_for_display`, which rebuilds the transcript with
each decodable multi-select answer expanded to one value per line. Recovering
the answers means splitting a transcript that `format_ask_user_transcript`
warns is not unambiguously decodable, so it anchors on the known question text
— the mitigation that docstring prescribes — and returns `None` on any
mismatch, leaving the caller its literal rendering. Placeholders such as
`(cancelled)` are not JSON, so they still show verbatim. Display only; nothing
here feeds a trust decision.
`_QuestionWidget.answer_is_empty` reimplemented the multi-select rule that now
lives in `ask_user_answer_is_empty`; delegate instead, so the widget and the
authorization path cannot drift again.

`on_ask_user_text_area_submitted` still tested `get_answer().strip()`. That is
reachable for a multi-select, where `[]` is truthy — correct today only because
`validate_for_submit` runs first and catches the required-and-empty case.
Reordering those two would have made a required empty multi-select confirm
silently. Use `answer_is_empty` so it holds by construction.

Also trim the `[]`-is-truthy rationale in the submit loop, which restated the
docstring it points at.
`handle_tool_error = True` means a malformed `questions` payload no longer
kills the turn — the model gets an error `ToolMessage` and retries. Nothing on
that path logged, so a model looping on the same bad payload was invisible
until the recursion limit tripped, leaving an error that names none of the
seven real causes. Log once where the tool calls the validator, matching what
`_parse_answers` already does for every malformed-payload branch.

Widen the codec tests to pin behavior that was already correct but untested:
brackets inside a value (the one character class that collides with the
encoding's own delimiters, including a value that is itself a JSON array), and
decode rejection of the empty string, JSON scalars, nested arrays, and a null
element. The empty string matters most — it is the pre-JSON encoding of an
untouched question and is still synthesized on `textual_adapter`'s error and
cancel paths, so a lenient decode would quietly restore the old semantics.

Also retire a stale contrast in `test_allows_comma_in_multiple_choice_value`,
which distinguished single-selection answers from multi-select on comma
handling. Both accept commas now.
@github-actions github-actions Bot added size: XL 1000+ LOC and removed size: L 500-999 LOC labels Aug 19, 2026

@open-swe open-swe Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open SWE Review found 1 potential issue.

Open in WebView Open SWE trace

Comment thread libs/code/deepagents_code/_ask_user_types.py Outdated
…nscript

A text answer can legitimately contain a later `Q: ...` block verbatim.
Anchoring on the first occurrence of the next block folded the real
multi-select block into the preceding answer, so the expanded row showed
the quoted `["fake"]` as that answer. A separator that occurs more
than once now fails the parse and the transcript renders literally.
…ct-json-answers

# Conflicts:
#	libs/code/deepagents_code/ask_user.py
#	libs/code/tests/unit_tests/test_ask_user_middleware.py
@github-actions github-actions Bot added size: L 500-999 LOC and removed size: XL 1000+ LOC labels Aug 20, 2026
@github-actions github-actions Bot added size: XL 1000+ LOC and removed size: L 500-999 LOC labels Aug 20, 2026
Corrections to the JSON multi-select encoding: documentation that contradicted
the code, one display placeholder with two meanings, a dead guard, a duplicate
log, and unattributable drops of consent evidence.

Behavior:

- An unselected `multi_select` now displays as `(nothing selected)` via a new
  display-only `ASK_USER_NOTHING_SELECTED`. It previously reused
  `ASK_USER_NO_ANSWER`, whose docstring promises that constant marks a
  *missing* answer and never a deliberately blank one.
- `_same_turn_user_answers` logs when it withholds an undecodable
  `multi_select` answer from the authorization evidence. Only a non-TUI client
  can produce one; withholding is fail-closed but costs the user an
  authorization they gave, so the drop is now attributable. The answer text is
  not logged.
- `_CLASSIFIER_POLICY` states that a multi-select answer arrives as a JSON
  array, so the classifier compares values rather than brackets and quotes.
- The TUI row only attempts the display re-render when a `multi_select` is
  actually present, and debug-logs when it cannot unpack one.
- Dropped the duplicate `logger.warning` in the `ask_user` tool body:
  `_tool_arg_validation_on_error` already logs the rejection with `exc_info`,
  and its own comment claims to be the single record.

Dead code:

- Removed `if position != len(transcript)` from
  `render_ask_user_transcript_for_display`. It was unreachable — the final
  loop iteration always sets `position` to the end — and it advertised a
  trailing-content rejection that does not exist: with two or more questions
  the junk lands in the last answer and is re-emitted.

Corrected comments, each verified against the code:

- `format_ask_user_transcript` no longer claims the TUI never parses the
  transcript, and no longer says the JSON encoding "buys nothing here" — it
  does close the block-forgery hazard, this function just cannot rely on it.
- `encode_multi_select_answer` drops "compact" (the default separators are
  `", "`) and scopes the no-newline guarantee: `ensure_ascii=False` passes
  U+2028/U+2029 through literally.
- `ask_user_answer_is_empty` says the TUI re-prompts a *required* question,
  and documents that `(cancelled)`/`(error: ...)` read as empty for
  `multi_select` only.
- The `auto_mode` skip comment names the real mechanisms: the char budget
  rejects the whole row set, and `rows[-20:]` is what evicts.
- Documented `decode_multi_select_answer`'s `str` precondition, the
  `end + 2` uniqueness re-search, the trailing-content and duplicate-question
  fallbacks, that the per-answer receipt budget is measured on the encoded
  form, and the `["a", "b"]` vs `["a\nb"]` display ambiguity.
- Fixed `\\n` rendering as two characters inside an r-string docstring.
- One phrasing for the permitted characters across all three model-facing
  sites, plus the `multiple_choice` counterpart: that value is returned bare
  and unescaped, so it must stay single-line.

Tests for gaps where the code could be broken with the suite still green:

- The emptiness skip running before the char budget, so a declined question
  cannot discard a real affirmative.
- An undecodable `multi_select` answer being withheld and logged.
- A benign quoted `Q:`/`A:` block still unpacking its neighbour, the
  over-rejection side the uniqueness rule trades against.
- Trailing content being re-emitted, and three identically worded questions
  falling back to literal rendering.
- The preview branch staying a summary with `multi_select` args.
- A required text answer of literally `[]` not reading as empty.
- JSON escaping pushing an answer past the receipt cap.
@mdrxy
Mason Daugherty (mdrxy) merged commit fc70294 into main Aug 20, 2026
57 checks passed
@mdrxy
Mason Daugherty (mdrxy) deleted the mdrxy/code/multi-select-json-answers branch August 20, 2026 15:58
Mason Daugherty (mdrxy) pushed a commit that referenced this pull request Aug 20, 2026
> [!CAUTION]
> Merging this PR will automatically publish to **PyPI** and create a
**GitHub release**.

For the full release process, see
[`.github/RELEASING.md`](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md).

---

_Release notes preview: keep this section in sync with the package
`CHANGELOG.md`. Publish reads the merged CHANGELOG via `release.yml`,
not this PR description — keep them aligned anyway so the PR stays an
accurate historical record for reviewers and anyone returning later._

---


##
[0.1.59](deepagents-code==0.1.58...deepagents-code==0.1.59)
(2026-08-20)

### Features

- Added support for `managed_config.toml` configuration
([#5604](#5604))
- Multi-select `ask_user` answers are now encoded as JSON arrays
([#5660](#5660))
- Made teardown usage stats configurable
([#5696](#5696))
- Footer pickers now open on click
([#5674](#5674))
- Replaced Gemini 3.6 Flash with Gemini 3.7 Flash
([#5681](#5681))

### Bug fixes

- Made tool argument validation errors recoverable
([#5659](#5659))
- Improved streaming performance for tool-call arguments to run in
linear time
([#5712](#5712))
- Fixed durable-mask config resolution with ranked resolver behavior
([#5672](#5672))
- Skipped background sync in Apple Terminal
([#5666](#5666))
- Hid thread IDs when tracing is disabled
([#5692](#5692))
- Kept installed providers visible in `/auth`
([#5689](#5689))
- Preloaded the auth UI before notification handoff
([#5697](#5697))
- Updated and clarified UI copy across Auto mode, YOLO hints, classifier
notices, `/tokens`, line-number toggles, review failures, onboarding
Tavily cancellation, and OpenAI subscription login labels
([#5685](#5685),
[#5694](#5694),
[#5684](#5684),
[#5687](#5687),
[#5680](#5680),
[#5688](#5688),
[#5686](#5686),
[#5691](#5691),
[#5693](#5693))
- Removed the `Muse Spark 1.1` recommendation
([#5683](#5683))

_End release notes preview._

---

> [!NOTE]
> A **community contributors** list and a **Special thanks** section
(crediting the users who filed the issues this release's PRs closed) are
appended to the GitHub release notes automatically at publish time (see
[Release
Pipeline](https://github.com/langchain-ai/deepagents/blob/main/.github/RELEASING.md#release-pipeline),
step 3).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: langchain-oss-automated-triage[bot] <248757908+langchain-oss-automated-triage[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dcode Related to `deepagents-code` feature New feature/enhancement or request for one internal User is a member of the `langchain-ai` GitHub organization size: XL 1000+ LOC

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant