From 2218bbcfef3911093af19a80fe1fd6e4acdaf1f3 Mon Sep 17 00:00:00 2001 From: gongzhiyong Date: Mon, 13 Apr 2026 17:07:22 +0800 Subject: [PATCH] feat: integrate Azure OpenAI Responses API for Pro mode COT reasoning When modelMode is "pro", searcher/enterprise/coder agents now call the Responses API directly via fetch to obtain reasoning summary blocks. The reasoning text is injected as a { type: "thinking" } content block in the AIMessage, while flash/auto modes remain unchanged using the existing Chat Completions path through AzureChatOpenAI. Key changes: - create-llm.ts: add invokeWithReasoning() with inline zod-to-JSON-Schema converter and Responses API output parser (reasoning/message/function_call) - All three agent nodes: Pro branch converts LangChain messages to Responses API format (developer role, function_call_output for tool results, function_call for AI tool invocations) Co-Authored-By: Claude Opus 4.6 (1M context) --- langgraph/src/agent/coder/nodes/agent.ts | 71 ++++++- langgraph/src/agent/enterprise/nodes/agent.ts | 71 ++++++- langgraph/src/agent/searcher/nodes/agent.ts | 76 +++++++- langgraph/src/agent/utils/create-llm.ts | 180 ++++++++++++++++++ 4 files changed, 392 insertions(+), 6 deletions(-) diff --git a/langgraph/src/agent/coder/nodes/agent.ts b/langgraph/src/agent/coder/nodes/agent.ts index 9b8fb6e..61fb36a 100644 --- a/langgraph/src/agent/coder/nodes/agent.ts +++ b/langgraph/src/agent/coder/nodes/agent.ts @@ -1,10 +1,11 @@ /** * Coder agent node: LLM decides whether to write/execute code. */ -import { createLlm, type ModelMode } from "@/agent/utils/create-llm"; +import { createLlm, type ModelMode, invokeWithReasoning, type AgentToolDef } from "@/agent/utils/create-llm"; import { truncateMessages } from "@/agent/utils/truncate-messages"; import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls"; import { injectThinking, stripThinkingBlocks } from "@/agent/utils/inject-thinking"; +import { AIMessage } from "@langchain/core/messages"; import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { CoderState, CoderUpdate } from "../types.js"; import { z } from "zod"; @@ -91,11 +92,77 @@ export async function agentNode( config: LangGraphRunnableConfig, ): Promise { const modelMode = ((config.configurable?.modelMode as string) ?? "auto") as ModelMode; - const llm = createLlm({ modelMode }); const truncated = truncateMessages(state.messages); // Clean up polluted messages and strip thinking blocks before sending to LLM const cleanedMessages = stripThinkingBlocks(cleanPollutedToolCalls(truncated)); + + // --- Pro mode: use Responses API for COT reasoning summary --- + if (modelMode === "pro") { + // Build plain message array for Responses API + const plainMessages: Array> = [ + { role: "developer", content: SYSTEM_PROMPT }, + ]; + for (const m of cleanedMessages) { + const msgType = m.getType(); + const textContent = typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? m.content + .filter((b: any) => typeof b === "object" && b.type === "text") + .map((b: any) => b.text) + .join("\n") || "" + : ""; + + if (msgType === "tool") { + plainMessages.push({ + type: "function_call_output", + call_id: (m as any).tool_call_id ?? "", + output: textContent, + }); + } else if (msgType === "ai") { + const aiToolCalls = (m as any).tool_calls as Array<{ name: string; args: Record; id: string }> | undefined; + if (aiToolCalls && aiToolCalls.length > 0) { + for (const tc of aiToolCalls) { + plainMessages.push({ + type: "function_call", + name: tc.name, + arguments: JSON.stringify(tc.args ?? {}), + call_id: tc.id, + }); + } + } + if (textContent) { + plainMessages.push({ role: "assistant", content: textContent }); + } + } else { + plainMessages.push({ role: "user", content: textContent }); + } + } + + const result = await invokeWithReasoning({ + messages: plainMessages, + tools: CODER_TOOLS as unknown as AgentToolDef[], + reasoningEffort: "high", + maxOutputTokens: 8192, + }); + + const contentBlocks: Array> = []; + if (result.reasoning) { + contentBlocks.push({ type: "thinking", thinking: result.reasoning }); + } + contentBlocks.push({ type: "text", text: result.content || "" }); + + const aiMessage = new AIMessage({ + content: contentBlocks, + tool_calls: result.toolCalls.length > 0 ? result.toolCalls : undefined, + }); + + return { messages: [aiMessage], timestamp: Date.now() }; + } + + // --- Flash / Auto mode: existing Chat Completions path --- + const llm = createLlm({ modelMode }); const messagesWithSystem = [ { role: "system" as const, content: SYSTEM_PROMPT }, ...cleanedMessages, diff --git a/langgraph/src/agent/enterprise/nodes/agent.ts b/langgraph/src/agent/enterprise/nodes/agent.ts index 21e53c8..5ae0d6b 100644 --- a/langgraph/src/agent/enterprise/nodes/agent.ts +++ b/langgraph/src/agent/enterprise/nodes/agent.ts @@ -2,10 +2,11 @@ * Agent node: LLM thinks and decides whether to call tools. * Does NOT execute tools — only returns the AI message (possibly with tool_calls). */ -import { createLlm, type ModelMode } from "@/agent/utils/create-llm"; +import { createLlm, type ModelMode, invokeWithReasoning, type AgentToolDef } from "@/agent/utils/create-llm"; import { truncateMessages } from "@/agent/utils/truncate-messages"; import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls"; import { injectThinking, stripThinkingBlocks } from "@/agent/utils/inject-thinking"; +import { AIMessage } from "@langchain/core/messages"; import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { EnterpriseState, EnterpriseUpdate } from "../types.js"; import { filterTools } from "./tool-defs.js"; @@ -132,12 +133,78 @@ export async function agentNode( const enabledTools = config.configurable?.enabledTools as string[] | undefined; const temperature = (config.configurable as { temperature?: number } | undefined)?.temperature; - const llm = createLlm({ modelMode, temperature }); const tools = filterTools(modelMode, enabledTools); const truncated = truncateMessages(state.messages); // Clean up polluted messages and strip thinking blocks before sending to LLM const cleanedMessages = stripThinkingBlocks(cleanPollutedToolCalls(truncated)); + + // --- Pro mode: use Responses API for COT reasoning summary --- + if (modelMode === "pro") { + // Build plain message array for Responses API + const plainMessages: Array> = [ + { role: "developer", content: SYSTEM_PROMPT }, + ]; + for (const m of cleanedMessages) { + const msgType = m.getType(); + const textContent = typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? m.content + .filter((b: any) => typeof b === "object" && b.type === "text") + .map((b: any) => b.text) + .join("\n") || "" + : ""; + + if (msgType === "tool") { + plainMessages.push({ + type: "function_call_output", + call_id: (m as any).tool_call_id ?? "", + output: textContent, + }); + } else if (msgType === "ai") { + const aiToolCalls = (m as any).tool_calls as Array<{ name: string; args: Record; id: string }> | undefined; + if (aiToolCalls && aiToolCalls.length > 0) { + for (const tc of aiToolCalls) { + plainMessages.push({ + type: "function_call", + name: tc.name, + arguments: JSON.stringify(tc.args ?? {}), + call_id: tc.id, + }); + } + } + if (textContent) { + plainMessages.push({ role: "assistant", content: textContent }); + } + } else { + plainMessages.push({ role: "user", content: textContent }); + } + } + + const result = await invokeWithReasoning({ + messages: plainMessages, + tools: tools.length > 0 ? (tools as unknown as AgentToolDef[]) : undefined, + reasoningEffort: "high", + maxOutputTokens: 8192, + }); + + const contentBlocks: Array> = []; + if (result.reasoning) { + contentBlocks.push({ type: "thinking", thinking: result.reasoning }); + } + contentBlocks.push({ type: "text", text: result.content || "" }); + + const aiMessage = new AIMessage({ + content: contentBlocks, + tool_calls: result.toolCalls.length > 0 ? result.toolCalls : undefined, + }); + + return { messages: [aiMessage], timestamp: Date.now() }; + } + + // --- Flash / Auto mode: existing Chat Completions path --- + const llm = createLlm({ modelMode, temperature }); const messagesWithSystem = [ { role: "system" as const, content: SYSTEM_PROMPT }, ...cleanedMessages, diff --git a/langgraph/src/agent/searcher/nodes/agent.ts b/langgraph/src/agent/searcher/nodes/agent.ts index 21b4efc..7c3d29f 100644 --- a/langgraph/src/agent/searcher/nodes/agent.ts +++ b/langgraph/src/agent/searcher/nodes/agent.ts @@ -1,10 +1,11 @@ /** * Searcher agent node: LLM plans search strategy and decides whether to continue searching. */ -import { createLlm, type ModelMode } from "@/agent/utils/create-llm"; +import { createLlm, type ModelMode, invokeWithReasoning, type AgentToolDef } from "@/agent/utils/create-llm"; import { truncateMessages } from "@/agent/utils/truncate-messages"; import { cleanPollutedToolCalls } from "@/agent/utils/clean-polluted-tool-calls"; import { injectThinking, stripThinkingBlocks } from "@/agent/utils/inject-thinking"; +import { AIMessage } from "@langchain/core/messages"; import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { SearcherState, SearcherUpdate } from "../types.js"; import { z } from "zod"; @@ -91,11 +92,82 @@ export async function agentNode( config: LangGraphRunnableConfig, ): Promise { const modelMode = ((config.configurable?.modelMode as string) ?? "auto") as ModelMode; - const llm = createLlm({ modelMode }); const truncated = truncateMessages(state.messages); // Clean up polluted messages and strip thinking blocks before sending to LLM const cleanedMessages = stripThinkingBlocks(cleanPollutedToolCalls(truncated)); + + // --- Pro mode: use Responses API for COT reasoning summary --- + if (modelMode === "pro") { + // Build plain message array for Responses API + // Note: tool results use { type: "function_call_output", call_id, output } format + // and AI tool_calls use { type: "function_call", name, arguments, call_id } format + const plainMessages: Array> = [ + { role: "developer", content: SYSTEM_PROMPT }, + ]; + for (const m of cleanedMessages) { + const msgType = m.getType(); + const textContent = typeof m.content === "string" + ? m.content + : Array.isArray(m.content) + ? m.content + .filter((b: any) => typeof b === "object" && b.type === "text") + .map((b: any) => b.text) + .join("\n") || "" + : ""; + + if (msgType === "tool") { + // Responses API format for tool results + plainMessages.push({ + type: "function_call_output", + call_id: (m as any).tool_call_id ?? "", + output: textContent, + }); + } else if (msgType === "ai") { + // If AI message has tool_calls, emit function_call items before the text + const aiToolCalls = (m as any).tool_calls as Array<{ name: string; args: Record; id: string }> | undefined; + if (aiToolCalls && aiToolCalls.length > 0) { + for (const tc of aiToolCalls) { + plainMessages.push({ + type: "function_call", + name: tc.name, + arguments: JSON.stringify(tc.args ?? {}), + call_id: tc.id, + }); + } + } + if (textContent) { + plainMessages.push({ role: "assistant", content: textContent }); + } + } else { + plainMessages.push({ role: "user", content: textContent }); + } + } + + const result = await invokeWithReasoning({ + messages: plainMessages, + tools: SEARCHER_TOOLS as unknown as AgentToolDef[], + reasoningEffort: "high", + maxOutputTokens: 8192, + }); + + // Build content blocks: thinking (if any) + text (if any) + const contentBlocks: Array> = []; + if (result.reasoning) { + contentBlocks.push({ type: "thinking", thinking: result.reasoning }); + } + contentBlocks.push({ type: "text", text: result.content || "" }); + + const aiMessage = new AIMessage({ + content: contentBlocks, + tool_calls: result.toolCalls.length > 0 ? result.toolCalls : undefined, + }); + + return { messages: [aiMessage], timestamp: Date.now() }; + } + + // --- Flash / Auto mode: existing Chat Completions path --- + const llm = createLlm({ modelMode }); const messagesWithSystem = [ { role: "system" as const, content: SYSTEM_PROMPT }, ...cleanedMessages, diff --git a/langgraph/src/agent/utils/create-llm.ts b/langgraph/src/agent/utils/create-llm.ts index cbb2387..3768c1e 100644 --- a/langgraph/src/agent/utils/create-llm.ts +++ b/langgraph/src/agent/utils/create-llm.ts @@ -1,5 +1,6 @@ import { AzureChatOpenAI } from "@langchain/openai"; import { config } from "@/agent/utils/config"; +import type { ZodObject, ZodTypeAny } from "zod"; export type ModelMode = "flash" | "pro" | "auto"; @@ -39,3 +40,182 @@ export function createLlm(options?: { modelKwargs: { max_completion_tokens: maxTokens }, }); } + +// --------------------------------------------------------------------------- +// Responses API types +// --------------------------------------------------------------------------- + +/** A tool definition in the format expected by the Responses API. */ +interface ResponsesApiTool { + type: "function"; + name: string; + description: string; + parameters: Record; + strict?: boolean; +} + +/** A tool definition as used by our agent nodes (name + description + zod schema). */ +export interface AgentToolDef { + name: string; + description: string; + schema: ZodObject; +} + +/** Result returned by invokeWithReasoning. */ +export interface ReasoningResult { + /** The reasoning summary text (COT). Empty string if none. */ + reasoning: string; + /** The assistant's text reply. Empty string if only tool_calls. */ + content: string; + /** Converted tool_calls compatible with LangChain AIMessage.tool_calls. */ + toolCalls: Array<{ name: string; args: Record; id: string; type: "tool_call" }>; + /** Raw response_id from the API. */ + responseId: string; +} + +/** + * Minimal zod-to-JSON-Schema converter for our flat tool schemas. + * Handles string, number, enum, optional, array, and object fields. + */ +function zodToJsonSchema(schema: ZodTypeAny): Record { + const def = (schema as any)._def; + const typeName: string = def?.typeName ?? ""; + + if (typeName === "ZodString") { + return { type: "string", ...(def.description ? { description: def.description } : {}) }; + } + if (typeName === "ZodNumber") { + return { type: "number", ...(def.description ? { description: def.description } : {}) }; + } + if (typeName === "ZodEnum") { + return { type: "string", enum: def.values, ...(def.description ? { description: def.description } : {}) }; + } + if (typeName === "ZodOptional") { + return zodToJsonSchema(def.innerType); + } + if (typeName === "ZodArray") { + return { type: "array", items: zodToJsonSchema(def.type), ...(def.description ? { description: def.description } : {}) }; + } + if (typeName === "ZodObject") { + const shape = def.shape?.() ?? {}; + const properties: Record = {}; + const required: string[] = []; + for (const [key, val] of Object.entries(shape)) { + const fieldDef = (val as any)?._def; + properties[key] = zodToJsonSchema(val as ZodTypeAny); + // A field is required unless it's ZodOptional + if (fieldDef?.typeName !== "ZodOptional") { + required.push(key); + } + } + const result: Record = { type: "object", properties }; + if (required.length > 0) result.required = required; + if (def.description) result.description = def.description; + return result; + } + // Fallback + return { type: "string" }; +} + +/** + * Convert our agent tool definitions to the Responses API function tool format. + */ +function toResponsesTools(tools: readonly AgentToolDef[]): ResponsesApiTool[] { + return tools.map((t) => ({ + type: "function" as const, + name: t.name, + description: t.description, + parameters: zodToJsonSchema(t.schema), + strict: false, + })); +} + +/** + * Call Azure OpenAI Responses API to get reasoning summary + answer. + * Used only in Pro mode to surface COT thinking blocks. + * + * The Responses API returns an `output` array containing: + * - `{ type: "reasoning", summary: [{ type: "summary_text", text }] }` + * - `{ type: "message", content: [{ type: "output_text", text }] }` + * - `{ type: "function_call", name, arguments, call_id }` + */ +export async function invokeWithReasoning(options: { + messages: Array>; + tools?: readonly AgentToolDef[]; + reasoningEffort?: "low" | "medium" | "high"; + maxOutputTokens?: number; +}): Promise { + const url = `${config.azureOpenAI.endpoint}/openai/v1/responses`; + + const body: Record = { + model: config.azureOpenAI.deployment, + input: options.messages, + max_output_tokens: options.maxOutputTokens ?? 8192, + reasoning: { + effort: options.reasoningEffort ?? "medium", + summary: "detailed", + }, + }; + + if (options.tools && options.tools.length > 0) { + body.tools = toResponsesTools(options.tools); + } + + const resp = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "api-key": config.azureOpenAI.apiKey, + }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const errorBody = await resp.text(); + throw new Error( + `[invokeWithReasoning] Responses API returned ${resp.status}: ${errorBody}`, + ); + } + + const data = await resp.json(); + const output: any[] = data.output ?? []; + + let reasoning = ""; + let content = ""; + const toolCalls: ReasoningResult["toolCalls"] = []; + + for (const item of output) { + if (item.type === "reasoning" && Array.isArray(item.summary)) { + reasoning = item.summary + .filter((s: any) => s.type === "summary_text") + .map((s: any) => s.text) + .join("\n"); + } else if (item.type === "message" && Array.isArray(item.content)) { + content = item.content + .filter((c: any) => c.type === "output_text") + .map((c: any) => c.text) + .join("\n"); + } else if (item.type === "function_call") { + // Responses API returns: { type: "function_call", name, arguments (string), call_id } + let parsedArgs: Record = {}; + try { + parsedArgs = JSON.parse(item.arguments ?? "{}"); + } catch { + parsedArgs = {}; + } + toolCalls.push({ + name: item.name, + args: parsedArgs, + id: item.call_id ?? `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + type: "tool_call", + }); + } + } + + return { + reasoning, + content, + toolCalls, + responseId: data.id ?? "", + }; +}