Skip to content

Truncated streamed tool arguments are repaired into valid-looking tool_calls #11469

Description

@Sparky579

Checked other resources

  • I added a very descriptive title to this issue.
  • I searched the LangChain.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 LangChain.js rather than my code.

Example Code

import { ChatOpenAI } from "@langchain/openai";
import { concat } from "@langchain/core/utils/stream";

const chunk = (delta, finish = null) =>
  `data: ${JSON.stringify({
    id: "c",
    object: "chat.completion.chunk",
    created: 1,
    model: "m",
    choices: [{ index: 0, delta, finish_reason: finish }],
  })}\n\n`;

// The model was cut off mid-argument, so the stream ends on finish_reason "length".
const sse =
  chunk({
    role: "assistant",
    tool_calls: [
      {
        index: 0,
        id: "c1",
        type: "function",
        function: { name: "write", arguments: '{"path":"/etc/hosts","content":"line1' },
      },
    ],
  }) +
  chunk({}, "length") +
  "data: [DONE]\n\n";

const model = new ChatOpenAI({
  model: "m",
  apiKey: "x",
  configuration: {
    baseURL: "https://example.invalid/v1",
    fetch: async () =>
      new Response(sse, {
        status: 200,
        headers: { "content-type": "text/event-stream" },
      }),
  },
});

let message;
for await (const c of await model.stream("hi")) {
  message = message ? concat(message, c) : c;
}

console.log("tool_calls        ", JSON.stringify(message.tool_calls));
console.log("invalid_tool_calls", JSON.stringify(message.invalid_tool_calls));
console.log("finish_reason     ", message.response_metadata.finish_reason);

Output:

tool_calls         [{"name":"write","args":{"path":"/etc/hosts","content":"line1"},"id":"c1","type":"tool_call"}]
invalid_tool_calls []
finish_reason      length

Error Message and Stack Trace (if applicable)

No error is raised.

Description

When a stream is cut short mid-argument, the partial JSON is repaired and the result lands in tool_calls as a complete call, with invalid_tool_calls empty. In the example above "content":"line1 becomes content: "line1", which is byte-identical to what a legitimately finished call would produce. A tool bound to that message would write a truncated file with nothing to indicate the input was cut off; finish_reason: "length" is the only signal, and it is on the message rather than the call.

The repair is only wrong at the end of the stream. Partial parsing is what makes tool_call_chunks useful while arguments are still arriving, but once the stream terminates the repaired value should not be presented as a finished call. Other truncation points degrade differently and just as quietly: {"content": and {"cont both yield {}, and {"cfg":{"a":1,"b": yields {"cfg":{"a":1}}, silently dropping a key. For contrast, arguments that cannot be repaired at all are handled correctly — {"a": } and hello world both go to invalid_tool_calls with "Malformed args." — so the classification path exists and truncated input simply never reaches it.

System Info

@langchain/openai 1.5.8
@langchain/core 1.2.9
node v20.19.5
linux x64

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions