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) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-13 17:07:22 +08:00
co-authored by Claude Opus 4.6
parent 2422ea39c6
commit 2218bbcfef
4 changed files with 392 additions and 6 deletions
+69 -2
View File
@@ -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<CoderUpdate> {
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<Record<string, unknown>> = [
{ 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<string, unknown>; 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<Record<string, unknown>> = [];
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,
+69 -2
View File
@@ -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<Record<string, unknown>> = [
{ 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<string, unknown>; 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<Record<string, unknown>> = [];
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,
+74 -2
View File
@@ -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<SearcherUpdate> {
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<Record<string, unknown>> = [
{ 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<string, unknown>; 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<Record<string, unknown>> = [];
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,
+180
View File
@@ -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<string, unknown>;
strict?: boolean;
}
/** A tool definition as used by our agent nodes (name + description + zod schema). */
export interface AgentToolDef {
name: string;
description: string;
schema: ZodObject<any>;
}
/** 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<string, unknown>; 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<string, unknown> {
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<string, unknown> = {};
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<string, unknown> = { 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<Record<string, unknown>>;
tools?: readonly AgentToolDef[];
reasoningEffort?: "low" | "medium" | "high";
maxOutputTokens?: number;
}): Promise<ReasoningResult> {
const url = `${config.azureOpenAI.endpoint}/openai/v1/responses`;
const body: Record<string, unknown> = {
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<string, unknown> = {};
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 ?? "",
};
}