diff --git a/src/uipath_langchain/agent/advanced/code_interpreter.py b/src/uipath_langchain/agent/advanced/code_interpreter.py index 82eb5b818..fe2fbdb85 100644 --- a/src/uipath_langchain/agent/advanced/code_interpreter.py +++ b/src/uipath_langchain/agent/advanced/code_interpreter.py @@ -43,6 +43,9 @@ _RESERVED_TOOL_NAMES = frozenset({"task"}) +# Upstream's default ``tool_name``; the factory does not override it. +EVAL_TOOL_NAME = "eval" + # Subagent spec keys that make deepagents interrupt without a stamped tool. _SUBAGENT_INTERRUPT_KEYS = ("interrupt_on", "permissions", "middleware") @@ -60,6 +63,20 @@ # computation, and a bridged tool call does not consume it. DEFAULT_EVAL_TIMEOUT_SECONDS = 5.0 +SINGLE_IN_FLIGHT_NOTE = ( + " Only one eval may run at a time: they share one interpreter and its state, " + "so a second call issued in the same turn fails instead of queueing. Put the " + "work in one call and use `await Promise.all([...])` to parallelise inside it." +) +"""Appended to the ``eval`` tool description by the factory. + +Upstream renders that description from the persistence mode and offers no +override, and the single-in-flight rule is not in it. It is also not expressible +as a tool schema field: ``parallel_tool_calls`` is a request-level switch in both +the OpenAI and Anthropic APIs, so a model that is not told batches two ``eval`` +calls and loses a turn to ``ConcurrentEvalError``. +""" + def ptc_tool_names(tools: Sequence[BaseTool]) -> list[str]: """Names of the agent tools that may be called from inside the REPL. @@ -205,14 +222,33 @@ def build_code_interpreter_middleware( len(tools), "offered" if dispatch else "withheld", ) - return [ - middleware_cls( - ptc=[*exposed, *PTC_FILESYSTEM_TOOLS], - mode=mode, - subagents=dispatch, - timeout=timeout, - ) - ] + middleware = middleware_cls( + ptc=[*exposed, *PTC_FILESYSTEM_TOOLS], + mode=mode, + subagents=dispatch, + timeout=timeout, + ) + _append_single_in_flight_note(middleware) + return [middleware] + + +def _append_single_in_flight_note(middleware: Any) -> None: + """Tell the model the REPL takes one call at a time. + + Mutates the description of the tool this factory just built, rather than the + class, so no other consumer of ``langchain_quickjs`` is affected. A rendering + change upstream drops the note rather than corrupting it, which the factory + test catches. + """ + for tool in getattr(middleware, "tools", ()): + if tool.name == EVAL_TOOL_NAME: + tool.description = tool.description.rstrip() + SINGLE_IN_FLIGHT_NOTE + return + logger.warning( + "Code interpreter: no %r tool to annotate, so the model is not told that " + "only one eval may be in flight", + EVAL_TOOL_NAME, + ) def _without_camel_collisions( diff --git a/tests/agent/advanced/test_code_interpreter.py b/tests/agent/advanced/test_code_interpreter.py index 857f46942..827fae50b 100644 --- a/tests/agent/advanced/test_code_interpreter.py +++ b/tests/agent/advanced/test_code_interpreter.py @@ -30,6 +30,10 @@ ptc_tool_names, subagent_dispatch_is_replay_safe, ) +from uipath_langchain.agent.advanced.code_interpreter import ( + EVAL_TOOL_NAME, + SINGLE_IN_FLIGHT_NOTE, +) pytest.importorskip("langchain_quickjs", reason="needs the code-interpreter extra") @@ -250,6 +254,22 @@ def test_dispatch_withheld_for_a_precompiled_subagent() -> None: ) +def test_eval_description_tells_the_model_only_one_may_be_in_flight() -> None: + """The REPL takes one call at a time, and nothing else tells the model. + + Upstream renders the description and offers no override, and per-tool + parallelism is not expressible in a tool schema, so a model that is not told + batches two ``eval`` calls in one turn and loses one to ``ConcurrentEvalError``. + Asserted on the description the model is shown, not on the constant. + """ + middleware = build_code_interpreter_middleware([_tool("read_invoice")])[0] + description = {t.name: t for t in middleware.tools}[EVAL_TOOL_NAME].description + + assert SINGLE_IN_FLIGHT_NOTE.strip() in description + # The rendered description survives ahead of the note rather than being replaced. + assert description.startswith("Execute JavaScript") + + def test_factory_returns_one_middleware() -> None: """The factory hands back exactly one entry, spliceable into a sequence.""" assert len(build_code_interpreter_middleware([_tool("read_invoice")])) == 1