Skip to content

Commit 5d43b21

Browse files
fix(langchain): further tool call limit optimizations (#9338)
1 parent 4906522 commit 5d43b21

File tree

2 files changed

+118
-45
lines changed

2 files changed

+118
-45
lines changed

libs/langchain/src/agents/middleware/tests/toolCallLimit.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,30 @@ describe("toolCallLimitMiddleware", () => {
544544
});
545545

546546
describe("Error Behavior", () => {
547+
it("should throw an error if run limit exceeds thread limit", async () => {
548+
expect(() =>
549+
toolCallLimitMiddleware({
550+
threadLimit: 2,
551+
runLimit: 3,
552+
exitBehavior: "error",
553+
})
554+
).toThrow(
555+
"runLimit (3) cannot exceed threadLimit (2). The run limit should be less than or equal to the thread limit."
556+
);
557+
});
558+
559+
it("should raise if invalid exit behavior is provided", async () => {
560+
expect(() =>
561+
toolCallLimitMiddleware({
562+
threadLimit: 2,
563+
runLimit: 1,
564+
exitBehavior: "invalid" as any,
565+
})
566+
).toThrow(
567+
"Invalid enum value. Expected 'continue' | 'error' | 'end', received 'invalid'"
568+
);
569+
});
570+
547571
it("should throw ToolCallLimitExceededError when exitBehavior is error", async () => {
548572
const middleware = toolCallLimitMiddleware({
549573
threadLimit: 2,
@@ -651,6 +675,49 @@ describe("toolCallLimitMiddleware", () => {
651675
);
652676
}
653677
});
678+
679+
it("should run remaining tools until limit is exceeded", async () => {
680+
const middleware = toolCallLimitMiddleware({
681+
threadLimit: 3,
682+
runLimit: 2,
683+
exitBehavior: "continue",
684+
});
685+
686+
const model = new FakeToolCallingChatModel({
687+
responses: [
688+
new AIMessage({
689+
content: "",
690+
tool_calls: [
691+
{ id: "1", name: "search", args: { query: "test1" } },
692+
{ id: "2", name: "search", args: { query: "test2" } },
693+
{ id: "3", name: "calculator", args: { expression: "1+1" } },
694+
],
695+
}),
696+
new AIMessage({
697+
content: "",
698+
tool_calls: [{ id: "4", name: "search", args: { query: "test3" } }],
699+
}),
700+
new AIMessage("Should not reach here"),
701+
],
702+
});
703+
704+
const agent = createAgent({
705+
model,
706+
tools: [searchTool, calculatorTool],
707+
middleware: [middleware],
708+
});
709+
710+
const result = await agent.invoke({
711+
messages: [new HumanMessage("Search and calculate")],
712+
});
713+
714+
const lastMessage = result.messages[result.messages.length - 1];
715+
expect(lastMessage.content).toContain(
716+
"Tool call limit exceeded. Do not make additional tool calls."
717+
);
718+
expect(searchToolMock).toHaveBeenCalledTimes(2);
719+
expect(calculatorToolMock).toHaveBeenCalledTimes(0);
720+
});
654721
});
655722

656723
describe("Combined Thread and Run Limits", () => {
@@ -715,7 +782,7 @@ describe("toolCallLimitMiddleware", () => {
715782

716783
const middleware = toolCallLimitMiddleware({
717784
threadLimit: 2, // Will hit this
718-
runLimit: 10, // Won't hit this
785+
runLimit: 2, // Won't hit this
719786
exitBehavior: "end",
720787
});
721788

libs/langchain/src/agents/middleware/toolCallLimit.ts

Lines changed: 50 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AIMessage, ToolMessage } from "@langchain/core/messages";
2+
import { z as z4 } from "zod/v4";
23
import { z } from "zod/v3";
34
import type { InferInteropZodInput } from "@langchain/core/utils/types";
45
import type { ToolCall } from "@langchain/core/messages/tool";
@@ -12,42 +13,19 @@ import { createMiddleware } from "../middleware.js";
1213
* that the model has no notion of.
1314
*
1415
* @param toolName - Tool name being limited (if specific tool), or undefined for all tools.
15-
* @param threadCount - Current thread tool call count.
16-
* @param runCount - Current run tool call count.
17-
* @param threadLimit - Thread tool call limit (if set).
18-
* @param runLimit - Run tool call limit (if set).
19-
* @returns A concise message. If only run limit is exceeded (not thread limit),
20-
* returns a simple "limit exceeded" message without instructing model to stop.
21-
* If thread limit is exceeded, includes instruction not to call again.
16+
* @returns A concise message instructing the model not to call the tool again.
2217
*/
23-
function buildToolMessageContent(
24-
toolName: string | undefined,
25-
threadCount: number,
26-
runCount: number,
27-
threadLimit: number | undefined,
28-
runLimit: number | undefined
29-
): string {
30-
// Check if thread limit is exceeded
31-
const threadExceeded = threadLimit !== undefined && threadCount > threadLimit;
32-
// Check if only run limit is exceeded (not thread limit)
33-
const onlyRunExceeded =
34-
runLimit !== undefined && runCount > runLimit && !threadExceeded;
35-
36-
if (onlyRunExceeded) {
37-
// Run limit exceeded but thread limit not exceeded - simpler message
38-
if (toolName) {
39-
return `Tool call limit exceeded for '${toolName}'.`;
40-
}
41-
return "Tool call limit exceeded.";
42-
}
43-
44-
// Thread limit exceeded (or both) - include instruction not to call again
18+
function buildToolMessageContent(toolName: string | undefined): string {
19+
// Always instruct the model not to call again, regardless of which limit was hit
4520
if (toolName) {
4621
return `Tool call limit exceeded. Do not call '${toolName}' again.`;
4722
}
4823
return "Tool call limit exceeded. Do not make additional tool calls.";
4924
}
5025

26+
const VALID_EXIT_BEHAVIORS = ["continue", "error", "end"] as const;
27+
const DEFAULT_EXIT_BEHAVIOR = "continue";
28+
5129
/**
5230
* Build the final AI message content for 'end' behavior.
5331
*
@@ -84,6 +62,13 @@ function buildFinalAIMessageContent(
8462
return `${toolDesc} call limit reached: ${limitsText}.`;
8563
}
8664

65+
/**
66+
* Schema for the exit behavior.
67+
*/
68+
const exitBehaviorSchema = z
69+
.enum(VALID_EXIT_BEHAVIORS)
70+
.default(DEFAULT_EXIT_BEHAVIOR);
71+
8772
/**
8873
* Exception raised when tool call limits are exceeded.
8974
*
@@ -162,8 +147,10 @@ export const ToolCallLimitOptionsSchema = z.object({
162147
* - "end": Stop execution immediately, injecting a ToolMessage and an AI message
163148
* for the single tool call that exceeded the limit. Raises NotImplementedError
164149
* if there are multiple tool calls.
150+
*
151+
* @default "continue"
165152
*/
166-
exitBehavior: z.enum(["continue", "error", "end"]).default("continue"),
153+
exitBehavior: exitBehaviorSchema,
167154
});
168155

169156
export type ToolCallLimitConfig = InferInteropZodInput<
@@ -202,7 +189,7 @@ const DEFAULT_TOOL_COUNT_KEY = "__all__";
202189
* - "error": Raise a ToolCallLimitExceededError exception
203190
* - "end": Stop execution immediately with a ToolMessage + AI message for the single tool call that exceeded the limit. Raises NotImplementedError if there are multiple tool calls.
204191
*
205-
* @throws {Error} If both limits are undefined.
192+
* @throws {Error} If both limits are undefined, if exitBehavior is invalid, or if runLimit exceeds threadLimit.
206193
* @throws {NotImplementedError} If exitBehavior is "end" and there are multiple tool calls.
207194
*
208195
* @example Continue execution with blocked tools (default)
@@ -271,9 +258,27 @@ export function toolCallLimitMiddleware(options: ToolCallLimitConfig) {
271258
}
272259

273260
/**
274-
* Apply default for exitBehavior
261+
* Validate exitBehavior (Zod schema already validates, but provide helpful error)
275262
*/
276-
const exitBehavior = options.exitBehavior ?? "continue";
263+
const exitBehavior = options.exitBehavior ?? DEFAULT_EXIT_BEHAVIOR;
264+
const parseResult = exitBehaviorSchema.safeParse(exitBehavior);
265+
if (!parseResult.success) {
266+
throw new Error(z4.prettifyError(parseResult.error).slice(2));
267+
}
268+
269+
/**
270+
* Validate that runLimit does not exceed threadLimit
271+
*/
272+
if (
273+
options.threadLimit !== undefined &&
274+
options.runLimit !== undefined &&
275+
options.runLimit > options.threadLimit
276+
) {
277+
throw new Error(
278+
`runLimit (${options.runLimit}) cannot exceed threadLimit (${options.threadLimit}). ` +
279+
"The run limit should be less than or equal to the thread limit."
280+
);
281+
}
277282

278283
/**
279284
* Generate the middleware name based on the tool name
@@ -358,7 +363,7 @@ export function toolCallLimitMiddleware(options: ToolCallLimitConfig) {
358363
return {
359364
allowed,
360365
blocked,
361-
finalThreadCount: tempThreadCount + blocked.length,
366+
finalThreadCount: tempThreadCount,
362367
finalRunCount: tempRunCount + blocked.length,
363368
};
364369
};
@@ -387,7 +392,9 @@ export function toolCallLimitMiddleware(options: ToolCallLimitConfig) {
387392
);
388393

389394
/**
390-
* Update counts to include ALL tool call attempts (both allowed and blocked)
395+
* Update counts:
396+
* - Thread count includes only allowed calls (blocked calls don't count towards thread-level tracking)
397+
* - Run count includes blocked calls since they were attempted in this run
391398
*/
392399
threadCounts[countKey] = finalThreadCount;
393400
runCounts[countKey] = finalRunCount;
@@ -409,8 +416,10 @@ export function toolCallLimitMiddleware(options: ToolCallLimitConfig) {
409416
* Handle different exit behaviors
410417
*/
411418
if (exitBehavior === "error") {
419+
// Use hypothetical thread count to show which limit was exceeded
420+
const hypotheticalThreadCount = finalThreadCount + blocked.length;
412421
throw new ToolCallLimitExceededError(
413-
finalThreadCount,
422+
hypotheticalThreadCount,
414423
finalRunCount,
415424
options.threadLimit,
416425
options.runLimit,
@@ -421,13 +430,7 @@ export function toolCallLimitMiddleware(options: ToolCallLimitConfig) {
421430
/**
422431
* Build tool message content (sent to model - no thread/run details)
423432
*/
424-
const toolMsgContent = buildToolMessageContent(
425-
options.toolName,
426-
finalThreadCount,
427-
finalRunCount,
428-
options.threadLimit,
429-
options.runLimit
430-
);
433+
const toolMsgContent = buildToolMessageContent(options.toolName);
431434

432435
/**
433436
* Inject artificial error ToolMessages for blocked tool calls
@@ -485,9 +488,12 @@ export function toolCallLimitMiddleware(options: ToolCallLimitConfig) {
485488

486489
/**
487490
* Build final AI message content (displayed to user - includes thread/run details)
491+
* Use hypothetical thread count (what it would have been if call wasn't blocked)
492+
* to show which limit was actually exceeded
488493
*/
494+
const hypotheticalThreadCount = finalThreadCount + blocked.length;
489495
const finalMsgContent = buildFinalAIMessageContent(
490-
finalThreadCount,
496+
hypotheticalThreadCount,
491497
finalRunCount,
492498
options.threadLimit,
493499
options.runLimit,

0 commit comments

Comments
 (0)