Skip to content

Commit ade8b8a

Browse files
fix(langchain): properly retrieve structured output from thinking block (#9623)
1 parent a2df2d4 commit ade8b8a

File tree

3 files changed

+162
-4
lines changed

3 files changed

+162
-4
lines changed

.changeset/khaki-fishes-compare.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"langchain": patch
3+
---
4+
5+
fix(langchain): properly retrieve structured output from thinking block

libs/langchain/src/agents/responses.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,19 +165,45 @@ export class ProviderStrategy<T = unknown> {
165165
/**
166166
* Parse tool arguments according to the schema. If the response is not valid, return undefined.
167167
*
168-
* @param toolArgs - The arguments from the tool call
168+
* @param response - The AI message response to parse
169169
* @returns The parsed response according to the schema type
170170
*/
171171
parse(response: AIMessage) {
172172
/**
173-
* return if the response doesn't contain valid content
173+
* Extract text content from the response.
174+
* Handles both string content and array content (e.g., from thinking models).
174175
*/
175-
if (typeof response.content !== "string" || response.content === "") {
176+
let textContent: string | undefined;
177+
178+
if (typeof response.content === "string") {
179+
textContent = response.content;
180+
} else if (Array.isArray(response.content)) {
181+
/**
182+
* For thinking models, content is an array with thinking blocks and text blocks.
183+
* Extract the text from text blocks.
184+
*/
185+
for (const block of response.content) {
186+
if (
187+
typeof block === "object" &&
188+
block !== null &&
189+
"type" in block &&
190+
block.type === "text" &&
191+
"text" in block &&
192+
typeof block.text === "string"
193+
) {
194+
textContent = block.text;
195+
break; // Use the first text block found
196+
}
197+
}
198+
}
199+
200+
// Return if no valid text content found
201+
if (!textContent || textContent === "") {
176202
return;
177203
}
178204

179205
try {
180-
const content = JSON.parse(response.content);
206+
const content = JSON.parse(textContent);
181207
const validator = new Validator(this.schema);
182208
const result = validator.validate(content);
183209
if (!result.valid) {

libs/langchain/src/agents/tests/responses.int.test.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,130 @@ describe("Strategy Selection", () => {
245245
expect(hasExtractToolCalls).toBe(false);
246246
});
247247
});
248+
249+
describe("Thinking models with responseFormat", () => {
250+
it("should parse structured response from thinking model with providerStrategy", async () => {
251+
// Use Anthropic model with thinking enabled
252+
const model = new ChatAnthropic({
253+
model: "claude-haiku-4-5-20251001",
254+
thinking: { type: "enabled", budget_tokens: 5000 },
255+
maxTokens: 15000,
256+
});
257+
258+
// Define tools that should be called before returning structured output
259+
const { tool: getDetailByName, mock: getDetailByNameMock } = makeTool(
260+
({ name }: { name: string }) =>
261+
JSON.stringify({
262+
name,
263+
color: "blue",
264+
size: "large",
265+
}),
266+
{
267+
name: "get_detail_by_name",
268+
description: "Get details about a shape by its name",
269+
schema: z.object({
270+
name: z.string(),
271+
}),
272+
}
273+
);
274+
275+
const { tool: deleteShape, mock: deleteShapeMock } = makeTool(
276+
({ name }: { name: string }) =>
277+
`Shape "${name}" has been deleted successfully.`,
278+
{
279+
name: "delete_shape",
280+
description: "Delete a shape by its name",
281+
schema: z.object({
282+
name: z.string(),
283+
}),
284+
}
285+
);
286+
287+
const ResponseSchema = z.object({
288+
shapeName: z.string().describe("The name of the shape"),
289+
details: z.string().describe("Details about the shape"),
290+
wasDeleted: z.boolean().describe("Whether the shape was deleted"),
291+
});
292+
293+
const agent = createAgent({
294+
model,
295+
tools: [getDetailByName, deleteShape],
296+
systemPrompt: `You are a helpful shape management assistant.
297+
When asked to perform operations on shapes, you MUST:
298+
1. First use the get_detail_by_name tool to get information about the shape
299+
2. Then use the delete_shape tool if deletion is requested
300+
3. Only after using the tools, return the structured response`,
301+
responseFormat: providerStrategy(ResponseSchema),
302+
});
303+
304+
const result = await agent.invoke({
305+
messages: [
306+
new HumanMessage(
307+
"Please delete the circle shape and tell me about it first"
308+
),
309+
],
310+
});
311+
312+
// Verify user tools were called
313+
expect(getDetailByNameMock).toHaveBeenCalledTimes(1);
314+
expect(deleteShapeMock).toHaveBeenCalledTimes(1);
315+
316+
// Verify structured response is present and correctly parsed
317+
// This was previously undefined due to thinking model content being an array
318+
expect(result.structuredResponse).toBeDefined();
319+
expect(result.structuredResponse.shapeName).toBe("circle");
320+
expect(result.structuredResponse.wasDeleted).toBe(true);
321+
expect(typeof result.structuredResponse.details).toBe("string");
322+
323+
// Verify no extract- tool calls (providerStrategy uses native structured output)
324+
const hasExtractToolCalls = result.messages.some(
325+
(msg) =>
326+
AIMessage.isInstance(msg) &&
327+
msg.tool_calls?.some((tc) => tc.name.startsWith("extract-"))
328+
);
329+
expect(hasExtractToolCalls).toBe(false);
330+
331+
// Verify AI messages contain thinking blocks (array content)
332+
const aiMessages = result.messages.filter(AIMessage.isInstance);
333+
const hasThinkingContent = aiMessages.some(
334+
(msg) =>
335+
Array.isArray(msg.content) &&
336+
msg.content.some(
337+
(block: any) =>
338+
typeof block === "object" &&
339+
block !== null &&
340+
block.type === "thinking"
341+
)
342+
);
343+
expect(hasThinkingContent).toBe(true);
344+
});
345+
346+
it("should fail with toolStrategy and thinking models due to tool_choice conflict", async () => {
347+
// Use Anthropic model with thinking enabled
348+
const model = new ChatAnthropic({
349+
model: "claude-haiku-4-5-20251001",
350+
thinking: { type: "enabled", budget_tokens: 5000 },
351+
maxTokens: 15000,
352+
});
353+
354+
const ResponseSchema = z.object({
355+
answer: z.string(),
356+
});
357+
358+
const agent = createAgent({
359+
model,
360+
tools: [],
361+
// toolStrategy uses tool_choice: "any" which conflicts with thinking mode
362+
responseFormat: toolStrategy(ResponseSchema),
363+
});
364+
365+
// This should fail because Anthropic doesn't allow thinking with forced tool use
366+
await expect(
367+
agent.invoke({
368+
messages: [new HumanMessage("What is 2+2?")],
369+
})
370+
).rejects.toThrow(
371+
/Thinking may not be enabled when tool_choice forces tool use/
372+
);
373+
});
374+
});

0 commit comments

Comments
 (0)