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:
co-authored by
Claude Opus 4.6
parent
2422ea39c6
commit
2218bbcfef
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user