11import { AIMessage , ToolMessage } from "@langchain/core/messages" ;
2+ import { z as z4 } from "zod/v4" ;
23import { z } from "zod/v3" ;
34import type { InferInteropZodInput } from "@langchain/core/utils/types" ;
45import 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
169156export 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