diff --git a/pyproject.toml b/pyproject.toml index fd1e32b6c..970dd4f2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.16.13" +version = "0.16.14" 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" @@ -10,11 +10,11 @@ dependencies = [ "uipath-platform>=0.2.22, <0.3.0", "uipath-runtime>=0.13.0, <0.14.0", "uipath-llm-client>=1.18.0, <1.19.0", - "langgraph>=1.1.8, <2.0.0", - "langchain-core>=1.2.27, <2.0.0", + "langgraph>=1.2.11, <2.0.0", + "langchain-core>=1.6.1, <2.0.0", "langgraph-checkpoint-sqlite>=3.0.3, <4.0.0", - "langchain>=1.2.15, <2.0.0", - "deepagents>=0.5.9, <0.6.0", + "langchain>=1.3.18, <2.0.0", + "deepagents>=0.7.11, <0.8.0", "pydantic-settings>=2.6.0", "python-dotenv>=1.0.1", "httpx>=0.27.0", @@ -56,8 +56,12 @@ bedrock = [ fireworks = [ "uipath-langchain-client[fireworks]>=1.18.0, <1.19.0", ] +code-interpreter = [ + "langchain-quickjs>=0.3.5, <0.4.0", +] all = [ "uipath-langchain-client[all]>=1.18.0, <1.19.0", + "langchain-quickjs>=0.3.5, <0.4.0", ] [project.entry-points."uipath.middlewares"] diff --git a/samples/deepagent-storage-buckets/pyproject.toml b/samples/deepagent-storage-buckets/pyproject.toml index 3e44e43de..5473db381 100644 --- a/samples/deepagent-storage-buckets/pyproject.toml +++ b/samples/deepagent-storage-buckets/pyproject.toml @@ -5,7 +5,7 @@ description = "DeepAgent with persistant storage in Orchestrator Buckets" authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] requires-python = ">=3.11" dependencies = [ - "deepagents>=0.3.9", + "deepagents>=0.7.11, <0.8.0", "langchain-anthropic>=1.3.1", "langchain-tavily>=0.2.17", "langgraph>=1.0.7", diff --git a/samples/deepagent-storage-buckets/src/deepagent_storage_buckets/buckets_backend.py b/samples/deepagent-storage-buckets/src/deepagent_storage_buckets/buckets_backend.py index ad0aed3ae..4f5dd9f10 100644 --- a/samples/deepagent-storage-buckets/src/deepagent_storage_buckets/buckets_backend.py +++ b/samples/deepagent-storage-buckets/src/deepagent_storage_buckets/buckets_backend.py @@ -21,13 +21,17 @@ FileDownloadResponse, FileInfo, FileUploadResponse, + GlobResult, GrepMatch, + GrepResult, + LsResult, + ReadResult, WriteResult, ) from deepagents.backends.utils import ( check_empty_content, - format_content_with_line_numbers, perform_string_replacement, + slice_read_response, ) from uipath.platform import UiPath from uipath.platform.common import PagedResult @@ -51,6 +55,26 @@ class UiPathBucketConfig: prefix: str = "" +def _content_str(data: dict[str, Any]) -> str: + """File content as a single string. + + Current `FileData` stores `content` as one string. Blobs written by earlier + versions of this sample stored a list of lines, so both are accepted. + """ + content = data.get("content", "") + if isinstance(content, list): + return "\n".join(content) + return content + + +def _content_lines(data: dict[str, Any]) -> list[str]: + """File content split into lines, for either stored shape.""" + content = data.get("content", "") + if isinstance(content, list): + return content + return content.splitlines() + + class UiPathBucketBackend(BackendProtocol): """UiPath Storage Buckets backend for Deep Agents file operations. @@ -148,8 +172,7 @@ def _get_file_data(self, path: str) -> dict[str, Any] | None: try: return json.loads(content.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): - lines = content.decode("utf-8", errors="replace").splitlines() - return {"content": lines} + return {"content": content.decode("utf-8", errors="replace")} async def _aget_file_data(self, path: str) -> dict[str, Any] | None: """Get file data dict from bucket asynchronously.""" @@ -159,8 +182,7 @@ async def _aget_file_data(self, path: str) -> dict[str, Any] | None: try: return json.loads(content.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): - lines = content.decode("utf-8", errors="replace").splitlines() - return {"content": lines} + return {"content": content.decode("utf-8", errors="replace")} def _put_file_data( self, path: str, data: dict[str, Any], *, update_modified: bool = True @@ -256,7 +278,7 @@ async def _alist_files(self, prefix: str = "") -> list[BucketFile]: return results - def ls_info(self, path: str) -> list[FileInfo]: + def ls(self, path: str) -> LsResult: """List files in a directory.""" prefix = path.lstrip("/") if prefix and not prefix.endswith("/"): @@ -277,17 +299,19 @@ def ls_info(self, path: str) -> list[FileInfo]: seen_dirs.add(dir_path) results.append({"path": dir_path, "is_dir": True}) else: - results.append({ - "path": vpath, - "is_dir": False, - "size": file.size or 0, - "modified_at": file.last_modified, - }) + results.append( + { + "path": vpath, + "is_dir": False, + "size": file.size or 0, + "modified_at": file.last_modified, + } + ) results.sort(key=lambda x: x.get("path", "")) - return results + return LsResult(entries=results) - async def als_info(self, path: str) -> list[FileInfo]: + async def als(self, path: str) -> LsResult: """List files in a directory asynchronously.""" prefix = path.lstrip("/") if prefix and not prefix.endswith("/"): @@ -308,89 +332,79 @@ async def als_info(self, path: str) -> list[FileInfo]: seen_dirs.add(dir_path) results.append({"path": dir_path, "is_dir": True}) else: - results.append({ - "path": vpath, - "is_dir": False, - "size": file.size or 0, - "modified_at": file.last_modified, - }) + results.append( + { + "path": vpath, + "is_dir": False, + "size": file.size or 0, + "modified_at": file.last_modified, + } + ) results.sort(key=lambda x: x.get("path", "")) - return results + return LsResult(entries=results) + + def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> ReadResult: + """Read file content for the requested line range. - def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str: - """Read file content with line numbers.""" + Returns the raw window plus pagination metadata; the filesystem + middleware adds the line-number gutter, so this must not format. + """ data = self._get_file_data(file_path) if data is None: - return f"Error: File '{file_path}' not found" + return ReadResult(error=f"Error: File '{file_path}' not found") - lines = data.get("content", []) - if not lines: + if not data.get("content"): empty_msg = check_empty_content("") if empty_msg: - return empty_msg + return ReadResult(error=empty_msg) - if offset >= len(lines): - return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)" + return slice_read_response(data, offset, limit) - selected = lines[offset : offset + limit] - return format_content_with_line_numbers(selected, start_line=offset + 1) + async def aread( + self, file_path: str, offset: int = 0, limit: int = 2000 + ) -> ReadResult: + """Read file content for the requested line range. - async def aread(self, file_path: str, offset: int = 0, limit: int = 2000) -> str: - """Read file content with line numbers asynchronously.""" + Returns the raw window plus pagination metadata; the filesystem + middleware adds the line-number gutter, so this must not format. + """ data = await self._aget_file_data(file_path) if data is None: - return f"Error: File '{file_path}' not found" + return ReadResult(error=f"Error: File '{file_path}' not found") - lines = data.get("content", []) - if not lines: + if not data.get("content"): empty_msg = check_empty_content("") if empty_msg: - return empty_msg + return ReadResult(error=empty_msg) - if offset >= len(lines): - return f"Error: Line offset {offset} exceeds file length ({len(lines)} lines)" - - selected = lines[offset : offset + limit] - return format_content_with_line_numbers(selected, start_line=offset + 1) + return slice_read_response(data, offset, limit) def write(self, file_path: str, content: str) -> WriteResult: - """Create a new file.""" - if self._exists(file_path): - return WriteResult( - error=f"Cannot write to {file_path} because it already exists. " - "Read and then make an edit, or write to a new path." - ) - + """Create a file, replacing it if the path already exists.""" now = datetime.now(timezone.utc).isoformat() data = { - "content": content.splitlines(), + "content": content, "created_at": now, "modified_at": now, } try: self._put_file_data(file_path, data, update_modified=False) - return WriteResult(path=file_path, files_update=None) + return WriteResult(path=file_path) except Exception as e: return WriteResult(error=f"Error writing file '{file_path}': {e}") async def awrite(self, file_path: str, content: str) -> WriteResult: - """Create a new file asynchronously.""" - if await self._aexists(file_path): - return WriteResult( - error=f"Cannot write to {file_path} because it already exists. " - "Read and then make an edit, or write to a new path." - ) - + """Create a file asynchronously, replacing it if the path already exists.""" now = datetime.now(timezone.utc).isoformat() data = { - "content": content.splitlines(), + "content": content, "created_at": now, "modified_at": now, } try: await self._aput_file_data(file_path, data, update_modified=False) - return WriteResult(path=file_path, files_update=None) + return WriteResult(path=file_path) except Exception as e: return WriteResult(error=f"Error writing file '{file_path}': {e}") @@ -406,20 +420,20 @@ def edit( if data is None: return EditResult(error=f"Error: File '{file_path}' not found") - content = "\n".join(data.get("content", [])) - result = perform_string_replacement(content, old_string, new_string, replace_all) + content = _content_str(data) + result = perform_string_replacement( + content, old_string, new_string, replace_all + ) if isinstance(result, str): return EditResult(error=result) new_content, occurrences = result - data["content"] = new_content.splitlines() + data["content"] = new_content try: self._put_file_data(file_path, data) - return EditResult( - path=file_path, files_update=None, occurrences=int(occurrences) - ) + return EditResult(path=file_path, occurrences=int(occurrences)) except Exception as e: return EditResult(error=f"Error editing file '{file_path}': {e}") @@ -435,31 +449,36 @@ async def aedit( if data is None: return EditResult(error=f"Error: File '{file_path}' not found") - content = "\n".join(data.get("content", [])) - result = perform_string_replacement(content, old_string, new_string, replace_all) + content = _content_str(data) + result = perform_string_replacement( + content, old_string, new_string, replace_all + ) if isinstance(result, str): return EditResult(error=result) new_content, occurrences = result - data["content"] = new_content.splitlines() + data["content"] = new_content try: await self._aput_file_data(file_path, data) - return EditResult( - path=file_path, files_update=None, occurrences=int(occurrences) - ) + return EditResult(path=file_path, occurrences=int(occurrences)) except Exception as e: return EditResult(error=f"Error editing file '{file_path}': {e}") - def grep_raw( - self, pattern: str, path: str | None = None, glob: str | None = None - ) -> list[GrepMatch] | str: + def grep( + self, + pattern: str, + path: str | None = None, + glob: str | None = None, + *, + max_count: int | None = None, + ) -> GrepResult: """Search for pattern in files.""" try: regex = re.compile(pattern) except re.error as e: - return f"Invalid regex pattern: {e}" + return GrepResult(error=f"Invalid regex pattern: {e}") search_prefix = (path or "/").lstrip("/") files = self._list_files(search_prefix) @@ -476,20 +495,27 @@ def grep_raw( if data is None: continue - for line_num, line in enumerate(data.get("content", []), 1): + for line_num, line in enumerate(_content_lines(data), 1): if regex.search(line): matches.append({"path": vpath, "line": line_num, "text": line}) + if max_count is not None and len(matches) >= max_count: + return GrepResult(matches=matches, truncated=True) - return matches + return GrepResult(matches=matches) - async def agrep_raw( - self, pattern: str, path: str | None = None, glob: str | None = None - ) -> list[GrepMatch] | str: + async def agrep( + self, + pattern: str, + path: str | None = None, + glob: str | None = None, + *, + max_count: int | None = None, + ) -> GrepResult: """Search for pattern in files asynchronously.""" try: regex = re.compile(pattern) except re.error as e: - return f"Invalid regex pattern: {e}" + return GrepResult(error=f"Invalid regex pattern: {e}") search_prefix = (path or "/").lstrip("/") files = await self._alist_files(search_prefix) @@ -506,14 +532,17 @@ async def agrep_raw( if data is None: continue - for line_num, line in enumerate(data.get("content", []), 1): + for line_num, line in enumerate(_content_lines(data), 1): if regex.search(line): matches.append({"path": vpath, "line": line_num, "text": line}) + if max_count is not None and len(matches) >= max_count: + return GrepResult(matches=matches, truncated=True) - return matches + return GrepResult(matches=matches) - def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: + def glob(self, pattern: str, path: str | None = None) -> GlobResult: """Find files matching a glob pattern.""" + path = path or "/" search_prefix = path.lstrip("/") files = self._list_files(search_prefix) results: list[FileInfo] = [] @@ -523,18 +552,21 @@ def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: rel_path = vpath[len(path) :].lstrip("/") if path != "/" else vpath[1:] if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(vpath, pattern): - results.append({ - "path": vpath, - "is_dir": False, - "size": file.size or 0, - "modified_at": file.last_modified, - }) + results.append( + { + "path": vpath, + "is_dir": False, + "size": file.size or 0, + "modified_at": file.last_modified, + } + ) results.sort(key=lambda x: x.get("path", "")) - return results + return GlobResult(matches=results) - async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: + async def aglob(self, pattern: str, path: str | None = None) -> GlobResult: """Find files matching a glob pattern asynchronously.""" + path = path or "/" search_prefix = path.lstrip("/") files = await self._alist_files(search_prefix) results: list[FileInfo] = [] @@ -544,15 +576,17 @@ async def aglob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: rel_path = vpath[len(path) :].lstrip("/") if path != "/" else vpath[1:] if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(vpath, pattern): - results.append({ - "path": vpath, - "is_dir": False, - "size": file.size or 0, - "modified_at": file.last_modified, - }) + results.append( + { + "path": vpath, + "is_dir": False, + "size": file.size or 0, + "modified_at": file.last_modified, + } + ) results.sort(key=lambda x: x.get("path", "")) - return results + return GlobResult(matches=results) def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: """Upload multiple files.""" @@ -570,7 +604,9 @@ def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadRespons except LookupError: responses.append(FileUploadResponse(path=path, error="file_not_found")) except PermissionError: - responses.append(FileUploadResponse(path=path, error="permission_denied")) + responses.append( + FileUploadResponse(path=path, error="permission_denied") + ) except Exception: responses.append(FileUploadResponse(path=path, error="invalid_path")) @@ -594,7 +630,9 @@ async def aupload_files( except LookupError: responses.append(FileUploadResponse(path=path, error="file_not_found")) except PermissionError: - responses.append(FileUploadResponse(path=path, error="permission_denied")) + responses.append( + FileUploadResponse(path=path, error="permission_denied") + ) except Exception: responses.append(FileUploadResponse(path=path, error="invalid_path")) @@ -609,7 +647,9 @@ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: content = self._download_content(path) if content is None: responses.append( - FileDownloadResponse(path=path, content=None, error="file_not_found") + FileDownloadResponse( + path=path, content=None, error="file_not_found" + ) ) else: responses.append( @@ -617,7 +657,9 @@ def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: ) except PermissionError: responses.append( - FileDownloadResponse(path=path, content=None, error="permission_denied") + FileDownloadResponse( + path=path, content=None, error="permission_denied" + ) ) except Exception: responses.append( @@ -635,7 +677,9 @@ async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: content = await self._adownload_content(path) if content is None: responses.append( - FileDownloadResponse(path=path, content=None, error="file_not_found") + FileDownloadResponse( + path=path, content=None, error="file_not_found" + ) ) else: responses.append( @@ -643,7 +687,9 @@ async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: ) except PermissionError: responses.append( - FileDownloadResponse(path=path, content=None, error="permission_denied") + FileDownloadResponse( + path=path, content=None, error="permission_denied" + ) ) except Exception: responses.append( diff --git a/samples/simple-deepagent/pyproject.toml b/samples/simple-deepagent/pyproject.toml index e82ea56f0..d673a42dc 100644 --- a/samples/simple-deepagent/pyproject.toml +++ b/samples/simple-deepagent/pyproject.toml @@ -5,7 +5,7 @@ description = "Simple DeepAgent for research tasks using Tavily search" authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] requires-python = ">=3.11" dependencies = [ - "deepagents>=0.3.9", + "deepagents>=0.7.11, <0.8.0", "langchain-anthropic>=1.3.1", "langchain-tavily>=0.2.17", "langgraph>=1.0.7", diff --git a/src/uipath_langchain/_utils/durable_interrupt/__init__.py b/src/uipath_langchain/_utils/durable_interrupt/__init__.py index bd36440fb..42f6cabfe 100644 --- a/src/uipath_langchain/_utils/durable_interrupt/__init__.py +++ b/src/uipath_langchain/_utils/durable_interrupt/__init__.py @@ -1,13 +1,17 @@ """Durable interrupt package for side-effect-safe interrupt/resume in LangGraph.""" from .decorator import ( + SUSPENDS_RUN, _durable_state, durable_interrupt, + suspends_run, ) from .skip_interrupt import SkipInterruptValue __all__ = [ + "SUSPENDS_RUN", "durable_interrupt", "SkipInterruptValue", "_durable_state", + "suspends_run", ] diff --git a/src/uipath_langchain/_utils/durable_interrupt/decorator.py b/src/uipath_langchain/_utils/durable_interrupt/decorator.py index 1d304f11f..a3d0d0a16 100644 --- a/src/uipath_langchain/_utils/durable_interrupt/decorator.py +++ b/src/uipath_langchain/_utils/durable_interrupt/decorator.py @@ -94,6 +94,41 @@ def _inject_resume(scratchpad: Any, value: Any) -> Any: return value +SUSPENDS_RUN = "suspends_run" +"""Tool-metadata key marking a tool that can suspend the run instead of returning. + +Such a tool may raise ``GraphInterrupt`` rather than produce a value: the run +checkpoints, and the node is replayed from that checkpoint on resume. Callers that +invoke tools *outside* the graph's tool node -- the QuickJS code interpreter's +programmatic tool calling, for one -- must not offer these, because a replayed +node re-runs every call made before the interrupt, and because such bridges +bypass approval hooks. + +The flag describes that behaviour rather than how it is reached. It covers both +``durable_interrupt`` and a bare ``interrupt()``, and it is set unconditionally on +a tool that suspends only sometimes -- one returning a ``SkipInterruptValue`` on +its fast path, say -- since the safe answer for a caller outside the tool node is +the same either way. + +Set it in the tool's ``metadata`` in every factory that can suspend; +``tests/agent/tools/test_suspends_run_metadata.py`` fails if one forgets. +""" + + +def suspends_run(tool: Any) -> bool: + """Whether ``tool`` suspends the run instead of returning a value. + + Reads :data:`SUSPENDS_RUN` from the tool's metadata, so it is accurate + per-tool even where one factory builds both suspending and non-suspending + variants (``context_tool`` does, by retrieval mode). + + Fails closed only where the flag is present: an unstamped tool reports + ``False``. ``tests/agent/tools/test_suspends_run_metadata.py`` is what keeps + that safe, by failing when a suspending factory ships without the stamp. + """ + return bool((getattr(tool, "metadata", None) or {}).get(SUSPENDS_RUN)) + + def durable_interrupt(fn: F) -> F: """Decorator that executes a side-effecting function exactly once and interrupts. diff --git a/src/uipath_langchain/agent/advanced/__init__.py b/src/uipath_langchain/agent/advanced/__init__.py index 1605996aa..dc4440d3d 100644 --- a/src/uipath_langchain/agent/advanced/__init__.py +++ b/src/uipath_langchain/agent/advanced/__init__.py @@ -2,13 +2,17 @@ from deepagents import CompiledSubAgent, SubAgent from deepagents.backends import BackendProtocol, FilesystemBackend -from deepagents.backends.protocol import BackendFactory from .agent import ( create_advanced_agent, create_advanced_agent_graph, create_conversational_advanced_agent_graph, ) +from .code_interpreter import ( + PTC_FILESYSTEM_TOOLS, + build_code_interpreter_middleware, + ptc_tool_names, +) from .types import AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState from .utils import ( MEMORY_DIR_NAME, @@ -21,15 +25,17 @@ "MEMORY_DIR_NAME", "MEMORY_INDEX_FILENAME", "MEMORY_INDEX_VIRTUAL_PATH", + "PTC_FILESYSTEM_TOOLS", "AdvancedAgentGraphState", - "BackendFactory", "BackendProtocol", "CompiledSubAgent", "ConversationalAdvancedAgentGraphState", "FilesystemBackend", "SubAgent", + "build_code_interpreter_middleware", "create_advanced_agent", "create_advanced_agent_graph", "create_conversational_advanced_agent_graph", "create_state_with_input", + "ptc_tool_names", ] diff --git a/src/uipath_langchain/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index ea6e1d564..f93a4d3fc 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -6,14 +6,13 @@ from deepagents import CompiledSubAgent, SubAgent from deepagents import create_deep_agent as _create_deep_agent -from deepagents.backends import BackendProtocol -from deepagents.backends.filesystem import FilesystemBackend -from deepagents.backends.protocol import BackendFactory +from deepagents.backends import BackendProtocol, FilesystemBackend from langchain.agents.middleware import ( AgentMiddleware, AgentState, ModelRequest, ModelResponse, + TodoListMiddleware, ) from langchain.agents.structured_output import ResponseFormat from langchain_core.language_models import BaseChatModel @@ -129,7 +128,7 @@ def create_advanced_agent( system_prompt: str | SystemMessage | None = "", tools: Sequence[BaseTool] = (), subagents: Sequence[SubAgent | CompiledSubAgent] = (), - backend: BackendProtocol | BackendFactory | None = None, + backend: BackendProtocol | None = None, response_format: ResponseFormat[Any] | None = None, memory: Sequence[str] = (), middleware: Sequence[AgentMiddleware[Any, Any]] = (), @@ -143,6 +142,10 @@ def create_advanced_agent( ``skills`` is a list of skill source paths for deepagents' ``SkillsMiddleware``; ``None`` or empty disables it (mirroring ``_create_deep_agent``'s contract). + + ``TodoListMiddleware`` is prepended so ``write_todos`` stays available: it left + the deepagents default stack in 0.7.0. Callers may replace it by passing their + own instance, which deepagents matches on ``.name``. """ return _create_deep_agent( model=model, @@ -152,7 +155,7 @@ def create_advanced_agent( backend=backend, response_format=response_format, memory=list(memory) or None, - middleware=list(middleware), + middleware=[TodoListMiddleware(), *middleware], skills=list(skills) if skills else None, ) @@ -161,12 +164,13 @@ def create_advanced_agent_graph( model: BaseChatModel, tools: Sequence[BaseTool], system_prompt: str | Callable[[dict[str, Any]], str], - backend: BackendProtocol | BackendFactory | None, + backend: BackendProtocol | None, response_format: ResponseFormat[Any] | None, input_schema: type[BaseModel] | None, output_schema: type[BaseModel], build_user_message: Callable[[dict[str, Any]], str], skills: Sequence[str] | None = None, + middleware: Sequence[AgentMiddleware[Any, Any]] = (), ) -> StateGraph[Any, Any, Any, Any]: """Wrap the advanced agent in a parent graph that maps typed I/O to/from messages. @@ -190,7 +194,7 @@ def create_advanced_agent_graph( backend=backend, response_format=response_format, memory=memory_sources, - middleware=runtime_prompt.middleware, + middleware=[*runtime_prompt.middleware, *middleware], skills=skills, ) @@ -249,9 +253,10 @@ def create_conversational_advanced_agent_graph( model: BaseChatModel, tools: Sequence[BaseTool], system_prompt: str | Callable[[dict[str, Any]], str], - backend: BackendProtocol | BackendFactory | None, + backend: BackendProtocol | None, skills: Sequence[str] | None = None, input_schema: type[BaseModel] | None = None, + middleware: Sequence[AgentMiddleware[Any, Any]] = (), ) -> StateGraph[Any, Any, Any, Any]: """Wrap the advanced agent in a parent graph that speaks the conversational contract. @@ -279,7 +284,7 @@ def create_conversational_advanced_agent_graph( system_prompt=runtime_prompt.static_prompt, backend=backend, memory=memory_sources, - middleware=runtime_prompt.middleware, + middleware=[*runtime_prompt.middleware, *middleware], skills=skills, ) diff --git a/src/uipath_langchain/agent/advanced/code_interpreter.py b/src/uipath_langchain/agent/advanced/code_interpreter.py new file mode 100644 index 000000000..e44703127 --- /dev/null +++ b/src/uipath_langchain/agent/advanced/code_interpreter.py @@ -0,0 +1,185 @@ +"""The QuickJS code interpreter for advanced agents, and what it may call. + +``CodeInterpreterMiddleware`` adds one ``eval`` tool: a persistent JavaScript REPL +in a WASM guest (QuickJS-ng under wasmtime). It serves three purposes in a single +tool call -- computation, programmatic tool calling (PTC), and subagent +orchestration through the top-level ``task()`` global. + +The guest has no ambient capability: no network, no filesystem, no ``fetch``, no +``require``, no timers. Everything it can reach arrives through the ``ptc`` +allowlist, which makes that allowlist the entire security surface of the feature. +It is derived here rather than configured, because the rule that governs it is a +property of our tools (see :data:`SUSPENDS_RUN`) and not of any one consumer. + +Requires the ``code-interpreter`` extra:: + + uv add "uipath-langchain[code-interpreter]" +""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable, Sequence +from typing import Any + +from langchain.agents.middleware import AgentMiddleware +from langchain_core.tools import BaseTool + +from uipath_langchain._utils.durable_interrupt import suspends_run + +logger = logging.getLogger(__name__) + +_MISSING_EXTRA = ( + "The code interpreter needs the 'code-interpreter' extra. Install it with " + '`uv add "uipath-langchain[code-interpreter]"` (or `pip install ' + '"uipath-langchain[code-interpreter]"`).' +) + +# deepagents' own file tools, safe to reach from inside the REPL: each returns a +# value, and each routes through the backend, so ``virtual_mode`` still resolves +# and bounds every path. ``delete`` and ``execute`` are deliberately absent, and +# ``task`` is reserved -- upstream raises if it is listed, because it is exposed +# as the ``task()`` global instead. +PTC_FILESYSTEM_TOOLS: tuple[str, ...] = ( + "ls", + "read_file", + "write_file", + "edit_file", + "glob", + "grep", +) + +_RESERVED_TOOL_NAMES = frozenset({"task"}) + +# Per-eval wall clock. The REPL is for orchestration and arithmetic, not long +# computation, and a bridged tool call does not consume it. +DEFAULT_EVAL_TIMEOUT_SECONDS = 5.0 + + +def ptc_tool_names(tools: Sequence[BaseTool]) -> list[str]: + """Names of the agent tools that may be called from inside the REPL. + + Three exclusions, each for a different reason: + + - **Tools that suspend the run.** One raising ``GraphInterrupt`` never returns + a value into the JS ``await``. Worse, the node is replayed from its + checkpoint on resume, so the ``eval`` re-runs from the top and every bridged + call made before the interrupt fires a second time. Upstream also documents + that PTC bridges bypass ``interrupt_on`` approval hooks, so an escalation + reached this way would skip its own approval. + - **Names that cannot be JavaScript identifiers.** Low-code tool names come + from ``agent.json`` and may hold spaces, dots or non-ASCII characters. + Upstream raises ``ValueError`` for those from inside ``wrap_model_call``, + faulting the run mid-turn, so they are dropped here instead. + - **camelCase collisions.** ``get_invoice`` and ``get-invoice`` both become + ``getInvoice``, and upstream dedupes by tool name rather than camel name. + Every member of a colliding group is dropped: binding one of two + identically-named JS functions would silently call the wrong tool. + + An excluded tool stays fully available as an ordinary tool call, so exclusion + costs a model round trip, never a capability. + """ + is_valid, to_camel = _name_validators() + + eligible: list[BaseTool] = [] + for tool in tools: + if tool.name in _RESERVED_TOOL_NAMES: + continue + if suspends_run(tool): + logger.debug("Tool %r withheld from PTC: it suspends the run", tool.name) + continue + if not is_valid(tool.name): + logger.info( + "Tool %r withheld from PTC: %r is not a valid JavaScript identifier", + tool.name, + to_camel(tool.name), + ) + continue + eligible.append(tool) + + return [t.name for t in _without_camel_collisions(eligible, to_camel)] + + +def build_code_interpreter_middleware( + tools: Sequence[BaseTool], + *, + timeout: float = DEFAULT_EVAL_TIMEOUT_SECONDS, + subagents: bool = True, +) -> list[AgentMiddleware[Any, Any]]: + """The code-interpreter middleware for ``tools``, ready to pass as ``middleware``. + + Returned as a list so a caller can splice it into a middleware sequence + without branching. + + Args: + tools: The agent's tools. Eligible ones become callable from the REPL. + timeout: Per-eval wall clock in seconds. + subagents: Expose the top-level ``task()`` global when the host has a + deepagents ``task`` tool. A no-op for an agent with no subagents. + + Raises: + ImportError: If the ``code-interpreter`` extra is not installed. + """ + middleware_cls = _code_interpreter_middleware_cls() + exposed = ptc_tool_names(tools) + logger.info( + "Code interpreter enabled: %d of %d agent tools exposed for PTC", + len(exposed), + len(tools), + ) + return [ + middleware_cls( + ptc=[*exposed, *PTC_FILESYSTEM_TOOLS], + # Globals persist across turns of a LangGraph thread. Whether that + # survives a suspend/resume boundary is unverified; "turn" is the + # fallback if it does not. + mode="thread", + subagents=subagents, + timeout=timeout, + ) + ] + + +def _without_camel_collisions( + tools: Iterable[BaseTool], to_camel: Any +) -> list[BaseTool]: + """Drop every tool whose camelCase name is shared with another tool.""" + by_camel: dict[str, list[BaseTool]] = {} + for tool in tools: + by_camel.setdefault(to_camel(tool.name), []).append(tool) + + kept: list[BaseTool] = [] + for camel, group in by_camel.items(): + if len(group) > 1: + logger.warning( + "Tools %s withheld from PTC: their names all map to %r", + [t.name for t in group], + camel, + ) + continue + kept.append(group[0]) + return kept + + +def _code_interpreter_middleware_cls() -> Any: + """Import ``CodeInterpreterMiddleware``, or raise with install guidance.""" + try: + from langchain_quickjs import CodeInterpreterMiddleware + except ImportError as exc: # pragma: no cover - exercised via monkeypatch + raise ImportError(_MISSING_EXTRA) from exc + return CodeInterpreterMiddleware + + +def _name_validators() -> tuple[Any, Any]: + """Upstream's identifier rule and camelCase conversion. + + Taken from ``langchain_quickjs._ptc`` rather than reimplemented: a local copy + risks drifting *looser* than upstream, and anything upstream rejects raises + from inside ``wrap_model_call``, faulting the run rather than degrading. The + import is pinned by ``tests/agent/advanced/test_code_interpreter.py``. + """ + try: + from langchain_quickjs._ptc import is_valid_ptc_tool_name, to_camel_case + except ImportError as exc: # pragma: no cover - exercised via monkeypatch + raise ImportError(_MISSING_EXTRA) from exc + return is_valid_ptc_tool_name, to_camel_case diff --git a/src/uipath_langchain/agent/advanced/utils.py b/src/uipath_langchain/agent/advanced/utils.py index cee975c83..888ef64cd 100644 --- a/src/uipath_langchain/agent/advanced/utils.py +++ b/src/uipath_langchain/agent/advanced/utils.py @@ -8,7 +8,6 @@ from typing import Any, NamedTuple, cast from deepagents.backends import BackendProtocol, FilesystemBackend -from deepagents.backends.protocol import BackendFactory from jsonpath_ng import parse as jsonpath_parse # type: ignore[import-untyped] from pydantic import BaseModel, ConfigDict from uipath.platform import UiPath @@ -59,7 +58,7 @@ class _AttachmentDownload(NamedTuple): async def resolve_input_attachments( - backend: BackendProtocol | BackendFactory | None, + backend: BackendProtocol | None, attachment_paths: list[str], input_args: dict[str, Any], ) -> dict[str, Any]: diff --git a/src/uipath_langchain/agent/tools/client_side_tool.py b/src/uipath_langchain/agent/tools/client_side_tool.py index b6cc710c3..fe0e93c11 100644 --- a/src/uipath_langchain/agent/tools/client_side_tool.py +++ b/src/uipath_langchain/agent/tools/client_side_tool.py @@ -9,7 +9,10 @@ from uipath.agent.models.agent import AgentClientSideToolResourceConfig from uipath.eval.mocks import mockable -from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain._utils.durable_interrupt import ( + SUSPENDS_RUN, + durable_interrupt, +) from uipath_langchain.agent.contracts.client_side_tools import ( ClientSideToolInfo as ClientSideToolInfo, ) @@ -126,6 +129,7 @@ async def wait_for_client_execution() -> dict[str, Any]: metadata={ IS_CONVERSATIONAL_CLIENT_SIDE_TOOL: True, "output_schema": resource.output_schema, + SUSPENDS_RUN: True, }, ) diff --git a/src/uipath_langchain/agent/tools/context_tool.py b/src/uipath_langchain/agent/tools/context_tool.py index 7c1c1a508..5479d8541 100644 --- a/src/uipath_langchain/agent/tools/context_tool.py +++ b/src/uipath_langchain/agent/tools/context_tool.py @@ -35,7 +35,10 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._utils import get_execution_folder_path -from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain._utils.durable_interrupt import ( + SUSPENDS_RUN, + durable_interrupt, +) from uipath_langchain.agent.exceptions import ( AgentRuntimeError, AgentRuntimeErrorCode, @@ -471,6 +474,7 @@ async def context_deep_rag_wrapper( "display_name": resource.name, "index_name": resource.index_name, "context_retrieval_mode": resource.settings.retrieval_mode, + SUSPENDS_RUN: True, }, ) tool.set_tool_wrappers(awrapper=context_deep_rag_wrapper) @@ -624,6 +628,7 @@ async def context_batch_transform_wrapper( "index_name": resource.index_name, "context_retrieval_mode": resource.settings.retrieval_mode, "output_schema": output_model, + SUSPENDS_RUN: True, }, ) tool.set_tool_wrappers(awrapper=job_attachment_wrapper) diff --git a/src/uipath_langchain/agent/tools/escalation_tool.py b/src/uipath_langchain/agent/tools/escalation_tool.py index 65d80b1c2..31089e82f 100644 --- a/src/uipath_langchain/agent/tools/escalation_tool.py +++ b/src/uipath_langchain/agent/tools/escalation_tool.py @@ -26,7 +26,10 @@ get_current_span_and_trace_ids, get_execution_folder_path, ) -from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain._utils.durable_interrupt import ( + SUSPENDS_RUN, + durable_interrupt, +) from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( create_model, create_output_model, @@ -514,6 +517,7 @@ async def escalation_wrapper( argument_properties=channel.argument_properties, metadata={ "tool_type": "escalation", + SUSPENDS_RUN: True, "display_name": _try_get_channel_app_name(channel) or channel.name, "channel_type": channel.type, "recipient": None, diff --git a/src/uipath_langchain/agent/tools/extraction_tool.py b/src/uipath_langchain/agent/tools/extraction_tool.py index ea040b223..e825284ce 100644 --- a/src/uipath_langchain/agent/tools/extraction_tool.py +++ b/src/uipath_langchain/agent/tools/extraction_tool.py @@ -14,6 +14,7 @@ from uipath.platform.errors import EnrichedException from uipath.runtime.errors import UiPathErrorCategory +from uipath_langchain._utils.durable_interrupt import SUSPENDS_RUN from uipath_langchain.agent.attachments.job_attachments import ( get_job_attachment_paths, get_job_attachments, @@ -159,6 +160,7 @@ async def extraction_tool_wrapper( output_type=ExtractionResponseIXP, metadata={ "tool_type": "ixp_extraction", + SUSPENDS_RUN: True, "display_name": resource.name, "project_name": project_name, "version_tag": version_tag, diff --git a/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py b/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py index 3f828c91c..e825799dd 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py @@ -26,6 +26,7 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._utils.durable_interrupt import ( + SUSPENDS_RUN, SkipInterruptValue, durable_interrupt, ) @@ -205,6 +206,7 @@ async def upload_result_attachment(): "args_schema": input_model, "output_schema": output_model, "retrieval_mode": "BatchTransform", + SUSPENDS_RUN: True, "output_columns": [ {"name": col.name, "description": col.description} for col in batch_transform_output_columns diff --git a/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py b/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py index 0d866df1f..3425d8fe0 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py @@ -24,6 +24,7 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._utils.durable_interrupt import ( + SUSPENDS_RUN, SkipInterruptValue, durable_interrupt, ) @@ -185,6 +186,7 @@ async def create_deeprag(): "display_name": tool_name, "args_schema": input_model, "output_schema": output_model, + SUSPENDS_RUN: True, }, ) tool.set_tool_wrappers(awrapper=job_attachment_wrapper) diff --git a/src/uipath_langchain/agent/tools/ixp_escalation_tool.py b/src/uipath_langchain/agent/tools/ixp_escalation_tool.py index 45abfb07c..16396c763 100644 --- a/src/uipath_langchain/agent/tools/ixp_escalation_tool.py +++ b/src/uipath_langchain/agent/tools/ixp_escalation_tool.py @@ -21,7 +21,10 @@ ) from uipath.runtime.errors import UiPathErrorCategory -from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain._utils.durable_interrupt import ( + SUSPENDS_RUN, + durable_interrupt, +) from uipath_langchain.agent.react.types import AgentGraphState from uipath_langchain.agent.tools.tool_node import ( ToolWrapperMixin, @@ -183,6 +186,7 @@ async def ixp_escalation_tool_wrapper( output_type=OutputSchema, metadata={ "tool_type": "vs_escalation", + SUSPENDS_RUN: True, "display_name": channel.properties.app_name, "channel_type": channel.type, "ixp_tool_id": ixp_tool_name, diff --git a/src/uipath_langchain/agent/tools/process_tool.py b/src/uipath_langchain/agent/tools/process_tool.py index 721eb0fe5..5fc2fc32c 100644 --- a/src/uipath_langchain/agent/tools/process_tool.py +++ b/src/uipath_langchain/agent/tools/process_tool.py @@ -13,7 +13,10 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._utils import get_execution_folder_path -from uipath_langchain._utils.durable_interrupt import durable_interrupt +from uipath_langchain._utils.durable_interrupt import ( + SUSPENDS_RUN, + durable_interrupt, +) from uipath_langchain.agent.attachments.job_attachments import get_job_attachments from uipath_langchain.agent.exceptions import raise_for_enriched from uipath_langchain.agent.react.jsonschema_pydantic_converter import ( @@ -134,6 +137,7 @@ async def start_job(): output_type=output_model, metadata={ "tool_type": resource.type.lower(), + SUSPENDS_RUN: True, "display_name": process_name, "folder_path": folder_path, "args_schema": input_model, diff --git a/tests/agent/advanced/test_code_interpreter.py b/tests/agent/advanced/test_code_interpreter.py new file mode 100644 index 000000000..a93adfedb --- /dev/null +++ b/tests/agent/advanced/test_code_interpreter.py @@ -0,0 +1,248 @@ +"""Tests for the QuickJS code interpreter and its PTC allowlist policy. + +The allowlist is the whole security surface of this feature: the WASM guest has +no ambient capability, so anything the sandboxed JS reaches, it reached through +``ptc``. These cover what must be in it, what must stay out, and that the sandbox +boundary still holds for the file tools that are in it. + +Requires the ``code-interpreter`` extra, which CI installs via +``uv sync --all-extras``. +""" + +import asyncio +import sys +from pathlib import Path +from typing import Any, Sequence + +import pytest +from deepagents.backends import FilesystemBackend +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage +from langchain_core.tools import BaseTool, StructuredTool, tool + +from uipath_langchain._utils.durable_interrupt import SUSPENDS_RUN +from uipath_langchain.agent.advanced import ( + PTC_FILESYSTEM_TOOLS, + build_code_interpreter_middleware, + create_advanced_agent, + ptc_tool_names, +) + +pytest.importorskip("langchain_quickjs", reason="needs the code-interpreter extra") + + +def _tool(name: str, *, suspends: bool = False) -> BaseTool: + """A minimal agent tool, optionally flagged as suspending the run.""" + return StructuredTool.from_function( + func=lambda value="": value, + name=name, + description=f"tool {name}", + metadata={SUSPENDS_RUN: True} if suspends else {}, + ) + + +class _ScriptedModel(GenericFakeChatModel): + """Replays a fixed script and accepts any tool binding.""" + + model_name: str = "test-model-code-interpreter" + + def _get_ls_params(self, stop: list[str] | None = None, **kwargs: Any) -> Any: + return {"ls_provider": "openai", "ls_model_name": self.model_name} + + def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> "_ScriptedModel": + return self + + +def _run_js(code: str, workspace: Path, tools: Sequence[BaseTool] = ()) -> str: + """Run one ``eval`` call through a real advanced agent, return the tool output.""" + model = _ScriptedModel( + messages=iter( + [ + AIMessage( + content="", + tool_calls=[{"name": "eval", "args": {"code": code}, "id": "c1"}], + ), + AIMessage(content="done"), + ] + ) + ) + graph = create_advanced_agent( + model=model, + tools=list(tools), + backend=FilesystemBackend(root_dir=workspace, virtual_mode=True), + middleware=build_code_interpreter_middleware(list(tools)), + ) + result = asyncio.run( + graph.ainvoke({"messages": [{"role": "user", "content": "go"}]}) + ) + tool_messages = [m for m in result["messages"] if m.type == "tool"] + assert tool_messages, "the eval tool produced no output" + return str(tool_messages[0].content) + + +# -------------------------------------------------------------------------- +# Allowlist policy +# -------------------------------------------------------------------------- + + +def test_suspending_tools_are_withheld() -> None: + """A tool that suspends the run must never be reachable from the REPL. + + It cannot return a value into the JS ``await``, a replayed node re-runs every + bridged call made before the interrupt, and PTC bypasses approval hooks. + """ + assert ptc_tool_names( + [_tool("read_invoice"), _tool("escalate", suspends=True)] + ) == ["read_invoice"] + + +@pytest.mark.parametrize( + "name", + ["Get Invoice", "invoice.total", "2fa_check", "tool!", "faktura_\u010desk\u00e1"], + ids=["space", "dot", "leading-digit", "punctuation", "non-ascii"], +) +def test_names_that_cannot_be_js_identifiers_are_withheld(name: str) -> None: + """Dropped here rather than raising from inside ``wrap_model_call`` mid-run. + + Low-code tool names come from ``agent.json`` and are not constrained to + JavaScript identifiers. + """ + assert ptc_tool_names([_tool(name)]) == [] + + +def test_camel_case_collisions_are_withheld() -> None: + """Two tools that camel-case to one name are both dropped. + + Upstream dedupes by tool name, not camel name, so binding either would + silently call the wrong tool. + """ + assert ptc_tool_names([_tool("get_invoice"), _tool("get-invoice")]) == [] + + +def test_reserved_task_name_is_withheld() -> None: + """``task`` is the top-level ``task()`` global; listing it in ptc raises upstream.""" + assert ptc_tool_names([_tool("task")]) == [] + + +def test_filesystem_tools_exposed_and_dangerous_ones_are_not() -> None: + """Workspace file access is offered; ``delete``, ``execute`` and ``task`` are not. + + Asserted on the composed allowlist rather than the middleware's internals. + That the allowlist actually reaches the sandbox is covered end to end by + ``test_workspace_files_are_reachable_through_the_file_tools``. + """ + exposed = {*ptc_tool_names([_tool("read_invoice")]), *PTC_FILESYSTEM_TOOLS} + assert set(PTC_FILESYSTEM_TOOLS) <= exposed + assert "read_invoice" in exposed + assert exposed.isdisjoint({"delete", "execute", "task", "eval"}) + + +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 + + +def test_factory_without_the_extra_raises_install_guidance( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The extra must be genuinely optional. + + Importing ``uipath_langchain.agent.advanced`` has to keep working for every + consumer that never asked for the code interpreter, so the middleware import + is deferred into the factory. Setting the module to ``None`` in + ``sys.modules`` is how the stdlib signals "absent", which is what a base + install looks like. + """ + monkeypatch.setitem(sys.modules, "langchain_quickjs", None) + monkeypatch.setitem(sys.modules, "langchain_quickjs._ptc", None) + + with pytest.raises(ImportError, match="code-interpreter"): + build_code_interpreter_middleware([_tool("read_invoice")]) + + +def test_private_upstream_helpers_still_resolve() -> None: + """Pins the private ``langchain_quickjs._ptc`` import the policy depends on. + + A local copy of the identifier rule risks drifting looser than upstream, and + anything upstream rejects raises from inside ``wrap_model_call``. If this + fails after a version bump, re-check the helpers before loosening the policy. + """ + from langchain_quickjs._ptc import is_valid_ptc_tool_name, to_camel_case + + assert to_camel_case("read_file") == "readFile" + assert is_valid_ptc_tool_name("read_file") + assert not is_valid_ptc_tool_name("read file") + + +# -------------------------------------------------------------------------- +# Sandbox behaviour, end to end through a real agent +# -------------------------------------------------------------------------- + + +def test_computation_runs_in_the_sandbox(tmp_path: Path) -> None: + """The plain arithmetic case: one eval call, no tools, a value back.""" + assert "320" in _run_js("10 * 32", tmp_path) + + +def test_programmatic_tool_calling_collapses_round_trips(tmp_path: Path) -> None: + """Two bridged tool calls and the arithmetic between them, in one eval call.""" + seen: list[str] = [] + + @tool + def lookup_price(sku: str) -> str: + """Look up the price of a SKU.""" + seen.append(sku) + return {"A": "10", "B": "32"}[sku] + + code = """ + const [a, b] = await Promise.all([ + tools.lookupPrice({ sku: "A" }), + tools.lookupPrice({ sku: "B" }), + ]); + Number(a) * Number(b) + """ + assert "320" in _run_js(code, tmp_path, [lookup_price]) + assert sorted(seen) == ["A", "B"] + + +def test_workspace_files_are_reachable_through_the_file_tools(tmp_path: Path) -> None: + """JS writes and reads a workspace file via the bridged file tools. + + This is what stands in for shell access: mediated by the tool, so the backend + still resolves and bounds the path. + """ + code = """ + await tools.writeFile({ file_path: "/note.txt", content: "hello" }); + await tools.readFile({ file_path: "/note.txt" }) + """ + assert "hello" in _run_js(code, tmp_path) + assert (tmp_path / "note.txt").read_text(encoding="utf-8") == "hello" + + +def test_path_traversal_is_still_rejected_through_the_bridge(tmp_path: Path) -> None: + """``virtual_mode`` bounds the path even from inside the REPL. + + This is the property that makes bridged file tools an acceptable substitute + for shell access: the backend, not the sandbox, resolves every path. The tool + reports the refusal as a returned string, so the ``await`` resolves normally + and the model sees the error. + """ + workspace = tmp_path / "workspace" + workspace.mkdir() + escape = tmp_path / "escaped.txt" + code = """ + const r = await tools.writeFile({ + file_path: "/../escaped.txt", content: "pwned", + }); + JSON.stringify(r) + """ + output = _run_js(code, workspace) + assert "Path traversal not allowed" in output + assert not escape.exists(), f"traversal escaped the workspace: {escape}" + assert list(workspace.rglob("*")) == [], "traversal wrote inside the workspace" + + +def test_sandbox_has_no_ambient_capability(tmp_path: Path) -> None: + """No network, no module loader, no process: the guest starts with nothing.""" + code = "[typeof fetch, typeof require, typeof process].join(',')" + assert "undefined,undefined,undefined" in _run_js(code, tmp_path) diff --git a/tests/agent/advanced/test_conversational_advanced_agent_graph.py b/tests/agent/advanced/test_conversational_advanced_agent_graph.py index cdb546d8e..706a105e0 100644 --- a/tests/agent/advanced/test_conversational_advanced_agent_graph.py +++ b/tests/agent/advanced/test_conversational_advanced_agent_graph.py @@ -1,5 +1,6 @@ """Tests for the conversational advanced agent wrapper builder.""" +from collections.abc import Sequence from typing import Any, cast from unittest.mock import MagicMock, patch @@ -77,6 +78,21 @@ def test_wrapper_graph_has_conversational_nodes() -> None: } <= set(graph.nodes) +def _runtime_prompt_middleware( + middleware: Sequence[Any], +) -> _RuntimeSystemPromptMiddleware | None: + """The runtime-prompt middleware in the stack handed to deepagents, if any. + + Located by type rather than by index: ``create_advanced_agent`` also prepends + ``TodoListMiddleware``, and callers may append middleware of their own. + """ + found = [m for m in middleware if isinstance(m, _RuntimeSystemPromptMiddleware)] + assert len(found) <= 1, ( + f"expected at most one runtime-prompt middleware, got {found}" + ) + return found[0] if found else None + + def test_callable_system_prompt_enables_runtime_middleware() -> None: with patch( "uipath_langchain.agent.advanced.agent._create_deep_agent", @@ -92,9 +108,8 @@ def test_callable_system_prompt_enables_runtime_middleware() -> None: call_kwargs = create_deep_agent.call_args.kwargs assert call_kwargs["system_prompt"] is None - assert len(call_kwargs["middleware"]) == 1 - middleware = call_kwargs["middleware"][0] - assert isinstance(middleware, _RuntimeSystemPromptMiddleware) + middleware = _runtime_prompt_middleware(call_kwargs["middleware"]) + assert middleware is not None assert middleware.state_key == "uipath__system_prompt" @@ -113,7 +128,7 @@ def test_static_system_prompt_skips_runtime_middleware() -> None: call_kwargs = create_deep_agent.call_args.kwargs assert call_kwargs["system_prompt"] == "sys" - assert call_kwargs["middleware"] == [] + assert _runtime_prompt_middleware(call_kwargs["middleware"]) is None @pytest.mark.asyncio @@ -259,7 +274,8 @@ async def test_runtime_prompt_reaches_deep_agent_model_request() -> None: captured_requests: list[ModelRequest[Any]] = [] def create_inner_graph(**kwargs: Any) -> Any: - middleware = kwargs["middleware"][0] + middleware = _runtime_prompt_middleware(kwargs["middleware"]) + assert middleware is not None def respond(state: BaseModel) -> dict[str, Any]: state_data = state.model_dump() diff --git a/tests/agent/advanced/test_create_advanced_agent_graph.py b/tests/agent/advanced/test_create_advanced_agent_graph.py index 154b3fc66..c2e04dc2a 100644 --- a/tests/agent/advanced/test_create_advanced_agent_graph.py +++ b/tests/agent/advanced/test_create_advanced_agent_graph.py @@ -1,5 +1,6 @@ """Tests for the create_advanced_agent_graph wrapper builder.""" +from collections.abc import Sequence from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -39,6 +40,21 @@ class _PromptNamedInput(BaseModel): uipath__system_prompt_1: str +def _runtime_prompt_middleware( + middleware: Sequence[Any], +) -> _RuntimeSystemPromptMiddleware | None: + """The runtime-prompt middleware in the stack handed to deepagents, if any. + + Located by type rather than by index: ``create_advanced_agent`` also prepends + ``TodoListMiddleware``, and callers may append middleware of their own. + """ + found = [m for m in middleware if isinstance(m, _RuntimeSystemPromptMiddleware)] + assert len(found) <= 1, ( + f"expected at most one runtime-prompt middleware, got {found}" + ) + return found[0] if found else None + + def _mock_model() -> MagicMock: model = MagicMock(spec=BaseChatModel) model.profile = None @@ -76,9 +92,9 @@ def test_callable_system_prompt_enables_runtime_middleware() -> None: call_kwargs = mock_create.call_args.kwargs assert call_kwargs["system_prompt"] is None - assert len(call_kwargs["middleware"]) == 1 - assert isinstance(call_kwargs["middleware"][0], _RuntimeSystemPromptMiddleware) - assert call_kwargs["middleware"][0].state_key == "uipath__system_prompt" + runtime_middleware = _runtime_prompt_middleware(call_kwargs["middleware"]) + assert runtime_middleware is not None + assert runtime_middleware.state_key == "uipath__system_prompt" def test_static_system_prompt_skips_runtime_middleware() -> None: @@ -91,7 +107,7 @@ def test_static_system_prompt_skips_runtime_middleware() -> None: call_kwargs = mock_create.call_args.kwargs assert call_kwargs["system_prompt"] == "sys" - assert call_kwargs["middleware"] == [] + assert _runtime_prompt_middleware(call_kwargs["middleware"]) is None @pytest.mark.asyncio @@ -182,7 +198,8 @@ def build_system_prompt(args: dict[str, Any]) -> str: return f"runtime:{args['question']}" def create_inner_graph(**kwargs: Any) -> Any: - middleware = kwargs["middleware"][0] + middleware = _runtime_prompt_middleware(kwargs["middleware"]) + assert middleware is not None runtime_key = middleware.state_key def capture_model_request(state: BaseModel) -> dict[str, Any]: diff --git a/tests/agent/tools/test_suspends_run_metadata.py b/tests/agent/tools/test_suspends_run_metadata.py new file mode 100644 index 000000000..ddf56ad4b --- /dev/null +++ b/tests/agent/tools/test_suspends_run_metadata.py @@ -0,0 +1,70 @@ +"""Every tool factory that suspends the run must advertise it in tool metadata. + +A suspending tool raises ``GraphInterrupt`` instead of returning: the run +checkpoints and the node is replayed from that checkpoint on resume. Callers that +invoke tools outside the graph's tool node -- the QuickJS code interpreter's +programmatic tool calling in particular -- must therefore not offer them, because +a replayed node re-runs every call made before the interrupt, and because such +bridges bypass approval hooks. + +Deciding eligibility from ``SUSPENDS_RUN`` keeps that policy next to the code that +suspends, rather than in a central list that silently goes stale. This test is +what makes the flag trustworthy: it reads the factory sources, so a new +suspending factory that forgets to stamp it fails here instead of quietly +becoming reachable from inside the sandbox. +""" + +import ast +from pathlib import Path + +import pytest + +from uipath_langchain._utils.durable_interrupt import SUSPENDS_RUN + +_TOOLS_DIR = Path(__file__).parents[3] / "src" / "uipath_langchain" / "agent" / "tools" + +# Suspends the run without the decorator: a bare ``interrupt()`` call. +_BARE_INTERRUPT_MODULES = {"extraction_tool.py"} + + +def _suspending_modules() -> list[Path]: + """Factory modules that suspend the run, found by reading the source.""" + found = [ + path + for path in sorted(_TOOLS_DIR.rglob("*.py")) + if "@durable_interrupt" in path.read_text(encoding="utf-8") + or path.name in _BARE_INTERRUPT_MODULES + ] + assert found, f"no suspending tool factories found under {_TOOLS_DIR}" + return found + + +def _assigns_suspends_run(source: str) -> bool: + """Whether the module sets ``SUSPENDS_RUN`` as a dict key to a true constant. + + Parsed rather than grepped so a mention in a comment or docstring does not + count as a stamp. + """ + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Dict): + continue + for key, value in zip(node.keys, node.values, strict=False): + if ( + isinstance(key, ast.Name) + and key.id == "SUSPENDS_RUN" + and isinstance(value, ast.Constant) + and value.value is True + ): + return True + return False + + +@pytest.mark.parametrize("module", _suspending_modules(), ids=lambda p: p.name) +def test_suspending_factory_stamps_the_flag(module: Path) -> None: + """A factory that suspends the run stamps ``SUSPENDS_RUN: True`` in metadata.""" + assert _assigns_suspends_run(module.read_text(encoding="utf-8")), ( + f"{module.name} suspends the run but does not set " + f"{SUSPENDS_RUN!r} in its tool metadata. Add " + f"`SUSPENDS_RUN: True` to the tool's metadata dict, or the tool becomes " + f"callable from the code interpreter's tools namespace." + ) diff --git a/uv.lock b/uv.lock index 67bb724be..61815988d 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-26T20:22:33.9044027Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -223,7 +223,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.111.0" +version = "0.125.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -235,9 +235,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/8a/9afc7305a2ce4b52b30e137f83cd2a6a90b918b3997073db11bb5a1de55a/anthropic-0.111.0.tar.gz", hash = "sha256:39cbda0ac17a6d423e5bf609811bd69b26eddf6299d7a468126e05bc711ce826", size = 934001, upload-time = "2026-06-18T17:31:44.733Z" } +sdist = { url = "https://files.pythonhosted.org/packages/62/f8/6f0560884b5363848347bd640b6c1d04abc25e7aa61787a232f790c6b60a/anthropic-0.125.0.tar.gz", hash = "sha256:e0cdd336580cb7411c1cdab69f80973e9bf4bff7f8e08141811d46307d45c682", size = 1112593, upload-time = "2026-08-19T22:00:42.837Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/bb/09e82a81885d787f350fb55ca9df865b63140dd28b3b5b3104c4ae261657/anthropic-0.111.0-py3-none-any.whl", hash = "sha256:c14edb36ed80da9099acbd26b5cec810d76606c31f32a0d56a4cf9d4fa9e25ae", size = 929774, upload-time = "2026-06-18T17:31:43.116Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1a/b1bd30cda3790557e8791bec5922a6ec8fabb6fa8b008c76a39cf7be6152/anthropic-0.125.0-py3-none-any.whl", hash = "sha256:3486013602eca76d8b12540764e53654f02cf4951110bca86cf06e67428a9f21", size = 1184067, upload-time = "2026-08-19T22:00:44.596Z" }, ] [package.optional-dependencies] @@ -550,11 +550,61 @@ wheels = [ [[package]] name = "bracex" -version = "2.6" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, +] + +[[package]] +name = "bsdiff4" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/b9/4559ede9a4c8c4451688303544da84654643fdc7f28790aca85be80b4b7c/bsdiff4-1.2.6.tar.gz", hash = "sha256:2ab57d01a78b39e29e5accc9cfead4130982ded9dccbc4261bd0e9c51d6b751d", size = 13259, upload-time = "2025-02-19T17:42:33.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/08/6472d5c2527688b16ad2c2dd09e324281f5e78eea5e4dba5f65a7949f39c/bsdiff4-1.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b151c28098b3c522b1735cdfe5e84e8f164f0ef4a592adb227d7a10727034673", size = 16212, upload-time = "2025-02-19T17:39:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/33/41/4d1fa5980c01faa0d5c578e41ce73b4df98cd74e33f92323880df0da035e/bsdiff4-1.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:29def064f6bcd13d0d7a82e5caa4848158b7f49c3a8fe44fbef3031456fb7dd2", size = 16031, upload-time = "2025-02-19T17:39:54.481Z" }, + { url = "https://files.pythonhosted.org/packages/9e/41/188f858a71eb529145b6706f8ac618fd9f719807f46e0cebe2ea482bfe78/bsdiff4-1.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6f4cf8e00116e14e9e6c3fb5747478022a27215a9a65ed223fed82d2cfbc4d3", size = 33763, upload-time = "2025-02-19T17:39:55.438Z" }, + { url = "https://files.pythonhosted.org/packages/27/ea/84cc364a0c0f6eb3e503bf1625aa62eb411aa7474d1c91ec201812295fcb/bsdiff4-1.2.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:897a260d30acc4df9803f500682eb7951fdc104a3e155787e1e581258f38df50", size = 35705, upload-time = "2025-02-19T17:39:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/c235fd3e95aa3a4ac53de83605723a149a33eb11aff64e49488132b857f8/bsdiff4-1.2.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d994ee6113c3f030bb9f373e917f00db13c026c295fe9f314f23171935d88371", size = 33807, upload-time = "2025-02-19T17:39:57.601Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8b/010d14d3ab321c1c35fc4145b020c1e76ed8a29a214ce6bcc093ddedee13/bsdiff4-1.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba5028a2aaa8e4cacb224031af9140e05d9c407ba15b59471380badcc4845777", size = 33250, upload-time = "2025-02-19T17:39:58.552Z" }, + { url = "https://files.pythonhosted.org/packages/2c/91/ae41950f7b823e8061520f3b28d47534b47f314b4148690c4a002d764bd7/bsdiff4-1.2.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1edd3069dc14cecaa804faaae776a5d14f85217c41b3180b794e5fbf684d35dd", size = 35919, upload-time = "2025-02-19T17:40:00.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b4/f29c451e7718d4366a72f9a87a7f3cc76cb56cb5e9305eae087eab83f7a0/bsdiff4-1.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fb562e451d5b3a7523c67ce04fe541d3a004914e5760a47116883972f5ff8bc", size = 33740, upload-time = "2025-02-19T17:40:01.893Z" }, + { url = "https://files.pythonhosted.org/packages/3a/73/004b3c4511df3df0d5e591ecd7aaf92c851b22be200283428d3577f4400b/bsdiff4-1.2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b7309380d8edbd3d46c4ed3930f7062b793bac8f004b32139db7af7c4612e241", size = 37325, upload-time = "2025-02-19T17:40:02.911Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/5fa1d331c4a2e73e4e90a851768749a9960cefcb443da3abaae69e891f06/bsdiff4-1.2.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f2f7504f08181227717fee04f25169d5901322c29d3fd054e4cb61bd60b3ffb4", size = 35791, upload-time = "2025-02-19T17:40:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/7616e8abec54562c86742c7bacaaba53c0c4733565ea00e8c5ffe2c5c9ce/bsdiff4-1.2.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6ad599216e7ee3db5737951d06c43b8e65d5b0db5c42300e85f18d399ec0bc5e", size = 35413, upload-time = "2025-02-19T17:40:05.692Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d6/3fff18a97e127cc783e02de3c934bca63fabc0d4a379e091973b006cbae6/bsdiff4-1.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cd133a9475c9dfba6243dd07f118ee58a0b7f136c00d316e2d92d3f82169bd9e", size = 32939, upload-time = "2025-02-19T17:40:06.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/32/2943637e17eca717cdd091625d4198cf7a49dd7d235944a86f1a8a6134fe/bsdiff4-1.2.6-cp311-cp311-win32.whl", hash = "sha256:403e8cc003451a8c4672c345a50aee3cf89d20983701e38fbbb67e07cb808c57", size = 18257, upload-time = "2025-02-19T17:40:07.59Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f8/83f087ab62bebde26956f084ab272e19d11db5df6700f4f48d29647235fd/bsdiff4-1.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:164a059e1e07932f91d90471a4ef4dac749f2dee780f08501522805398b32ed8", size = 19530, upload-time = "2025-02-19T17:40:08.504Z" }, + { url = "https://files.pythonhosted.org/packages/9a/58/044dd110fb0a0160f5cacecbfb9904043c8179f8c14093e22b6d8c6b9391/bsdiff4-1.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69c5052e94ad991c397b5a46f8eab42f2e256c42aa5677896b7a3ea9e3d06adc", size = 16267, upload-time = "2025-02-19T17:40:10.376Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/70b74154344486bac9bf438ec309ae502f07df8cd7ca713d58f658769ff4/bsdiff4-1.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:223ae0fc9f386dcf919a09a2029c391a0f0afaf4a5892b9a6e1b622bf42e1ae5", size = 16090, upload-time = "2025-02-19T17:40:11.334Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/36531261d8a150fcb8193fe2ad46d939b8a91549976424852f6a2a335689/bsdiff4-1.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ea2298a281068d82b78454ee58ac7306ed38c9af55afddb04cf796df932d63", size = 33675, upload-time = "2025-02-19T17:40:13.218Z" }, + { url = "https://files.pythonhosted.org/packages/4a/97/8b73b3684c63e88508ad308229f33a8a5be6c4762e4160f96e2a6fc46906/bsdiff4-1.2.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2534e286ef5ae58767b9b17be64742424ca1e52ec748b0d8f8e24eecd12bc28a", size = 35648, upload-time = "2025-02-19T17:40:14.296Z" }, + { url = "https://files.pythonhosted.org/packages/52/39/0b1dd6494c743fa2c62bd7c35f5dec9f5802d01c1da1ef75a2e20a481ed4/bsdiff4-1.2.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ff079b0f4cf874af4b6816983557b6b9d45996f88736046653e2d2311fa1876", size = 33772, upload-time = "2025-02-19T17:40:15.341Z" }, + { url = "https://files.pythonhosted.org/packages/88/23/98fc7482f957602c611203a9e485b9dbf4caf9d918e92453e3729cf5f0b4/bsdiff4-1.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56c2728c96d1d4eb8e089e4797c018a56be3f905f440fb507773f44c567fcd38", size = 33238, upload-time = "2025-02-19T17:40:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/75/04/c3db957b7a324a3f25f721a82c288e9abe60059a0a2d2f9b3c19fb49cdb2/bsdiff4-1.2.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9deb9b3cdb4d327e43b8c7bd11ed3707587f1183b35fb8a4c06c4f34bce62c6a", size = 35889, upload-time = "2025-02-19T17:40:18.237Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a8/73d2abfd98a33cd74a0fc491e527d734c222ae18b499a10689f3adbc8d5c/bsdiff4-1.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87c67b06ac96af6171b774dc8c03d2bde70c67c6488078eff44e0af4864acf6", size = 33606, upload-time = "2025-02-19T17:40:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c3/713b3bb3711b62e51f6f67d6d9f63098e4d3a51d8b91e52c962f5c01a2b7/bsdiff4-1.2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04bb2948301ad48123d308bf2342c83cae81d7edb52d11bdde00266d89ca071e", size = 37211, upload-time = "2025-02-19T17:40:22.365Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c5/40559695ea0bd3332c37ef8182fc0f96ceed838ae6b03ca9ddcd8cf0f7df/bsdiff4-1.2.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:43649a44fc21f017be902e19ccf7fb8bac6ef2d7f93d871bbc6bc49acec9ffee", size = 35750, upload-time = "2025-02-19T17:40:23.554Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9b/eb4683896119ec9d26d1eb3f12efc0d8a902451f4025db12c21c5a82992a/bsdiff4-1.2.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:baa76ec557dc48847c3ed1ff5720b5095c439c868f7568da30dcabbabceb2b92", size = 35364, upload-time = "2025-02-19T17:40:24.485Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ad/0968b67aecf00873e0e5c07e97ba2300594505d4dbce62702b9f56a62d66/bsdiff4-1.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:701168e2931da777e6e72ae17f22eb519e9ce25ec5108d149c9da7b3b80e1184", size = 32831, upload-time = "2025-02-19T17:40:25.571Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/adfcf72780f19cea1fe9948cbfb49890599424e94c752bf7d614093c0fc5/bsdiff4-1.2.6-cp312-cp312-win32.whl", hash = "sha256:f9f2e5e716d35af3252f69a15afc2b166970c98596a1114af4c6d2834fe8e871", size = 18308, upload-time = "2025-02-19T17:40:26.571Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5d/31672172bb4566c1f1187fa28a1437125d4b5106bc55f9f7b9a75371094c/bsdiff4-1.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:0b29568d1e33e32ea075c12a696b32e4d6cea344d0270a2292075254efd86014", size = 19553, upload-time = "2025-02-19T17:40:27.592Z" }, + { url = "https://files.pythonhosted.org/packages/4f/56/887d90b0e52ce7b5533a6f1390ab9a68215a70ba34848441730e215ffc1c/bsdiff4-1.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a98d7975a670fc360d894ef2ec00294e6b7b19790c58457e40c8a5d57a1865b0", size = 16260, upload-time = "2025-02-19T17:40:28.614Z" }, + { url = "https://files.pythonhosted.org/packages/d8/4c/825a16932605d305501ed144ae5567a3dc90c9164a393c61cc0ed68df3f0/bsdiff4-1.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ee4417341712a4bf736694ce9ad3902b8c6fbd3425aadca44df9b66a51bbefa4", size = 16080, upload-time = "2025-02-19T17:40:29.612Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e2/0cf538a786f47b08e26f3970a6f98c2b7b9d555c01e085425282944a2c7f/bsdiff4-1.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39ddfa2137de44c9743a611d71d263d0cc8c45e5b18ee84ca5ff6b6240be1740", size = 33664, upload-time = "2025-02-19T17:40:31.646Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c0/44ac255f1d16865e39ef941470e30bb5c362dd216b62837bb13880d1dd36/bsdiff4-1.2.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6474d8f34f89d25fa1803c639cc8ed49121752a56a15b4cd21e9267154cdaf70", size = 35648, upload-time = "2025-02-19T17:40:32.793Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6b/d5871af38cbb8527652b65463c3dd736b6250828d8d6daf48be712a2ebfe/bsdiff4-1.2.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8e9c876929c03ef5d448e2626e8b2961040c3a9f0dd3d483643dbccd0e7ff7a", size = 33792, upload-time = "2025-02-19T17:40:35.498Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1e/7027849a6dc02b580e352b1528899053bd919029b185fbaa14c6f268180b/bsdiff4-1.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46313f0eb8f63efb54a3c4219cd7b5b8a7795012b535f9d0838fe3f2b3349849", size = 33238, upload-time = "2025-02-19T17:40:37.165Z" }, + { url = "https://files.pythonhosted.org/packages/97/df/c4a3e2bb1c1f9f09c2c5f8a9025c67f5ec7fcc8949338e54cb2d4fba9009/bsdiff4-1.2.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6b5757b1a83829f00ef34953c6865ea82e9c71126e465bc32d029c55da9e45b", size = 35866, upload-time = "2025-02-19T17:40:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/83/03/76a5aaaa0ccc282b239b3f148f6dd6033d37f79c1d1a89846b712224d132/bsdiff4-1.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:734552992ecc86749a8ef55d03f999f9a47576cc609d7d4d9a7aec274b43ee4d", size = 33688, upload-time = "2025-02-19T17:40:39.762Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b3/b240d4840a16d923c60e8e9eacf0777cf9378e30610037f6c85324daea85/bsdiff4-1.2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:853c3221daac6f8d347f12eb0b73ca9dbb7db483e7b5f40b1e2fbb05730645a7", size = 37273, upload-time = "2025-02-19T17:40:41.626Z" }, + { url = "https://files.pythonhosted.org/packages/7d/84/2223a09c4950a3e419ce94eb0af6d90c1ee562b9962ef2d72515f4ad6271/bsdiff4-1.2.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:94526dc11e56f330c2f4b1e2e9389b958a7891f6c86b5aac83bd9c7a90eb088a", size = 35844, upload-time = "2025-02-19T17:40:42.646Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/c02f703b449feb20b245eb803e7d446508b80d5b4065d1eb9cc75d02ae3b/bsdiff4-1.2.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f5474e1d9253564ed0823e2685a403d9dfdbba3c7b70a80f5066d61427848253", size = 35418, upload-time = "2025-02-19T17:40:44.562Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/623ee28011b6935f0dfe67397ec27c2a900b9f0bda1b1ec2a5b174c53fb7/bsdiff4-1.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5529731ac88151345a8bb76dad4fdb218af10a8a505161d1aa3d669e49cb7b77", size = 32892, upload-time = "2025-02-19T17:40:45.552Z" }, + { url = "https://files.pythonhosted.org/packages/44/6c/e740e347bb46ea08ceacf39df56c2ffd2bd20b95d458409ea303fbf2b946/bsdiff4-1.2.6-cp313-cp313-win32.whl", hash = "sha256:c8089827c41b37f7c9192492742289929097c5ab2a6b3a120919fee27fbc01b8", size = 18304, upload-time = "2025-02-19T17:40:47.37Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/9be6f6124afab9837db1ffc5801ca1aa86f2077d4224ff729e88fabada71/bsdiff4-1.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:37ff935ba714e0726584dad2bc4c063218b588b110115e8554ebc438ee7bccf3", size = 19543, upload-time = "2025-02-19T17:40:48.369Z" }, ] [[package]] @@ -955,7 +1005,7 @@ wheels = [ [[package]] name = "deepagents" -version = "0.5.9" +version = "0.7.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain" }, @@ -963,11 +1013,12 @@ dependencies = [ { name = "langchain-core" }, { name = "langchain-google-genai" }, { name = "langsmith" }, + { name = "packaging" }, { name = "wcmatch" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/74/776e606f0508a4d7d4c9d061c797dcde43eed2452fea546ae29047aeaaa6/deepagents-0.5.9.tar.gz", hash = "sha256:74fe0f998641b20bda8adac662a018051c623a3c8e5ed4b6ff9ad53fc493a783", size = 165651, upload-time = "2026-05-10T22:31:17.095Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/e8/dfa6b3a4d43ad87ab28894a6edba167d909fbdd8d59fb356c8db0ebb53c9/deepagents-0.7.11.tar.gz", hash = "sha256:240e31cb60e4a8e3c4f9aa1ab706618bae4ea69ae6495d9fc0e3e773d551927e", size = 293354, upload-time = "2026-08-28T23:10:48.665Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/f6/9698f98da72dfa8dc8e1d0f0542f2407a40a50cfdccf522fdb5cb43e39e2/deepagents-0.5.9-py3-none-any.whl", hash = "sha256:ce24a41763b2793bd21217411e9fb9f187a9128da6789f5a81654da1de9e4c7c", size = 188118, upload-time = "2026-05-10T22:31:15.974Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/7d3eb8671cafefe8798d945c48bb664abbd92015446fc453093e44895788/deepagents-0.7.11-py3-none-any.whl", hash = "sha256:7f8ca58b7aa8b11fb8a18d054aa5da937700cba221b1104b5eda4469e9531382", size = 320468, upload-time = "2026-08-28T23:10:47.569Z" }, ] [[package]] @@ -1866,30 +1917,30 @@ wheels = [ [[package]] name = "langchain" -version = "1.3.10" +version = "1.3.18" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "langgraph" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/f6/e351d85c7828b9b90c5729de66170457c882c754efef0712904cfcd3192d/langchain-1.3.10.tar.gz", hash = "sha256:fd6ac9da86c479e4ff376e772d9e17a9232bd3113e9f2ddcb70cdc4bf7afc119", size = 632522, upload-time = "2026-06-18T19:43:00.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/31/e9/0e5425e522b624d7c5c0911957b467b82890b36b0099bf727951f2ee3059/langchain-1.3.18.tar.gz", hash = "sha256:74ce99294f6f2c82ee64c3df39daa6a22085ee1449a718065b3604a88d78bf4d", size = 637052, upload-time = "2026-08-27T17:33:12.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/f6/a682e68d004a2e23cae6c5c42e3c0d071bc0e7768167bd12277992f096f9/langchain-1.3.10-py3-none-any.whl", hash = "sha256:5da67f21aa56119744ad51b3e46ffac570c88f4fae0876e3b1c6a1c4bc0e344e", size = 133038, upload-time = "2026-06-18T19:42:58.918Z" }, + { url = "https://files.pythonhosted.org/packages/f7/04/374f6014ed6959dbdab92962c2b09e4d0223ed6a82f65694870b46d2c13f/langchain-1.3.18-py3-none-any.whl", hash = "sha256:f29cbef985848e5cfff5398f3c8c1568994a3a6f2a8f4fa720df30b6e0668b9c", size = 148007, upload-time = "2026-08-27T17:33:11.23Z" }, ] [[package]] name = "langchain-anthropic" -version = "1.4.6" +version = "1.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anthropic" }, { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/f5/cd397b94aeed5fa0e8ab9595b9fb578ac99f424d42220defe6626e6a1a7b/langchain_anthropic-1.4.6.tar.gz", hash = "sha256:78942d4458d883b7d362438a095ed501ed84f44d402622404482481fc973b9da", size = 706540, upload-time = "2026-06-12T16:54:15.352Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/fc/52f6d1d6069bafb08626e204c89c49c8dd4a536eedbb94f0b7e78668594d/langchain_anthropic-1.7.0.tar.gz", hash = "sha256:d48e3c118ff8d3eea83f17b50234a2d2ff491a2375d565f212eb990e7e3856cb", size = 750068, upload-time = "2026-08-27T15:23:59.261Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/af/927dbbc5a1f5fea1a69adc2883f034cbd1430004e36f4eacd302d500393a/langchain_anthropic-1.4.6-py3-none-any.whl", hash = "sha256:dbd412a956b6b8b0716d9d8460ef71f834a6731cdbfc59e6160482a4a9fb5200", size = 51797, upload-time = "2026-06-12T16:54:14.159Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ce/e4367713080bc750e1dba845409c058f196543d1a0dc99683e2ef062f581/langchain_anthropic-1.7.0-py3-none-any.whl", hash = "sha256:68b34369aa01dad0c67bc690b8c47e09d06bd30fb28f320ad19ddc71c4445dc0", size = 60475, upload-time = "2026-08-27T15:23:57.989Z" }, ] [[package]] @@ -1938,9 +1989,10 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.4.8" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "httpx" }, { name = "jsonpatch" }, { name = "langchain-protocol" }, { name = "langsmith" }, @@ -1951,9 +2003,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/90/12/aff76ca89c219ebe6f9dd3c5dbc4e3b1cf5450e9fc7037dccad23d45cd7a/langchain_core-1.6.1.tar.gz", hash = "sha256:1b156cb395aac4f009a8a1b38a574c7d948fe2d5f74c96e0d8a5017b4149e04f", size = 1003359, upload-time = "2026-08-27T19:31:14.956Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/f50dd65673c819aa33d3c34df58c115dbb6ec627d19f93e6e401dd0fc8d7/langchain_core-1.6.1-py3-none-any.whl", hash = "sha256:954a84132a5cb0435d27b910e336347b6744ecc18fbeef1e2de7029a0959841a", size = 571478, upload-time = "2026-08-27T19:31:13.34Z" }, ] [[package]] @@ -1974,7 +2026,7 @@ wheels = [ [[package]] name = "langchain-google-genai" -version = "4.2.5" +version = "4.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filetype" }, @@ -1982,9 +2034,9 @@ dependencies = [ { name = "langchain-core" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/4b/a1acdba3a86f861d379cb654f234d334c04a4c93178c8c7b0182ddeb9966/langchain_google_genai-4.2.5.tar.gz", hash = "sha256:2abab4be22699a9cc29948b2bf012946f51a0bbf10ab3a4a9a129047234829f8", size = 271850, upload-time = "2026-06-10T01:48:57.06Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/17/dcf759ad83ab3566db1b02c8b21211f43085c66f3b5964f6fe573ffe8664/langchain_google_genai-4.3.7.tar.gz", hash = "sha256:8a57f936b1fbde52776fdde6673fdabd99cfa5cbbb122a926f1cff0ba00f6bc6", size = 373696, upload-time = "2026-08-27T20:44:05.203Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/82/3d4d3dc181ea1756f323dad4d5936239c2f404ea0acb5102316224280634/langchain_google_genai-4.2.5-py3-none-any.whl", hash = "sha256:289699ddb8e1076a76144f83e25e0086e4ce629b196fc103251f2a629e0756e5", size = 69404, upload-time = "2026-06-10T01:48:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/47/2f/ade5d1a7ecfbc88f7de65c6610ef89d80905b29fa3dd28a764f7028f3d42/langchain_google_genai-4.3.7-py3-none-any.whl", hash = "sha256:8d4b1aa8f2c2e17b8e790d34a7e9a7b8e7d13e9a00175679d17647c235104bf9", size = 79611, upload-time = "2026-08-27T20:44:03.884Z" }, ] [[package]] @@ -2064,9 +2116,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, ] +[[package]] +name = "langchain-quickjs" +version = "0.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bsdiff4" }, + { name = "deepagents" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "quickjs-rs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/2a/45e376a9958c61537973458ad62b39c61a3f9e22cdf3598ea0b7b775a155/langchain_quickjs-0.3.5.tar.gz", hash = "sha256:4b4da850c71bb755af21a5cef46d599b0c0ad2dc78170a8399996ac32fc96435", size = 225157, upload-time = "2026-07-29T18:26:32.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/a1/bf460ae2009f76ec2fabd263ad202f6649c6ae6cb7429f0688c4239ff759/langchain_quickjs-0.3.5-py3-none-any.whl", hash = "sha256:288b276ea7dcc3cfac2b84b7fed1079ac076e1c682bc3e4e2c2ce31d20ea2d2c", size = 45509, upload-time = "2026-07-29T18:26:31.268Z" }, +] + [[package]] name = "langgraph" -version = "1.2.6" +version = "1.2.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, @@ -2076,9 +2145,9 @@ dependencies = [ { name = "pydantic" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400, upload-time = "2026-06-18T20:58:21.514Z" } +sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132, upload-time = "2026-06-18T20:58:20.335Z" }, + { url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" }, ] [[package]] @@ -2139,23 +2208,27 @@ wheels = [ [[package]] name = "langsmith" -version = "0.8.18" +version = "0.11.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "anyio" }, + { name = "distro" }, { name = "httpx" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, { name = "packaging" }, { name = "pydantic" }, { name = "requests" }, { name = "requests-toolbelt" }, + { name = "sniffio" }, + { name = "typing-extensions" }, { name = "uuid-utils" }, { name = "websockets" }, { name = "xxhash" }, { name = "zstandard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/d9/a6681aa9847bbbc5ec21abe20a5e233b94e5edcfe39624db607ac7e8ccb4/langsmith-0.8.18.tar.gz", hash = "sha256:32dde9c0e67e053e0fb738921fc8ced768af7b8fa83d7a0e3fd63597cf8776dd", size = 4526988, upload-time = "2026-06-19T13:12:17.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/0a/1acb2a3ffbccbe8f8dc358778967c9d2979e8a59b67ceba6eb54474324ab/langsmith-0.11.2.tar.gz", hash = "sha256:927694c939c9fb44187e0126cf718413c45ffce2324d480438e70eb0526e1380", size = 4841592, upload-time = "2026-08-27T22:31:34.004Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/70/0e0cc80a3b064c8d6c8d697c3125ed86e39d5a7393ec6dc8b07cb1cf13c4/langsmith-0.8.18-py3-none-any.whl", hash = "sha256:3940183349993faef48e6c7d08e4822ee9cefd906b362d0e3c2d650314d2f282", size = 508108, upload-time = "2026-06-19T13:12:15.348Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/cec0c1d24dda8c67a23ad204d0167e50aee202460e9388488661d94513f5/langsmith-0.11.2-py3-none-any.whl", hash = "sha256:75258142d27dffcc5df331479704b23fc3fd812cfca0469119bb9055a842882f", size = 753928, upload-time = "2026-08-27T22:31:31.644Z" }, ] [[package]] @@ -3817,6 +3890,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "quickjs-rs" +version = "0.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wasmtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/dc/177301106aede96a709d5577127aaa090364d4abb8b4c4cfb87b12ae2c30/quickjs_rs-0.2.5.tar.gz", hash = "sha256:3ceb30fba27013108fac92f0a716d6a16980131ce5b13f9912848df1f1f2cf09", size = 830147, upload-time = "2026-07-24T20:29:26.377Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/1d/e4406d13ce9b9443dbfa59e2a2d5b3e11278ebe322b54de38ae18faf5436/quickjs_rs-0.2.5-py3-none-any.whl", hash = "sha256:e82240af1f1dd1b2e12bcf169a22a8e0e451e356f0688f2fc3bba886d9b2bb20", size = 801138, upload-time = "2026-07-24T20:29:24.194Z" }, +] + [[package]] name = "rdflib" version = "7.6.0" @@ -4546,7 +4631,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.16.13" +version = "0.16.14" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4575,6 +4660,7 @@ dependencies = [ [package.optional-dependencies] all = [ + { name = "langchain-quickjs" }, { name = "uipath-langchain-client", extra = ["all"] }, ] anthropic = [ @@ -4584,6 +4670,9 @@ bedrock = [ { name = "boto3-stubs" }, { name = "uipath-langchain-client", extra = ["bedrock"] }, ] +code-interpreter = [ + { name = "langchain-quickjs" }, +] fireworks = [ { name = "uipath-langchain-client", extra = ["fireworks"] }, ] @@ -4612,14 +4701,16 @@ dev = [ requires-dist = [ { name = "a2a-sdk", specifier = ">=1.1.2,<2.0.0" }, { name = "boto3-stubs", marker = "extra == 'bedrock'", specifier = ">=1.41.4" }, - { name = "deepagents", specifier = ">=0.5.9,<0.6.0" }, + { name = "deepagents", specifier = ">=0.7.11,<0.8.0" }, { name = "httpx", specifier = ">=0.27.0" }, { name = "jsonpath-ng", specifier = ">=1.7.0" }, { name = "jsonschema-pydantic-converter", specifier = ">=0.4.0" }, - { name = "langchain", specifier = ">=1.2.15,<2.0.0" }, - { name = "langchain-core", specifier = ">=1.2.27,<2.0.0" }, + { name = "langchain", specifier = ">=1.3.18,<2.0.0" }, + { name = "langchain-core", specifier = ">=1.6.1,<2.0.0" }, { name = "langchain-mcp-adapters", specifier = "==0.2.1" }, - { name = "langgraph", specifier = ">=1.1.8,<2.0.0" }, + { name = "langchain-quickjs", marker = "extra == 'all'", specifier = ">=0.3.5,<0.4.0" }, + { name = "langchain-quickjs", marker = "extra == 'code-interpreter'", specifier = ">=0.3.5,<0.4.0" }, + { name = "langgraph", specifier = ">=1.2.11,<2.0.0" }, { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0.3,<4.0.0" }, { name = "mcp", specifier = "==1.26.0" }, { name = "openinference-instrumentation-langchain", specifier = ">=0.1.69,<0.2.0" }, @@ -4640,7 +4731,7 @@ requires-dist = [ { name = "uipath-platform", specifier = ">=0.2.22,<0.3.0" }, { name = "uipath-runtime", specifier = ">=0.13.0,<0.14.0" }, ] -provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "all"] +provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "code-interpreter", "all"] [package.metadata.requires-dev] dev = [ @@ -4897,16 +4988,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, ] +[[package]] +name = "wasmtime" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/1f/03a286dc84d83cc3274d5599543558442ba9332b676e6408ee9e1c171199/wasmtime-48.0.0.tar.gz", hash = "sha256:dba27d59209fac703e7d5753af78c2af2c1cd1ed735f520ec76dc31c60a05815", size = 128804, upload-time = "2026-08-20T19:31:57.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e6/39da2f4047a281bce7d4651e21785942d630033931eb397cf734e7ccc048/wasmtime-48.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:a55abf132fe238b843a963c68cd1a30d8f686c1bc75d8fbf042d8b7a1d51ee36", size = 8800816, upload-time = "2026-08-20T19:31:28.761Z" }, + { url = "https://files.pythonhosted.org/packages/be/d0/f4f107166a65ddf8a6d8cf74f0cea33c06a08c74fe686f8e83b194e57e8b/wasmtime-48.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:d8e94276ff6c0c5ce73ee16ccbacb00b3512a4b3a664749380705d81ee06a23c", size = 9731711, upload-time = "2026-08-20T19:31:31.905Z" }, + { url = "https://files.pythonhosted.org/packages/95/15/20fad0cb2b9cff130c225bf827e16365e02883f504297eccf172c6bb7228/wasmtime-48.0.0-py3-none-any.whl", hash = "sha256:49c9ee43e9cf59ad7453ac65dce0cc4b885837904dd3cfd45faafe930defe14a", size = 8157926, upload-time = "2026-08-20T19:31:34.74Z" }, + { url = "https://files.pythonhosted.org/packages/89/93/911434c6c4406e6979b6cb67ba889c85633ff8d92eb0cb569fec6e2a43f7/wasmtime-48.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:50e1ea81a3bec537d00e076722dfdc48978a56ea24619d8153aa1f75b11796b9", size = 9395773, upload-time = "2026-08-20T19:31:37.312Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a6/91c9c19ed7f8e164f4db6405d872c9397be9f53e4f325d0adcd5e67598f4/wasmtime-48.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ea69889a3c51702e9da5f5f441027ca934f7758f8926a4ed167b0d6877f092e8", size = 8343024, upload-time = "2026-08-20T19:31:39.922Z" }, + { url = "https://files.pythonhosted.org/packages/a2/92/e144fcf578fc394678c24b042efe45f3b0614acdb87ea95d8b839b208842/wasmtime-48.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:58544d539053dff7bd4cf30c40d7a540862d683013c0dfa6ba46a063f5b682f7", size = 9796354, upload-time = "2026-08-20T19:31:42.325Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c3/a957b226979daaeb09ec024562e9aac05e475a954537e6f150eb60bca84d/wasmtime-48.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:26fce3613fefbe29a28e9d659dca3326e800593858e5758cad086eb802b3b766", size = 8734885, upload-time = "2026-08-20T19:31:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bf/00e44d1971307620d6660760ed04796405a5fb1819c8b43ec03ad85efac6/wasmtime-48.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:77f6b75db20be065e205e7af814d4e4f06784c3a00eb346e8c76148ecb4afe5a", size = 8786289, upload-time = "2026-08-20T19:31:47.99Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/ce68af7734a5a9424dd66a301b11c810215ec7f70230b35bed10ed312e97/wasmtime-48.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62b241c8d5dfb59ff8af1ccaa5351f0ab7aba8cc872f7d80e0e3c95d54c13562", size = 9889697, upload-time = "2026-08-20T19:31:50.854Z" }, + { url = "https://files.pythonhosted.org/packages/9d/12/5266bebece874ebfa3196c973b917091dd4c55e9e9da55401e312c403044/wasmtime-48.0.0-py3-none-win_amd64.whl", hash = "sha256:21fa500e70f3819a8c0539c3f0be6b3b81ec3c630bb90c47dba4d8a2c1d4c698", size = 8157931, upload-time = "2026-08-20T19:31:53.312Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a4/bb6c90d99ad893bd42f33aa7fb386deecb55987f012c0c2f5fcaba83106d/wasmtime-48.0.0-py3-none-win_arm64.whl", hash = "sha256:09cd5e14df80a3a8d447428a548583181c568ea2e617419d23600deff21d4b82", size = 7044651, upload-time = "2026-08-20T19:31:55.63Z" }, +] + [[package]] name = "wcmatch" -version = "10.1" +version = "11.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bracex" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/43/30e407989e313677dbb9d5f045f966549a7254834571e342eaa4b55cc67b/wcmatch-11.0.1.tar.gz", hash = "sha256:1ea2b4fa678b8ca268253798d5963935df39132d47c3e241c0a0732224005e7d", size = 144662, upload-time = "2026-08-14T15:20:40.477Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, + { url = "https://files.pythonhosted.org/packages/ce/77/7a02b0f05b3ffcdbef9719ce3ee0b508d6a29b58e95299f1580055671db3/wcmatch-11.0.1-py3-none-any.whl", hash = "sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b", size = 43449, upload-time = "2026-08-14T15:20:39.379Z" }, ] [[package]]