Repository navigation
[api][plan][integrations][java][python] Apply an agent output schema natively on the final response - #1215
Conversation
…an apply it The native branch of the Anthropic, Bedrock, Gemini, Azure OpenAI and OpenAI completions connections required both that the request could carry the schema and that the model was on the connection's allowlist. A caller that resolves a NATIVE strategy on a FEASIBLE answer would have its schema silently dropped. The branch now applies the schema whenever the request can carry it. Model capability only decides whether supportsNativeStructuredOutput answers FEASIBLE or NATIVE_RECOMMENDED; whether a schema is passed at all is the caller's strategy. The query is therefore INFEASIBLE exactly when the native branch is skipped. Behavior change for direct chat(..., outputSchema) callers: a model outside a connection's allowlist now gets the native parameter. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
… can apply it The native request path of the OpenAI, Azure OpenAI, Anthropic and DashScope connections required both that the request could carry the schema and that the model was on the connection's allowlist, so a caller resolving a NATIVE strategy on a FEASIBLE answer would have its schema silently dropped. vLLM inherits the OpenAI path. The path now applies the schema whenever the request can carry it. Model capability only decides whether supports_native_structured_output answers FEASIBLE or NATIVE_RECOMMENDED; whether a schema is passed at all is the caller's strategy. Behavior change for direct chat(..., output_schema) callers: a model outside a connection's allowlist now gets the native parameter. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
…del setup Adds two framework-facing members to BaseChatModelSetup. willApplyNativeStructuredOutput(schema) asks the bound connection once whether a tool-free request built from the setup's parameters could carry the schema, and resolves the answer with the configured structured output strategy. A NATIVE strategy on a schema no request can carry fails here, naming the connection and the schema, before any model call. chatStructured(messages, params, schema) issues one schema-carrying call with no tools and without the bound prompt. It drops tool results and tool-calling assistant turns from the messages: some providers reject tool traffic in a request that defines no tools, and dropping whole turns keeps user and assistant roles alternating. PythonChatModelSetup cannot carry a schema across the bridge, so it answers false, raises under a NATIVE strategy, and refuses chatStructured. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
…model setup Python twin of the Java setup gate. will_apply_native_structured_output(output_schema) asks the bound connection once whether a tool-free request built from model_kwargs could carry the schema, and resolves the answer with the configured structured output strategy. A NATIVE strategy on a schema no request can carry raises ValueError naming the connection and the schema, before any model call. chat_structured(messages, output_schema, **kwargs) issues one schema-carrying call with no tools and without the bound prompt, and drops tool results and tool-calling assistant turns from the messages. JavaChatModelSetup cannot carry a schema across the bridge, so it answers False, raises under a NATIVE strategy, and refuses chat_structured. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
…onse When a chat request carries an output schema, the invoker asks the chat model setup once, before its retry loop, whether the schema will travel through the provider's native structured output. A failure of that question, such as a NATIVE strategy on a schema the connection cannot carry, fails the candidate without a model call. When the answer is native and the model's response has no tool calls, the same attempt issues one more call: the request messages the loop call sent, its answer, and a user instruction to convert that answer into the required format, carrying the schema and no tools. The call has its own durable record, trace span, failure report and token metrics; its response is the one parsed. A failure or a parse failure consumes the existing retry budget, and a retry repeats the attempt. When the answer is not native, the request behaves exactly as before. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
…al response Python twin of the Java finalization call. The chat model action asks the setup once, before its retry loop, whether the schema travels natively; a failure of that question fails the request without a model call. When the answer is native and the response has no tool calls, the same attempt sends the prepared request messages, the answer and the conversion instruction through chat_structured, under a fixed durable id so a recovered run replays it. Request preparation moves out of BaseChatModelSetup.chat into prepare_request_messages so both calls send the same history. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
…e schema ReActAgent describes the output schema to the model in a system instruction. When the default chat model's setup answers that the schema will travel natively, that instruction is left out, since the provider then enforces the schema itself. Any failure to answer, other than cancellation, keeps the instruction. The registered prompt resource is unchanged, so the agent plan is unchanged. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
…the schema Python twin of the Java ReActAgent change: start_action leaves out the schema instruction when the default chat model will apply the schema natively, keeps it on any other answer or failure, and lets cancellation propagate. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
wenjin272
left a comment
There was a problem hiding this comment.
Thanks for putting this together, @weiqingy, and for combining the remaining wiring into one PR! I left a few inline comments on simplifying the setup/finalization interfaces, removing the connection-null fallback, and handling tool-specific parameters in the vLLM finalization request.
| * @throws NullPointerException if {@code outputSchema} is null, or if {@link #open()} has not | ||
| * bound the connection yet | ||
| */ | ||
| public ChatMessage chatStructured( |
There was a problem hiding this comment.
Could we simplify the finalization path and keep its orchestration in the chat action/invoker?
The finalization input should be just the last assistant response plus the conversion directive, with the schema passed separately. That response already has no tool calls. If the final answer itself is incomplete, format conversion should not try to repair it using earlier context. This would remove the need to reconstruct the full history, call prepareRequestMessages again, or filter it through withoutToolTraffic.
Instead of a dedicated chatStructured method or a new request type, could we add an overload along these lines?
chat(messages, tools, modelParams, outputSchema)This overload would use the supplied messages and tools without injecting the setup's bound prompt or tools. The existing chat(messages, promptArgs, modelParams) could prepare the normal request and delegate to it. The action/invoker would call the explicit overload with the last response, conversion directive, empty tools, and schema.
There was a problem hiding this comment.
Done in e783465. The setup now has chat(messages, tools, modelParams, outputSchema), which sends messages and tools as given. The existing chat prepares the request and delegates to it. chatStructured, the history rebuild and withoutToolTraffic are gone. Python can't overload, and a new keyword on chat would reach the provider through **kwargs in subclasses that override it, so the Python twin is a separate chat_explicit(messages, tools, output_schema=None, **kwargs).
I changed one thing in the shape. The request is a single user message, the directive followed by the answer text, instead of [assistant(answer), user(directive)]. On Bedrock an empty answer becomes an assistant message with no content, and some vLLM/Ollama chat templates may refuse a conversation that opens with an assistant turn. Does that trade-off work for you?
One thing I'm unsure about on the ReAct side. On the native path the loop call no longer carries the schema instruction (613b275, 7e65097), and the conversion call now sees only the answer. So nothing tells the model which fields to fill, and a missing field either fails to parse or gets made up. Should we keep the schema instruction on the native path, or at least its field list? Or do you see a better place for that hint?
| if (outputSchema == null) { | ||
| return false; | ||
| } | ||
| if (connection == null) { |
There was a problem hiding this comment.
Could we remove the connection == null fallback from willApplyNativeStructuredOutput?
The framework calls open() before exposing the setup for use, so a normal setup should already have its connection initialized here. Treating a missing connection as a reason to select the prompt path hides a lifecycle or implementation error.
Special setups that intentionally operate without a connection can override this method, as the bridge setup already does. The tests expecting automatic fallback for a missing connection should be adjusted accordingly.
There was a problem hiding this comment.
Done in d6a22a3. A null schema still returns false. Otherwise the gate now requires a connection and fails with a message that names open() and suggests overriding the method. I noted the compatibility impact in the PR description.
| return connection.chat(messages, tools=self._get_tools(), **merged_kwargs) | ||
| return connection.chat( | ||
| _without_tool_traffic(messages), | ||
| tools=[], |
There was a problem hiding this comment.
The finalization call removes tools but still inherits tool_choice. With vLLM and additional_kwargs={"tool_choice": "auto"}, this produces an invalid request: When using tool_choice, tools must be set.
Could we clear the tool-specific parameters for finalization and add a regression test?
There was a problem hiding this comment.
Fixed in 445374f. When the call carries a schema and no tools, it drops tool_choice, tool_choice_option and parallel_tool_calls, at the top level and inside additional_kwargs. A plain chat call is unchanged. Regression tests added in Java and Python.
…d-output conversion call The conversion call now sends one user message holding the directive and the loop's final answer text, with the schema and no tools. It no longer rebuilds the conversation, prepares the request a second time, or filters tool traffic. The setup gains an explicit call that sends the given messages and tools without the bound prompt, skill-discovery message or bound tools: a chat(messages, tools, modelParams, outputSchema) overload in Java and chat_explicit in Python. The existing chat prepares the request and delegates to it. chatStructured / chat_structured are removed, and both cross-language bridges refuse the explicit call. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
…g request with no tools The explicit setup call, when it carries an output schema and binds no tools, drops tool_choice, tool_choice_option and parallel_tool_calls from the parameters it sends, at the top level and inside additional_kwargs. Providers such as vLLM reject a tool choice on a request that defines no tools, and the conversion call inherits the setup's parameters. The native structured output query sees the same parameters. A plain chat call is unchanged, and the setup's own parameters are not modified. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
…tructured output check willApplyNativeStructuredOutput / will_apply_native_structured_output still answer false for a null schema, and otherwise require a bound connection instead of falling back to the prompt path. A missing connection points at a lifecycle or implementation error, since the framework opens a setup before using it. The error names open() and the option to override the check, which a setup that binds no connection does. Generated-by: Claude Code 2.1.294 (Claude Opus 5.5)
There was a problem hiding this comment.
Thanks for the updates! A few follow-ups:
-
For Python, could we extend
chatwith keyword-only parameters instead of introducingchat_explicit? The provider setup classes do not overridechat, and we can adapt the cross-language wrapper and test implementations together. We do not need to preserve compatibility with external custom subclasses for this change. -
A single user message containing the conversion directive and the final answer works for me.
-
Good catch regarding the schema information in the ReAct loop. For this PR, let's keep the required fields and their descriptions visible to the model during the loop, without requiring JSON output there. The final conversion should still only format the final answer, without inferring missing information.
Could you also open a follow-up issue for the next release to support binding tools and the output schema together during the main loop? For providers/models that support this combination, the schema would make the required fields visible throughout the loop, so we could remove both the separately injected field instructions and the extra final conversion call.
Brings in apache#1214, apache#1208 and apache#1138. ChatModelInvoker's two new cancellation checks move to CancellationUtils, since main removed ModelRoutingResolver.isCancellation. ChatModelInvokerTest keeps this branch's tests plus main's provider cancellation test, and test_flink_runner_context_reconcilable.py takes both sides' imports. Generated-by: GitHub Copilot CLI 1.0.95 (Claude Opus 5.5)
…only chat parameters Replace BaseChatModelSetup.chat_explicit with keyword-only tools and output_schema on chat. Without tools, chat prepares the request as before: bound prompt, skill-discovery message and bound tools. With tools, even an empty list, the messages and tools are sent as given. prompt_args with tools raises ValueError, since that path skips the bound prompt. A schema-less call reaches the connection as on main. Whenever a call carries a schema and binds no tools, the tool-only parameters are omitted, on either path. JavaChatModelSetup.chat refuses tools or output_schema with NotImplementedError before converting anything, mirroring the Java PythonChatModelSetup refusal. The finalization call becomes chat(request, tools=[], output_schema=schema). Generated-by: GitHub Copilot CLI 1.0.95 (Claude Opus 5.5)
… the native path On the native path, ReActAgent no longer drops the schema instruction. It injects a second prompt resource, _default_native_schema_prompt, which asks the final response to include the information for every required field of the schema without requiring JSON. It goes where the JSON instruction goes, right after the first system message. Other answers and failures keep the JSON instruction. A plan built before this resource existed adds no instruction on the native path. Java POJO schemas carry no field descriptions, since Jackson's schema generator drops them; the JSON instruction has the same limit. Generated-by: GitHub Copilot CLI 1.0.95 (Claude Opus 5.5)
|
@wenjin272 Thanks for the review. All four points are in, and main is merged to clear the conflicts (62906bd).
|
|
The code changes look good to me now. Could we also add an e2e test covering the native structured-output path through the tool-calling loop? The existing ReAct e2e tests use It should exercise a tool call, the final native conversion, and parsing of the structured result. Please also verify that the conversion request actually carries the native schema, so the test cannot pass through prompt fallback. |
Adapt the native structured-output path to the ChatResult API from apache#1185: setup and invoker methods return ChatResult, and the parsed value is carried as the event's structured output. Generated-by: GitHub Copilot CLI 1.0.95 (Claude Opus 5.5)
…he ReAct tool loop Adds a ReAct e2e test in each language that runs the tool loop on Ollama with a POJO / BaseModel output schema under the default AUTO strategy, so the final answer is converted in a separate call carrying the native schema. A recording Ollama connection captures every request; the tests check that loop requests carried the tools and no schema, that the last request carried the schema and no tools, that the model called a tool, and that the result is parsed into the schema type. A run that falls back to the prompt path never sends the schema and fails the tests. Generated-by: GitHub Copilot CLI 1.0.95 (Claude Opus 5.5)
wenjin272
left a comment
There was a problem hiding this comment.
Thanks for addressing my comments. LGTM
|
@wenjin272 Thanks for the review! I merged main, which brings in the Each test runs the ReAct tool loop on Ollama (
The prompt path never sends a schema, so a fallback run fails. I checked this by forcing the The Java test checks the schema handed to the connection's Like the other ReAct e2e tests, the new ones rely on the small model calling the tool and getting the math right, so they can need a rerun. The Python one needed two on flink-2.2. |
Linked issue: #912
Purpose of change
When an agent asks for structured output and its chat model can enforce a schema natively, the final answer now comes from a provider call carrying the schema, so the provider enforces the format rather than the prompt. This completes #280: providers could apply a schema, but the runtime never handed one over.
Nothing changes when the schema does not resolve to native; under the default
AUTOthat covers every unlisted model and everyRowTypeInfoschema.Runtime flow
ReActAgentasks the chat model setup whether the schema will travel natively. If yes, the loop prompt describes the schema's fields without asking for JSON; otherwise it keeps the JSON instruction.Key decisions
tool_choice,tool_choice_optionandparallel_tool_calls, also insideadditional_kwargs.NATIVEon an unlisted model keeps the schema.Behavioral Semantics
Interaction decisions
PROMPTAUTOAUTONATIVENATIVERowTypeInfo)NATIVEfails before any model callopen()Behavioral contracts
tools(Java 4-arg, Pythontools=) skips the bound prompt, skill-discovery message and bound tools; otherwise chat prepares the request as before.NATIVEfails with a message naming the connection and the schema.ReActAgentuses the field instruction only on a native answer; any other answer or a failure keeps the JSON instruction.Failure behavior
ChatResponseEventis sent.ReActAgentcheck.Tests
Tests per contract
ChatModelActionRetryTest;test_chat_model_action_retry.pynativeSchemaConvertsTheFinalAnswerInASeparateSchemaCarryingCall;test_finalization_call_carries_answer_directive_and_schemaBaseChatModelTest,test_chat_model_base.py; bridge refusals inPythonChatModelSetupTest,test_java_chat_model.pytestWillApplyNativeStructuredOutputRequiresOpen;test_will_apply_native_structured_output_requires_openReActAgentTest;test_react_agent_schema_instruction.pyChatModelInvokerTestreplay;test_flink_runner_context_reconcilable.pyEnd to end against Ollama (
qwen3:1.7b),ReActAgentTest#testReActAgentNativeStructuredOutputandtest_react_agent_native_structured_output_on_remote_runnerrun the ReAct tool loop with a POJO /BaseModelschema underAUTO. A recording connection checks that the loop requests carried the tools and no schema, that the last request carried the schema and no tools, and that the model called a tool; the typed result is checked too. Forcing the prompt strategy makes both tests fail.Every commit was mutation-tested, and all non-equivalent mutants were killed.
Not verified:
Known limit: Java POJO instructions carry no field descriptions, since Jackson's schema generator drops them (as on the JSON path).
API
BaseChatModelSetup:willApplyNativeStructuredOutputandchat(messages, tools, modelParams, outputSchema)(Java);will_apply_native_structured_outputand keyword-onlytools/output_schemaonchat(Python;prompt_argswithtoolsraises). Users configurestructured_output_strategyinstead.connection.chat(..., outputSchema)callers: an unlisted model now gets the native parameter, so Anthropic drops its JSON prefill, Azure and DashScope reject a caller-suppliedresponse_format, and Python raisesTypeErrorfor a schema Pydantic cannot render.ReActAgentwith a schema registers one more prompt resource; an older plan gets no instruction on the native path.Documentation
doc-neededdoc-not-neededdoc-includedUser documentation for structured output across providers follows in a separate PR.
Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code 2.1.294 and GitHub Copilot CLI 1.0.95 (Claude Opus 5.5)