Skip to content

Commit e7d01cc

Browse files
ZhiXiao-Linclaude
andauthored
fix(llm): stabilize JSON-object generation (force tool_choice + native response_format + robust planner parsing) (#78)
* fix(llm): stabilize JSON-object generation Users reported unstable JSON-object generation. Two root causes: 1. Structured output never used provider-native guarantees: the synthetic `emit_<schema>` tool was only *offered* (no `tool_choice`), and Strict/Json modes collapsed to Tool universally — so the model could emit prose or malformed args ("parse-and-pray"). 2. The planner / pre-analysis JSON path used a naive first-`{`/last-`}` slice with no fence handling and no repair, hard-erroring on fenced/prosey output. Fix (Tier 1 + Tier 2): - LlmClient: additive `native_structured_support()`, `complete_structured()`, `complete_streaming_structured()` with default impls (non-breaking — existing clients/mocks keep working). - structured.rs: capability-aware `resolve_mode` + `StructuredDirective`. Force `tool_choice` (Tool/Auto), and request native `response_format` (Strict→json_schema+strict, Json→json_object) on capable providers, falling back to forced Tool mode otherwise. - anthropic/openai/zhipu: honor the directive (Anthropic forced tool_choice; OpenAI tool_choice + response_format; Zhipu delegates to its inner client). - llm_planner: reuse the robust shared extractor + add one repair retry in `pre_analyze`. - generate_object: stop pre-collapsing Strict/Json to Tool (engine resolves it). Tests: - Deep adversarial unit tests: capability/directive routing, provider wire-format (tool_choice/response_format), adversarial JSON extraction, planner fence/prose/brace-in-string + repair-retry. 1811 lib tests green, fmt + clippy clean. - New `#[ignore]` real-LLM integration test (tests/test_structured_json_real_llm.rs). Validated end-to-end against gpt-4o via the real gateway in .a3s/config.acl: forced tool_choice 5/5 stable (0 repairs), json_object ok, pre_analyze ok. Notes: - Strict json_schema is opt-in only; Auto/Tool never send response_format. - Follow-up: override complete_streaming_structured on providers so the streaming structured path also forces the directive (today it uses the non-forcing default). * fix(llm): force structured directive on the streaming path too Follow-up to the blocking-path fix: providers now override `complete_streaming_structured` so streaming structured generation also forces `tool_choice` / sets native `response_format`, instead of falling back to the non-forcing default. - anthropic/openai: extract `send_streaming` from `complete_streaming`; the trait methods become thin wrappers, and `complete_streaming_structured` applies the directive before executing. The large streaming parsers are unchanged. - zhipu: already delegates to its inner client (no change). - tests: RecordingClient records the streaming directive; new unit test asserts `generate_streaming` forces the tool; new `#[ignore]` real-LLM streaming case. Validated against gpt-4o: streaming forced tool_choice -> 8 partials, valid object (5/5 integration cases pass). --------- Co-authored-by: Claude <claude@anthropic.com>
1 parent 63d2647 commit e7d01cc

9 files changed

Lines changed: 1254 additions & 123 deletions

File tree

core/src/llm/anthropic.rs

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Anthropic Claude LLM client
22
33
use super::http::{default_http_client, normalize_base_url, HttpClient};
4+
use super::structured;
45
use super::types::*;
56
use super::LlmClient;
67
use crate::retry::{AttemptOutcome, RetryConfig};
@@ -152,17 +153,24 @@ impl AnthropicClient {
152153
}
153154
}
154155

155-
#[async_trait]
156-
impl LlmClient for AnthropicClient {
157-
async fn complete(
158-
&self,
159-
messages: &[Message],
160-
system: Option<&str>,
161-
tools: &[ToolDefinition],
162-
) -> Result<LlmResponse> {
156+
impl AnthropicClient {
157+
/// Apply a structured-output directive to an Anthropic request.
158+
///
159+
/// Anthropic supports forced tool choice (`tool_choice`) but has no
160+
/// `response_format`, so only `force_tool` is honored.
161+
fn apply_directive(
162+
request: &mut serde_json::Value,
163+
directive: &structured::StructuredDirective,
164+
) {
165+
if let Some(tool) = &directive.force_tool {
166+
request["tool_choice"] = serde_json::json!({ "type": "tool", "name": tool });
167+
}
168+
}
169+
170+
/// Execute a fully-built (non-streaming) request body.
171+
async fn send_request(&self, request_body: serde_json::Value) -> Result<LlmResponse> {
163172
{
164173
let request_started_at = Instant::now();
165-
let request_body = self.build_request(messages, system, tools);
166174
let url = format!("{}/v1/messages", self.base_url);
167175

168176
let headers = vec![
@@ -259,17 +267,70 @@ impl LlmClient for AnthropicClient {
259267
Ok(llm_response)
260268
}
261269
}
270+
}
271+
272+
#[async_trait]
273+
impl LlmClient for AnthropicClient {
274+
async fn complete(
275+
&self,
276+
messages: &[Message],
277+
system: Option<&str>,
278+
tools: &[ToolDefinition],
279+
) -> Result<LlmResponse> {
280+
self.send_request(self.build_request(messages, system, tools))
281+
.await
282+
}
283+
284+
async fn complete_structured(
285+
&self,
286+
messages: &[Message],
287+
system: Option<&str>,
288+
tools: &[ToolDefinition],
289+
directive: &structured::StructuredDirective,
290+
) -> Result<LlmResponse> {
291+
let mut request_body = self.build_request(messages, system, tools);
292+
Self::apply_directive(&mut request_body, directive);
293+
self.send_request(request_body).await
294+
}
295+
296+
fn native_structured_support(&self) -> structured::NativeStructuredSupport {
297+
structured::NativeStructuredSupport::ForcedTool
298+
}
262299

263300
async fn complete_streaming(
264301
&self,
265302
messages: &[Message],
266303
system: Option<&str>,
267304
tools: &[ToolDefinition],
268305
cancel_token: CancellationToken,
306+
) -> Result<mpsc::Receiver<StreamEvent>> {
307+
self.send_streaming(self.build_request(messages, system, tools), cancel_token)
308+
.await
309+
}
310+
311+
async fn complete_streaming_structured(
312+
&self,
313+
messages: &[Message],
314+
system: Option<&str>,
315+
tools: &[ToolDefinition],
316+
directive: &structured::StructuredDirective,
317+
cancel_token: CancellationToken,
318+
) -> Result<mpsc::Receiver<StreamEvent>> {
319+
let mut request_body = self.build_request(messages, system, tools);
320+
Self::apply_directive(&mut request_body, directive);
321+
self.send_streaming(request_body, cancel_token).await
322+
}
323+
}
324+
325+
impl AnthropicClient {
326+
/// Execute a fully-built streaming request body (sets `stream: true`).
327+
async fn send_streaming(
328+
&self,
329+
mut request_body: serde_json::Value,
330+
cancel_token: CancellationToken,
269331
) -> Result<mpsc::Receiver<StreamEvent>> {
270332
{
271333
let request_started_at = Instant::now();
272-
let mut request_body = self.build_request(messages, system, tools);
273334
request_body["stream"] = serde_json::json!(true);
274335

275336
let url = format!("{}/v1/messages", self.base_url);
@@ -739,4 +800,40 @@ mod tests {
739800
assert_eq!(req["max_tokens"], 16_000);
740801
assert_eq!(req["thinking"]["budget_tokens"], 8_000);
741802
}
803+
804+
#[test]
805+
fn test_apply_directive_forces_tool_choice() {
806+
let mut req = serde_json::json!({ "model": "m", "messages": [] });
807+
let directive = structured::StructuredDirective {
808+
force_tool: Some("emit_person".to_string()),
809+
response_format: None,
810+
};
811+
AnthropicClient::apply_directive(&mut req, &directive);
812+
assert_eq!(req["tool_choice"]["type"], "tool");
813+
assert_eq!(req["tool_choice"]["name"], "emit_person");
814+
}
815+
816+
#[test]
817+
fn test_apply_directive_ignores_response_format() {
818+
// Anthropic has no response_format; both a response_format-only and an
819+
// empty directive must be no-ops.
820+
let mut req = serde_json::json!({ "model": "m" });
821+
AnthropicClient::apply_directive(
822+
&mut req,
823+
&structured::StructuredDirective {
824+
force_tool: None,
825+
response_format: Some(structured::ResponseFormat::JsonObject),
826+
},
827+
);
828+
assert!(req.get("response_format").is_none());
829+
assert!(req.get("tool_choice").is_none());
830+
}
831+
832+
#[test]
833+
fn test_native_structured_support_is_forced_tool() {
834+
assert_eq!(
835+
make_client().native_structured_support(),
836+
structured::NativeStructuredSupport::ForcedTool
837+
);
838+
}
742839
}

core/src/llm/mod.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,44 @@ pub trait LlmClient: Send + Sync {
4848
tools: &[ToolDefinition],
4949
cancel_token: CancellationToken,
5050
) -> Result<mpsc::Receiver<StreamEvent>>;
51+
52+
/// Report the strongest provider-native structured-output enforcement this
53+
/// client supports. Used by [`structured`] to decide whether to force a
54+
/// tool call, request a native `response_format`, or fall back to
55+
/// prompt-and-parse. Defaults to no native support.
56+
fn native_structured_support(&self) -> structured::NativeStructuredSupport {
57+
structured::NativeStructuredSupport::None
58+
}
59+
60+
/// Complete a conversation while honoring a structured-output directive
61+
/// (forced `tool_choice` and/or native `response_format`).
62+
///
63+
/// The default implementation ignores the directive and behaves exactly
64+
/// like [`LlmClient::complete`], so existing clients keep working unchanged;
65+
/// providers that support native structured output override this.
66+
async fn complete_structured(
67+
&self,
68+
messages: &[Message],
69+
system: Option<&str>,
70+
tools: &[ToolDefinition],
71+
_directive: &structured::StructuredDirective,
72+
) -> Result<LlmResponse> {
73+
self.complete(messages, system, tools).await
74+
}
75+
76+
/// Streaming counterpart of [`LlmClient::complete_structured`]. Defaults to
77+
/// [`LlmClient::complete_streaming`], ignoring the directive.
78+
async fn complete_streaming_structured(
79+
&self,
80+
messages: &[Message],
81+
system: Option<&str>,
82+
tools: &[ToolDefinition],
83+
_directive: &structured::StructuredDirective,
84+
cancel_token: CancellationToken,
85+
) -> Result<mpsc::Receiver<StreamEvent>> {
86+
self.complete_streaming(messages, system, tools, cancel_token)
87+
.await
88+
}
5189
}
5290

5391
// Include test modules — these reference internal types via crate paths

0 commit comments

Comments
 (0)