From 72cf0bf66358a6fe2c20fc6c9b765146b59f78a3 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Mon, 7 Sep 2026 20:05:42 +0300 Subject: [PATCH] fix: keep the output file tool off subagents and its errors recoverable An advanced agent delegated the report to a subagent, which published it with create_output_file and returned a text summary. Subagents run with their own context, so the reference never reached the main agent, which is the only one that fills the typed output. The main agent regenerated the report from that summary, published a second attachment, then referenced the file by path and faulted the run. Three changes, one per link in that chain. The tool now travels as main-agent-only middleware. create_deep_agent hands its tools list to the general-purpose subagent as well, and excluded_tools on the harness profile strips from the main agent too, so neither seam worked; middleware tools are collected per agent and deepagents gives caller middleware to the main agent alone. A mistake the agent can correct now comes back as an error rather than raising. A wrong path, a missing source, two sources, or a traversal attempt each cost a turn instead of the job. The tool says what it does: it creates an Orchestrator attachment, not a file on disk. It previously opened with "Create a file", and told the agent to pass the path of anything it "had already written to a file" - which is what it believed after using content. Describing the tool accurately removes the need to warn about the consequence. --- pyproject.toml | 2 +- src/uipath_langchain/agent/advanced/agent.py | 34 +++++++- .../tools/internal_tools/output_file_tool.py | 74 +++++++++++------- .../test_create_advanced_agent_graph.py | 78 +++++++++++++++++++ .../internal_tools/test_output_file_tool.py | 39 +++++++--- uv.lock | 2 +- 6 files changed, 185 insertions(+), 44 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6a7a510f..d90ebd408 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.17.3" +version = "0.17.4" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath_langchain/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index 25548d9ef..8bd01220b 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -43,6 +43,9 @@ from uipath_langchain.agent.react.utils import ( has_custom_conversational_output_fields, ) +from uipath_langchain.agent.tools.internal_tools.output_file_tool import ( + OUTPUT_FILE_TOOL_NAME, +) from uipath_langchain.runtime.messages import UiPathChatMessagesMapper from .types import ( @@ -57,6 +60,19 @@ ) +class _MainAgentToolsMiddleware(AgentMiddleware[AgentState[Any], Any]): + """Carries tools that must not reach subagents. + + ``create_deep_agent`` shares its ``tools`` list with the general-purpose + subagent but gives caller middleware to the main agent alone, and only the + main agent fills the typed output. + """ + + def __init__(self, tools: Sequence[BaseTool]) -> None: + super().__init__() + self.tools = list(tools) + + class _RuntimeSystemPromptMiddleware(AgentMiddleware[AgentState[Any], Any]): """Attach a once-resolved invocation prompt to every model request.""" @@ -174,6 +190,17 @@ def create_advanced_agent( ) +def _partition_main_agent_tools( + tools: Sequence[BaseTool], +) -> tuple[list[BaseTool], list[BaseTool]]: + """Split off the tools the general-purpose subagent must not receive.""" + shared: list[BaseTool] = [] + main_only: list[BaseTool] = [] + for tool in tools: + (main_only if tool.name == OUTPUT_FILE_TOOL_NAME else shared).append(tool) + return shared, main_only + + def create_advanced_agent_graph( model: BaseChatModel, tools: Sequence[BaseTool], @@ -208,6 +235,11 @@ def create_advanced_agent_graph( output_file_fields = ( get_output_file_fields(output_schema) if output_files_enabled else [] ) + tools, main_agent_only_tools = _partition_main_agent_tools(tools) + + middleware: list[AgentMiddleware[Any, Any]] = list(runtime_prompt.middleware) + if main_agent_only_tools: + middleware.append(_MainAgentToolsMiddleware(main_agent_only_tools)) inner_graph = create_advanced_agent( model=model, @@ -216,7 +248,7 @@ def create_advanced_agent_graph( backend=backend, response_format=response_format, memory=memory_sources, - middleware=runtime_prompt.middleware, + middleware=middleware, skills=skills, ) diff --git a/src/uipath_langchain/agent/tools/internal_tools/output_file_tool.py b/src/uipath_langchain/agent/tools/internal_tools/output_file_tool.py index 9882c6a80..ff55bf573 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/output_file_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/output_file_tool.py @@ -7,8 +7,8 @@ Two content sources, and which one is offered depends on the agent flavour: -- ``content`` — the file body inline. The only source a standard agent has, - since it owns no filesystem. Text formats only. +- ``content`` — the body inline. The only source a standard agent has, since it + owns no filesystem. Text formats only. - ``file_path`` — a path in the agent's own workspace, offered only when the backend exposes a workspace root (advanced agents). Preferred there: the body never round-trips through the model, so large and binary files work. @@ -51,27 +51,30 @@ } _TOOL_DESCRIPTION = ( - "Create a file and attach it to this job, then return the attachment " - "reference to put in the agent output field that expects a file. Call this " - "before ending execution: an output file field can only be filled with a " - "reference this tool returned." + "Create an Orchestrator attachment on this job and return its reference, " + "for an agent output field that expects a file. This creates an attachment, " + "not a file on disk." ) _FILE_NAME_DESCRIPTION = ( - "File name including the extension, e.g. 'summary.md' or 'accounts.csv'. " - "The extension determines the file's MIME type, so it must match the " - "format of the content." + "Name the attachment carries, including the extension, e.g. 'summary.md' " + "or 'accounts.csv'. The extension determines its MIME type, so it must " + "match the format of the content." ) -_CONTENT_DESCRIPTION = "The full text content of the file." +_CONTENT_DESCRIPTION = "The full text content of the attachment." _FILE_PATH_DESCRIPTION = ( - "Path of an existing file in your workspace to publish, e.g. '/report.md'. " - "Prefer this over 'content' for anything you have already written to a " - "file, and use it for any non-text file." + "Path of a file you wrote to your workspace with the filesystem tools, " + "e.g. '/report.md'. Prefer this over 'content' for a file that already " + "exists there, and use it for any non-text file." ) +class _OutputFileRejected(Exception): + """A model-correctable rejection, reported to the agent rather than raised.""" + + @runtime_checkable class _WorkspaceBackend(Protocol): """The part of a filesystem backend this tool needs: the workspace root. @@ -136,12 +139,12 @@ def _resolve_source_path(backend: Any, file_path: str) -> Path: virtual_path = file_path if file_path.startswith("/") else f"/{file_path}" if ".." in virtual_path or virtual_path.startswith("~"): - raise ValueError(f"Path traversal is not allowed: {file_path!r}") + raise _OutputFileRejected(f"Path traversal is not allowed: {file_path!r}") root = Path(backend.cwd).resolve() resolved = (root / virtual_path.lstrip("/")).resolve() if resolved != root and root not in resolved.parents: - raise ValueError(f"{file_path!r} is outside your workspace") + raise _OutputFileRejected(f"{file_path!r} is outside your workspace") return resolved @@ -166,19 +169,21 @@ async def create_output_file_fn(**kwargs: Any) -> dict[str, Any]: content = kwargs.get("content") file_path = kwargs.get("file_path") + # Returned rather than raised: an exception here faults the whole run. if not file_name: - raise ValueError("'file_name' is required.") + return {"error": "'file_name' is required."} if not content and not file_path: - raise ValueError( - "Provide the file body in 'content'" + return { + "error": "Provide the file body in 'content'" + ( - ", or an existing workspace path in 'file_path'." + ", or the path of a file you already wrote to your " + "workspace in 'file_path'." if with_file_path else "." ) - ) + } if content and file_path: - raise ValueError("'content' and 'file_path' are mutually exclusive.") + return {"error": "'content' and 'file_path' are mutually exclusive."} # file_name comes from the model; it names the attachment, not a path. attachment_name = Path(file_name).name @@ -191,14 +196,22 @@ async def create_output_file_fn(**kwargs: Any) -> dict[str, Any]: example_calls=[], ) async def publish_output_file(**_tool_kwargs: Any) -> dict[str, Any]: - source_path = ( - _resolve_source_path(backend, file_path) if file_path else None - ) - if source_path is not None and not source_path.is_file(): - raise ValueError( - f"'{file_path}' does not exist in your workspace. Write the " - "file first, or pass its body in 'content'." + try: + source_path = ( + _resolve_source_path(backend, file_path) if file_path else None ) + except _OutputFileRejected as rejection: + return {"error": str(rejection)} + if source_path is not None and not source_path.is_file(): + return { + "error": ( + f"'{file_path}' is not a file in your workspace. Note " + "that passing 'content' uploads directly and leaves no " + "file behind, so a file you created that way cannot be " + "referenced by path. Write it with the filesystem tools " + "first, or pass its body in 'content'." + ) + } uipath = UiPath() attachment_id = await uipath.jobs.create_attachment_async( @@ -214,7 +227,10 @@ async def publish_output_file(**_tool_kwargs: Any) -> dict[str, Any]: "MimeType": guess_mime_type(attachment_name), } - return {"file": await publish_output_file(**kwargs)} + published = await publish_output_file(**kwargs) + if "error" in published: + return published + return {"file": published} # Imported here to avoid a circular import at module load. from uipath_langchain.agent.wrappers import get_job_attachment_wrapper diff --git a/tests/agent/advanced/test_create_advanced_agent_graph.py b/tests/agent/advanced/test_create_advanced_agent_graph.py index 53363b79e..ba28d074b 100644 --- a/tests/agent/advanced/test_create_advanced_agent_graph.py +++ b/tests/agent/advanced/test_create_advanced_agent_graph.py @@ -441,3 +441,81 @@ async def test_verification_state_is_not_forwarded_as_agent_input(self) -> None: assert "messages" in update assert "uipath__output_file_retries" not in update + + +class TestOutputFileToolIsWithheldFromSubagents: + """deepagents hands its ``tools`` list to the general-purpose subagent, but + only the main agent produces the typed output. A subagent that published a + file returned a text summary, losing the reference, so the main agent redid + the work and then referenced a workspace file that was never written.""" + + @staticmethod + def _output_model() -> type[BaseModel]: + from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( + create_model as create_model_from_schema, + ) + from uipath_langchain.agent.tools.internal_tools.schema_utils import ( + JOB_ATTACHMENT_DEFINITION, + ) + + return create_model_from_schema( + { + "type": "object", + "properties": {"file": {"$ref": "#/definitions/job-attachment"}}, + "required": ["file"], + "definitions": {"job-attachment": JOB_ATTACHMENT_DEFINITION}, + } + ) + + @staticmethod + def _build_and_capture(tools: list[Any]) -> tuple[list[str], list[str]]: + from uipath_langchain.agent.advanced.agent import create_advanced_agent_graph + + with patch( + "uipath_langchain.agent.advanced.agent._create_deep_agent", + return_value=MagicMock(), + ) as create_deep_agent: + create_advanced_agent_graph( + model=_mock_model(), + tools=tools, + system_prompt="sys", + backend=None, + response_format=None, + input_schema=None, + output_schema=TestOutputFileToolIsWithheldFromSubagents._output_model(), + build_user_message=lambda args: "go", + output_files_enabled=True, + ) + kwargs = create_deep_agent.call_args.kwargs + shared = [t.name for t in kwargs["tools"]] + main_only = [ + t.name for mw in kwargs["middleware"] for t in getattr(mw, "tools", []) + ] + return shared, main_only + + def test_the_tool_reaches_the_main_agent_only(self) -> None: + from uipath_langchain.agent.tools.internal_tools.output_file_tool import ( + OUTPUT_FILE_TOOL_NAME, + create_output_file_tool, + ) + + shared, main_only = self._build_and_capture([create_output_file_tool()]) + + assert OUTPUT_FILE_TOOL_NAME not in shared + assert OUTPUT_FILE_TOOL_NAME in main_only + + def test_every_other_tool_stays_shared(self) -> None: + from langchain_core.tools import StructuredTool + + from uipath_langchain.agent.tools.internal_tools.output_file_tool import ( + create_output_file_tool, + ) + + other = StructuredTool.from_function( + lambda x: x, name="some_other_tool", description="d" + ) + + shared, main_only = self._build_and_capture([other, create_output_file_tool()]) + + assert "some_other_tool" in shared + assert main_only == ["create_output_file"] diff --git a/tests/agent/tools/internal_tools/test_output_file_tool.py b/tests/agent/tools/internal_tools/test_output_file_tool.py index 93a051375..269ab8cd8 100644 --- a/tests/agent/tools/internal_tools/test_output_file_tool.py +++ b/tests/agent/tools/internal_tools/test_output_file_tool.py @@ -111,6 +111,14 @@ def test_only_file_name_is_required(self): assert schema["required"] == ["file_name"] + def test_the_description_says_it_makes_an_attachment_not_a_file(self): + """The old wording, "create a file", is what led an agent to reference + the result by workspace path and fault the run.""" + description = create_output_file_tool().description + + assert "attachment" in description + assert "not a file on disk" in description + def test_tool_is_named_for_the_prompt(self): assert create_output_file_tool().name == OUTPUT_FILE_TOOL_NAME @@ -140,12 +148,12 @@ async def test_file_name_is_reduced_to_its_basename(self, created): assert result["file"]["FullName"] == "passwd.txt" assert created[0]["name"] == "passwd.txt" - async def test_no_source_is_rejected(self, created): + async def test_no_source_comes_back_as_an_error(self, created): tool = create_output_file_tool() - with pytest.raises(ValueError, match="'content'"): - await call(tool, file_name="report.md") + result = await call(tool, file_name="report.md") + assert "'content'" in result["error"] assert created == [] @@ -160,12 +168,17 @@ async def test_uploads_the_workspace_file(self, created, tmp_path): assert created[0]["source_path"] == str(tmp_path / "report.md") assert created[0]["content"] is None - async def test_missing_workspace_file_is_rejected(self, created, tmp_path): + async def test_missing_workspace_file_comes_back_as_an_error( + self, created, tmp_path + ): + """Raising here faulted whole runs: the agent had used `content`, which + leaves no file, then referenced it by path.""" tool = create_output_file_tool(FakeBackend(tmp_path)) - with pytest.raises(ValueError, match="does not exist in your workspace"): - await call(tool, file_name="report.md", file_path="/absent.md") + result = await call(tool, file_name="report.md", file_path="/absent.md") + assert "not a file in your workspace" in result["error"] + assert "leaves no file behind" in result["error"] assert created == [] @pytest.mark.parametrize( @@ -174,9 +187,9 @@ async def test_missing_workspace_file_is_rejected(self, created, tmp_path): async def test_traversal_is_rejected(self, created, tmp_path, file_path): tool = create_output_file_tool(FakeBackend(tmp_path)) - with pytest.raises(ValueError, match="traversal"): - await call(tool, file_name="x.txt", file_path=file_path) + result = await call(tool, file_name="x.txt", file_path=file_path) + assert "traversal" in result["error"] assert created == [] async def test_symlink_out_of_the_workspace_is_rejected(self, created, tmp_path): @@ -189,9 +202,9 @@ async def test_symlink_out_of_the_workspace_is_rejected(self, created, tmp_path) (workspace / "link.txt").symlink_to(outside / "secret.txt") tool = create_output_file_tool(FakeBackend(workspace)) - with pytest.raises(ValueError, match="outside your workspace"): - await call(tool, file_name="secret.txt", file_path="/link.txt") + result = await call(tool, file_name="secret.txt", file_path="/link.txt") + assert "outside your workspace" in result["error"] assert created == [] async def test_a_relative_path_is_read_from_the_workspace_root( @@ -207,9 +220,11 @@ async def test_a_relative_path_is_read_from_the_workspace_root( async def test_content_and_file_path_together_are_rejected(self, created, tmp_path): tool = create_output_file_tool(FakeBackend(tmp_path)) - with pytest.raises(ValueError, match="mutually exclusive"): - await call(tool, file_name="report.md", content="x", file_path="/report.md") + result = await call( + tool, file_name="report.md", content="x", file_path="/report.md" + ) + assert "mutually exclusive" in result["error"] assert created == [] diff --git a/uv.lock b/uv.lock index f185c12cb..242fcee73 100644 --- a/uv.lock +++ b/uv.lock @@ -4574,7 +4574,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.17.3" +version = "0.17.4" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },