From 378295d934c4e5f516ba64ea8f7de78b33d37da4 Mon Sep 17 00:00:00 2001 From: Mark Ericksen Date: Fri, 15 May 2026 23:10:25 -0600 Subject: [PATCH] fix max_runs check when used with until_tool_used - an issue with a max_run of 1 --- lib/chains/llm_chain/modes/until_tool_used.ex | 9 ++++++- test/chains/llm_chain_test.exs | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/chains/llm_chain/modes/until_tool_used.ex b/lib/chains/llm_chain/modes/until_tool_used.ex index 840b5f59..0189dd4a 100644 --- a/lib/chains/llm_chain/modes/until_tool_used.ex +++ b/lib/chains/llm_chain/modes/until_tool_used.ex @@ -53,11 +53,18 @@ defmodule LangChain.Chains.LLMChain.Modes.UntilToolUsed do end defp do_run(chain, opts) do + # Order matters: `check_max_runs` must come AFTER `check_until_tool`, + # otherwise the very LLM call that successfully invokes the target + # tool can have its result discarded — `call_llm` increments the run + # counter, the check fires and short-circuits the pipeline before + # `execute_tools`/`check_until_tool` get to see the result. With this + # order, a successful run terminates first; `check_max_runs` is a + # no-op on terminal pipeline states. {:continue, chain} |> call_llm() - |> check_max_runs(opts) |> execute_tools() |> check_until_tool(opts) + |> check_max_runs(opts) |> continue_or_done(&do_run/2, opts) end end diff --git a/test/chains/llm_chain_test.exs b/test/chains/llm_chain_test.exs index 88377687..3b0d0355 100644 --- a/test/chains/llm_chain_test.exs +++ b/test/chains/llm_chain_test.exs @@ -3201,6 +3201,33 @@ defmodule LangChain.Chains.LLMChainTest do assert error.message == "Exceeded maximum number of runs (3/3)" end + # Regression: a successful tool call on the LLM call that hits the + # max_runs ceiling must terminate with success, not be discarded. + # Previously `check_max_runs` ran before `check_until_tool` and a + # max_runs=1 + first-call success path errored out instead of + # returning the tool result. + test "succeeds when the target tool is called on the same LLM call that hits max_runs", + %{greet: greet, sync: do_thing} do + expect(ChatOpenAI, :call, fn _model, _messages, _tools -> + {:ok, + new_function_calls!([ + ToolCall.new!(%{call_id: "call_doThing", name: "do_thing", arguments: nil}) + ])} + end) + + {:ok, updated_chain, tool_result} = + %{llm: ChatOpenAI.new!(%{stream: false}), verbose: false} + |> LLMChain.new!() + |> LLMChain.add_tools([greet, do_thing]) + |> LLMChain.add_message(Message.new_system!()) + |> LLMChain.add_message(Message.new_user!("Call do_thing right away.")) + |> LLMChain.run_until_tool_used("do_thing", max_runs: 1) + + assert updated_chain.last_message.role == :tool + assert %ToolResult{is_error: false} = tool_result + assert tool_result.name == "do_thing" + end + test "returns error when tool_name does not exist in available tools", %{greet: greet} do {:error, _updated_chain, error} = %{llm: ChatOpenAI.new!(%{stream: false}), verbose: false}