Let a plugin's query request ask for structured output
- Status: Done
- Kind: Feature
- Authors: jp
- Date: 2026-10-01
- Label: domain=plugins
- Label: package=jp_cli
- Label: package=jp_plugin
A plugin can run a turn through the host with query, but QueryRequest carries only conversation, content, new, title, and cfg (crates/jp_plugin/src/message.rs:496-536). There is no way to ask for a response that matches a JSON Schema, or to run the turn in a throwaway conversation that is not kept.
Input that hits it: RFD 116's jp rfd track reads an RFD's Implementation Plan as structured output. The recipe does it with jp query --new --local --tmp=5m --format=json --no-reasoning --no-tools --schema "$SCHEMA" (justfile:2703-2704). The plugin cannot ask its host for the same, so it shells out to jp query as a subprocess.
The subprocess works and is cheap, so this is a cleanup, not a blocker.
Scope
schema: draft D18 (Plugin Event Subscriptions and Query Delegation) already specifies it on thequerypayload, as the same fieldChatRequestcarries. This ticket ships that field on its own, ahead of the rest of D18.- A temporary conversation, the way
--local --tmpgives one: not in D18. Without it, a plugin's one-off structured query leaves a conversation behind.
Reasoning and tools can likely be switched off through the existing cfg field; check that before adding fields for them.
Comments
- From: jp
- Date: 2026-10-01T10:26:09Z
Implemented; protocol bumped to 11.
query.schema: set on the turn'sChatRequest, asjp query --schemadoes.query_completecarries the parsed response in a newdatafield. A turn whose own response has no structured data is answered with anerror; only the last turn is searched, so an earlier turn's data is never returned in its place.query.expires_in: a humantime duration (5m,1h,0s) stamped asexpires_aton the conversation anewquery creates. Refused withoutnew, matching--tmprequiring--new.
The rest of the recipe's flags go through cfg on the same request (key paths checked against the config tree, not exercised by a test):
--local:conversation.start_local=true--no-reasoning:assistant.model.parameters.reasoning=off--no-tools: no exact equivalent.assistant.tool_choice=noneforbids tool calls, but the tools are still sent with the request.
- From: jp
- Date: 2026-10-01T11:41:01Z
Review follow-up (PR #1221):
- A turn stopped before it starts (a client stop or Ctrl-C during MCP startup, model lookup, or before the loop's first phase) returned
Ok(()), soquery_completehanded back the previous turn's data.TurnInputs::runreturns aTurnOutcome(Started/NotStarted), and a structured query on a turn that did not start is answered with anerror. - A response that is not valid JSON is recorded as its raw text in a JSON string, which
query_completepassed off asdata. That string is refused when the schema's roottyperules a string out (most often a response cut off at the output token limit).
Both go through turn_structured_data in cmd::query, which jp query --schema uses too. The CLI had both problems as well, and worse: it searched the whole stream rather than the last turn. A turn that did not start also no longer deletes the query draft, which is the only copy of a request that was never recorded.
- From: jp
- Date: 2026-10-01T12:30:53Z
- Re: #2
Review follow-up (PR #1221, second round): TurnOutcome and the coordinator's started flag are gone, and TurnInputs::run, Query::run_turn, and run_turn_loop return Result<()> again.
Callers read last_request_id (the id of the conversation's last ChatRequest) from the lock before the turn runs, and turn_structured_data takes that id: if the last request is unchanged, the turn did not start. The same comparison gates deleting the query draft in jp query.
The marker is the last request, not the last TurnStart: sanitizing the stream before the turn can drop a trailing empty TurnStart or insert one into a stream that has none, but never adds or removes a request. turn_structured_data_refuses_a_turn_that_did_not_start_after_sanitizing fails against a TurnStart-based check.