The graph state update fails when called from the sub-agent's tool #399

Closed
opened 2026-02-15 18:16:26 -05:00 by yindo · 2 comments
Owner

Originally created by @July-ing on GitHub (Jan 27, 2026).

Checked other resources

  • I added a very descriptive title to this issue.
  • I searched the LangGraph.js documentation with the integrated search.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangGraph.js rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package).

Example Code

import { Annotation, Command, Messages, messagesStateReducer, START, StateGraph } from "@langchain/langgraph";
import { AIMessage, BaseMessage, createAgent, HumanMessage, tool, ToolMessage } from "langchain";
import { createZhipuModel } from "../../utils/ai-models";
import z from "zod";
import { nanoid } from 'nanoid';
import { RunnableConfig } from "@langchain/core/runnables";


const DemoAgentState = Annotation.Root({
  messages: Annotation<BaseMessage[], Messages>({
    reducer: messagesStateReducer,
    default: () => [],
  }),
  content: Annotation<string, string>({
    reducer: (x: string, y: string) => y ?? x ?? '',
    default: () => '',
  }),
});

export const demoSaveDocumentTool = tool(
  async (
    input: { content: string },
    config
  ) => {
    const aiMessage = new AIMessage({
      content: `save content to state`,
    });

    const toolMessage = new ToolMessage({
      content: `save content success`,
      name: 'demo_save_document',
      tool_call_id: config.toolCall.id,
      status: 'success',
    });

    return new Command({
      update: {
        content: input.content, // not work
        messages: [aiMessage, toolMessage],
      },
    });
  },
  {
    name: 'demo_save_document',
    schema: z.object({
      content: z.string().describe('test content'),
    }),
    description: 'save content to state',
  }
);

const demoFormatAgent = createAgent({
  model: createZhipuModel(),
  tools: [demoSaveDocumentTool],
  name: 'format_agent',
  systemPrompt: formatPrompt,
  stateSchema: DemoAgentState,
});

const createDemoHandoffTool = ({
  agentName,
  agentDescription,
}: {
  agentName: string;
  agentDescription: string;
}) => {
  const toolName = `transfer_to_${agentName}`;

  const handoffTool = tool(
    async (_, config) => {

      const toolMessage = new ToolMessage({
        content: `Successfully transferred to ${agentName}`,
        name: toolName,
        tool_call_id: config.toolCall.id,
      });

      return new Command({
        goto: agentName,
        graph: Command.PARENT,
        update: {
          messages: [toolMessage],
        },
      });
    },
    {
      name: toolName,
      schema: z.object({}),
      description: agentDescription ?? `transfer to ${agentName} agent`,
    }
  );

  return handoffTool;
};

const createDemoHandoffBackMessages = (
  agentName: string,
  supervisorName: string
): [AIMessage, ToolMessage] => {
  const toolCallId = nanoid();
  const toolName = `transfer_back_to_${supervisorName}`;
  const toolCalls = [{ name: toolName, args: {}, id: toolCallId }];

  return [
    new AIMessage({
      content: `Transferring back to ${supervisorName}`,
      tool_calls: toolCalls,
      name: agentName,
    }),
    new ToolMessage({
      content: `Successfully transferred back to ${supervisorName}`,
      name: toolName,
      tool_call_id: toolCallId,
    }),
  ];
};

const createDemoSupervisor = ({
  formatAgent,
  llm,
  tools,
  prompt,
  stateSchema,
}: any): any => {
  const formatAgentName = 'format_agent'
  const handoffTool = createDemoHandoffTool({
    agentName: formatAgentName,
    agentDescription: `transfer to ${formatAgentName}`,
  });

  formatAgent.name = formatAgent.name || formatAgent.options?.name || formatAgent.graph.name;

  const allTools = [...(tools ?? []), handoffTool];

  const supervisorAgent = createAgent({
    name: 'supervisor',
    model: llm,
    tools: allTools,
    systemPrompt: prompt,
    stateSchema: stateSchema,
  });

  supervisorAgent.name =
    supervisorAgent.name ||
    supervisorAgent.options?.name ||
    supervisorAgent.graph.name;

  const supervisorFunction = async (
    state: any,
    config?: RunnableConfig
  ) => {
    const output = await (supervisorAgent as any).invoke(state, config);
    return { ...state, messages: output.messages };
  };

  const formatAgentFunction = async (state: any, config?: RunnableConfig) => {

    try {
      const output = await (formatAgent as any).invoke(state, config);

      const [handoffAIMsg, handoffToolMsg] = createDemoHandoffBackMessages(
        formatAgentName,
        'supervisor'
      );

      output.messages = output.messages || [];
      output.messages.push(handoffAIMsg, handoffToolMsg);

      return { ...state, messages: output.messages };
    } catch (error) {
      throw error;
    }
  };

  const builder = new StateGraph(stateSchema)
    .addNode('supervisor', supervisorFunction, {
      ends: [formatAgentName],
    } as any)
    .addNode(formatAgentName, formatAgentFunction, {
      subgraphs: [formatAgent],
    } as any)
    .addEdge(START, 'supervisor')
    .addEdge(formatAgentName, 'supervisor');

  return builder;
};

export const demoDemoModalAgent = createDemoSupervisor({
  llm: createZhipuModel(),
  tools: [],
  formatAgent: demoFormatAgent,
  stateSchema: DemoAgentState,
  prompt: supervisorPrompt,
}).compile({});


const stream = await demoDemoModalAgent.stream({
  messages: [
    new HumanMessage({
      id: nanoid(),
      content: message,
    }),
  ],
}, {
  streamMode: 'messages',
});

Error Message and Stack Trace (if applicable)

No response

Description

I took this example as a reference use-inside-tools, when the demo_save_document tool was executed, I saw two messages: save content to state and save content success, but the content under state remained as an empty string '', and the update did not take effect. Is my usage incorrect?

System Info

Node version: v20.20.0
Operating system: darwin arm64
Package manager: pnpm
Package manager version: N/A

@langchain/anthropic -> 1.3.12
zod -> 4.3.5
@langchain/core -> 1.1.17
langsmith -> 0.4.9
@langchain/google-genai -> 2.1.13
@langchain/langgraph -> 1.1.2
@langchain/langgraph-checkpoint -> 1.0.0
@langchain/langgraph-sdk -> 1.5.5
@langchain/langgraph-cli -> 1.1.11
@langchain/langgraph-api -> 1.1.11
@langchain/langgraph-ui -> 1.1.11
@langchain/langgraph-supervisor -> 1.0.1
@langchain/mcp-adapters -> 1.1.2
@langchain/openai -> 1.2.3
langchain -> 1.1.0

Originally created by @July-ing on GitHub (Jan 27, 2026). ### Checked other resources - [x] I added a very descriptive title to this issue. - [x] I searched the LangGraph.js documentation with the integrated search. - [x] I used the GitHub search to find a similar question and didn't find it. - [x] I am sure that this is a bug in LangGraph.js rather than my code. - [x] The bug is not resolved by updating to the latest stable version of LangGraph (or the specific integration package). ### Example Code ```typescript import { Annotation, Command, Messages, messagesStateReducer, START, StateGraph } from "@langchain/langgraph"; import { AIMessage, BaseMessage, createAgent, HumanMessage, tool, ToolMessage } from "langchain"; import { createZhipuModel } from "../../utils/ai-models"; import z from "zod"; import { nanoid } from 'nanoid'; import { RunnableConfig } from "@langchain/core/runnables"; const DemoAgentState = Annotation.Root({ messages: Annotation<BaseMessage[], Messages>({ reducer: messagesStateReducer, default: () => [], }), content: Annotation<string, string>({ reducer: (x: string, y: string) => y ?? x ?? '', default: () => '', }), }); export const demoSaveDocumentTool = tool( async ( input: { content: string }, config ) => { const aiMessage = new AIMessage({ content: `save content to state`, }); const toolMessage = new ToolMessage({ content: `save content success`, name: 'demo_save_document', tool_call_id: config.toolCall.id, status: 'success', }); return new Command({ update: { content: input.content, // not work messages: [aiMessage, toolMessage], }, }); }, { name: 'demo_save_document', schema: z.object({ content: z.string().describe('test content'), }), description: 'save content to state', } ); const demoFormatAgent = createAgent({ model: createZhipuModel(), tools: [demoSaveDocumentTool], name: 'format_agent', systemPrompt: formatPrompt, stateSchema: DemoAgentState, }); const createDemoHandoffTool = ({ agentName, agentDescription, }: { agentName: string; agentDescription: string; }) => { const toolName = `transfer_to_${agentName}`; const handoffTool = tool( async (_, config) => { const toolMessage = new ToolMessage({ content: `Successfully transferred to ${agentName}`, name: toolName, tool_call_id: config.toolCall.id, }); return new Command({ goto: agentName, graph: Command.PARENT, update: { messages: [toolMessage], }, }); }, { name: toolName, schema: z.object({}), description: agentDescription ?? `transfer to ${agentName} agent`, } ); return handoffTool; }; const createDemoHandoffBackMessages = ( agentName: string, supervisorName: string ): [AIMessage, ToolMessage] => { const toolCallId = nanoid(); const toolName = `transfer_back_to_${supervisorName}`; const toolCalls = [{ name: toolName, args: {}, id: toolCallId }]; return [ new AIMessage({ content: `Transferring back to ${supervisorName}`, tool_calls: toolCalls, name: agentName, }), new ToolMessage({ content: `Successfully transferred back to ${supervisorName}`, name: toolName, tool_call_id: toolCallId, }), ]; }; const createDemoSupervisor = ({ formatAgent, llm, tools, prompt, stateSchema, }: any): any => { const formatAgentName = 'format_agent' const handoffTool = createDemoHandoffTool({ agentName: formatAgentName, agentDescription: `transfer to ${formatAgentName}`, }); formatAgent.name = formatAgent.name || formatAgent.options?.name || formatAgent.graph.name; const allTools = [...(tools ?? []), handoffTool]; const supervisorAgent = createAgent({ name: 'supervisor', model: llm, tools: allTools, systemPrompt: prompt, stateSchema: stateSchema, }); supervisorAgent.name = supervisorAgent.name || supervisorAgent.options?.name || supervisorAgent.graph.name; const supervisorFunction = async ( state: any, config?: RunnableConfig ) => { const output = await (supervisorAgent as any).invoke(state, config); return { ...state, messages: output.messages }; }; const formatAgentFunction = async (state: any, config?: RunnableConfig) => { try { const output = await (formatAgent as any).invoke(state, config); const [handoffAIMsg, handoffToolMsg] = createDemoHandoffBackMessages( formatAgentName, 'supervisor' ); output.messages = output.messages || []; output.messages.push(handoffAIMsg, handoffToolMsg); return { ...state, messages: output.messages }; } catch (error) { throw error; } }; const builder = new StateGraph(stateSchema) .addNode('supervisor', supervisorFunction, { ends: [formatAgentName], } as any) .addNode(formatAgentName, formatAgentFunction, { subgraphs: [formatAgent], } as any) .addEdge(START, 'supervisor') .addEdge(formatAgentName, 'supervisor'); return builder; }; export const demoDemoModalAgent = createDemoSupervisor({ llm: createZhipuModel(), tools: [], formatAgent: demoFormatAgent, stateSchema: DemoAgentState, prompt: supervisorPrompt, }).compile({}); const stream = await demoDemoModalAgent.stream({ messages: [ new HumanMessage({ id: nanoid(), content: message, }), ], }, { streamMode: 'messages', }); ``` ### Error Message and Stack Trace (if applicable) _No response_ ### Description I took this example as a reference [use-inside-tools](https://docs.langchain.com/oss/javascript/langgraph/use-graph-api#use-inside-tools), when the `demo_save_document` tool was executed, I saw two messages: `save content to state` and `save content success`, but the content under state remained as an empty string `''`, and the update did not take effect. Is my usage incorrect? ### System Info Node version: v20.20.0 Operating system: darwin arm64 Package manager: pnpm Package manager version: N/A -------------------- @langchain/anthropic -> 1.3.12 zod -> 4.3.5 @langchain/core -> 1.1.17 langsmith -> 0.4.9 @langchain/google-genai -> 2.1.13 @langchain/langgraph -> 1.1.2 @langchain/langgraph-checkpoint -> 1.0.0 @langchain/langgraph-sdk -> 1.5.5 @langchain/langgraph-cli -> 1.1.11 @langchain/langgraph-api -> 1.1.11 @langchain/langgraph-ui -> 1.1.11 @langchain/langgraph-supervisor -> 1.0.1 @langchain/mcp-adapters -> 1.1.2 @langchain/openai -> 1.2.3 langchain -> 1.1.0
yindo closed this issue 2026-02-15 18:16:26 -05:00
Author
Owner

@pavel-voronin commented on GitHub (Feb 8, 2026):

Root cause

Two things are happening at the same time:

  1. createAgent state schema mismatch in langchain@1.1.0
    In your code, DemoAgentState is built with Annotation.Root(...) and then passed to createAgent(...) as stateSchema.
    In this version, createAgent reliably picks up custom state fields when stateSchema is a Zod object (z.object(...)).
    With Annotation.Root(...), fields like content may not be tracked as expected inside agent state updates.

  2. Wrapper nodes drop non-message fields
    In both wrapper functions you return:

    { ...state, messages: output.messages }
    

    This overwrites only messages and drops other updates coming from sub-agent output (including content from Command.update).


Fix

Keep your existing DemoAgentState for the outer StateGraph, but add a Zod schema for createAgent:

const DemoAgentZodState = z.object({
  content: z.string().default(""),
});

Use this Zod schema in both agent creations:

const demoFormatAgent = createAgent({
  model: createZhipuModel(),
  tools: [demoSaveDocumentTool],
  name: "format_agent",
  systemPrompt: formatPrompt,
  stateSchema: DemoAgentZodState, // changed
});

const supervisorAgent = createAgent({
  name: "supervisor",
  model: llm,
  tools: allTools,
  systemPrompt: prompt,
  stateSchema: DemoAgentZodState, // changed
});

Update both wrapper returns to preserve full agent output state:

const supervisorFunction = async (state: any, config?: RunnableConfig) => {
  const output = await (supervisorAgent as any).invoke(state, config);
  return { ...state, ...output, messages: output.messages }; // changed
};

const formatAgentFunction = async (state: any, config?: RunnableConfig) => {
  const output = await (formatAgent as any).invoke(state, config);

  const [handoffAIMsg, handoffToolMsg] = createDemoHandoffBackMessages(
    formatAgentName,
    "supervisor"
  );

  output.messages = output.messages || [];
  output.messages.push(handoffAIMsg, handoffToolMsg);

  return { ...state, ...output, messages: output.messages }; // changed
};

When demo_save_document runs, content is no longer lost and is present in final state (instead of staying "").

@pavel-voronin commented on GitHub (Feb 8, 2026): ### Root cause Two things are happening at the same time: 1. `createAgent` state schema mismatch in `langchain@1.1.0` In your code, `DemoAgentState` is built with `Annotation.Root(...)` and then passed to `createAgent(...)` as `stateSchema`. In this version, `createAgent` reliably picks up custom state fields when `stateSchema` is a Zod object (`z.object(...)`). With `Annotation.Root(...)`, fields like `content` may not be tracked as expected inside agent state updates. 2. Wrapper nodes drop non-message fields In both wrapper functions you return: ```ts { ...state, messages: output.messages } ``` This overwrites only `messages` and drops other updates coming from sub-agent output (including `content` from `Command.update`). --- ### Fix Keep your existing `DemoAgentState` for the outer `StateGraph`, but add a Zod schema for `createAgent`: ```ts const DemoAgentZodState = z.object({ content: z.string().default(""), }); ``` Use this Zod schema in both agent creations: ```ts const demoFormatAgent = createAgent({ model: createZhipuModel(), tools: [demoSaveDocumentTool], name: "format_agent", systemPrompt: formatPrompt, stateSchema: DemoAgentZodState, // changed }); const supervisorAgent = createAgent({ name: "supervisor", model: llm, tools: allTools, systemPrompt: prompt, stateSchema: DemoAgentZodState, // changed }); ``` Update both wrapper returns to preserve full agent output state: ```ts const supervisorFunction = async (state: any, config?: RunnableConfig) => { const output = await (supervisorAgent as any).invoke(state, config); return { ...state, ...output, messages: output.messages }; // changed }; const formatAgentFunction = async (state: any, config?: RunnableConfig) => { const output = await (formatAgent as any).invoke(state, config); const [handoffAIMsg, handoffToolMsg] = createDemoHandoffBackMessages( formatAgentName, "supervisor" ); output.messages = output.messages || []; output.messages.push(handoffAIMsg, handoffToolMsg); return { ...state, ...output, messages: output.messages }; // changed }; ``` --- When `demo_save_document` runs, `content` is no longer lost and is present in final state (instead of staying `""`).
Author
Owner

@July-ing commented on GitHub (Feb 10, 2026):

Hi @pavel-voronin , Thanks for the detailed explanation! Your solution works perfectly. Really appreciate your help! 🙏

@July-ing commented on GitHub (Feb 10, 2026): Hi @pavel-voronin , Thanks for the detailed explanation! Your solution works perfectly. Really appreciate your help! 🙏
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraphjs#399