diff --git a/samples/deepagent-serverless/README.md b/samples/deepagent-serverless/README.md new file mode 100644 index 000000000..af0937d97 --- /dev/null +++ b/samples/deepagent-serverless/README.md @@ -0,0 +1,85 @@ +# DeepAgent Serverless Sandbox + +A DeepAgent that delegates code execution to a serverless sandbox pod via `interrupt(InvokeProcess(...))`. + +## What It Demonstrates + +- **Serverless code execution**: The agent writes Python scripts and executes them in UiPath Orchestrator, without needing a local runtime. +- **Operation batching**: Writes are buffered and flushed together with execute commands in a single `InvokeProcess` call.. +- **Subagent delegation**: A `research_specialist` subagent uses Tavily web search to gather information before the main agent writes code. +- **`SandboxBackendProtocol`**: Extends the deepagents `BackendProtocol` with an `execute` tool, giving the LLM the ability to run shell commands. + +## Architecture + +``` +User prompt + │ + ▼ +┌─────────────────────────────────────────┐ +│ agent (DeepAgent + LLM) │ +│ │ +│ Tools: write_file, execute, read, ... │ +│ Subagents: research_specialist │ +│ │ +│ ServerlessBackend buffers writes, │ +│ then flushes them + execute as a │ +│ single interrupt(InvokeProcess(...)) │ +└───────────────┬─────────────────────────┘ + │ InvokeProcess + ▼ +┌─────────────────────────────────────────┐ +│ sandbox (non-LLM StateGraph) │ +│ Runs in UiPath Orchestrator │ +│ │ +│ Receives batched operations: │ +│ [write, write, ..., execute] │ +│ Executes them sequentially via │ +│ FilesystemBackend + subprocess │ +└─────────────────────────────────────────┘ +``` + +## Entry Points + +Defined in `langgraph.json`: + +| Entry Point | Graph | Description | +|-------------|-------|-------------| +| `agent` | `graph.py:deep_agent` | LLM-powered DeepAgent with sandbox backend | +| `sandbox` | `sandbox.py:graph` | Non-LLM graph that executes batched file/shell operations | + +## Requirements + +- Python 3.11+ +- Tavily API key (for web search) + +## Setup + +```bash +uv venv -p 3.11 .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +uv sync +``` + +Run `uv run uipath auth` to authenticate to UiPath. + + +Add your Tavily API key in `.env`. +```bash +TAVILY_API_KEY=your_tavily_api_key +``` + +## Usage + +```bash +uv run uipath run agent --file input.json +``` + +## Example Prompt + +``` +Research the best practices for benchmarking sorting algorithms in Python. +Then write a script that compares bubble sort, merge sort, and Python's +built-in timsort on random lists of 10000 elements, and execute it. +``` + +This exercises the full flow: research (Tavily) → write code (buffered) → execute → return results. diff --git a/samples/deepagent-serverless/agent.mermaid b/samples/deepagent-serverless/agent.mermaid new file mode 100644 index 000000000..36b5d155a --- /dev/null +++ b/samples/deepagent-serverless/agent.mermaid @@ -0,0 +1,14 @@ +flowchart TB + __start__(__start__) + model(model) + tools(tools) + TodoListMiddleware.after_model(TodoListMiddleware.after_model) + PatchToolCallsMiddleware.before_agent(PatchToolCallsMiddleware.before_agent) + __end__(__end__) + PatchToolCallsMiddleware.before_agent --> model + TodoListMiddleware.after_model --> __end__ + TodoListMiddleware.after_model --> model + TodoListMiddleware.after_model --> tools + __start__ --> PatchToolCallsMiddleware.before_agent + model --> TodoListMiddleware.after_model + tools --> model diff --git a/samples/deepagent-serverless/bindings.json b/samples/deepagent-serverless/bindings.json new file mode 100644 index 000000000..5dd5a0fd8 --- /dev/null +++ b/samples/deepagent-serverless/bindings.json @@ -0,0 +1,4 @@ +{ + "version": "2.0", + "resources": [] +} \ No newline at end of file diff --git a/samples/deepagent-serverless/input.json b/samples/deepagent-serverless/input.json new file mode 100644 index 000000000..bb4ad16f0 --- /dev/null +++ b/samples/deepagent-serverless/input.json @@ -0,0 +1,8 @@ +{ + "messages": [ + { + "type": "human", + "content": "Research the best practices for benchmarking sorting algorithms in Python. Then write a script that compares bubble sort, merge sort, and Python's built-in timsort on random lists of 10000 elements, and execute it." + } + ] +} diff --git a/samples/deepagent-serverless/langgraph.json b/samples/deepagent-serverless/langgraph.json new file mode 100644 index 000000000..35fa1a838 --- /dev/null +++ b/samples/deepagent-serverless/langgraph.json @@ -0,0 +1,6 @@ +{ + "graphs": { + "agent": "./src/deepagent_serverless/graph.py:deep_agent", + "sandbox": "./src/deepagent_serverless/sandbox.py:graph" + } +} diff --git a/samples/deepagent-serverless/pyproject.toml b/samples/deepagent-serverless/pyproject.toml new file mode 100644 index 000000000..111053caf --- /dev/null +++ b/samples/deepagent-serverless/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "sandbox-deepagent" +version = "0.0.6" +description = "DeepAgent with serverless sandbox backend via interrupt(InvokeProcess)" +authors = [{ name = "John Doe", email = "john.doe@myemail.com" }] +requires-python = ">=3.11" +dependencies = [ + "deepagents>=0.3.9", + "langchain-tavily>=0.2.17", + "langgraph>=1.0.7", + "tavily-python>=0.7.21", + "uipath", + "uipath-langchain", +] + +[dependency-groups] +dev = [ + "uipath-dev", +] diff --git a/samples/deepagent-serverless/sandbox.mermaid b/samples/deepagent-serverless/sandbox.mermaid new file mode 100644 index 000000000..b4f267a8c --- /dev/null +++ b/samples/deepagent-serverless/sandbox.mermaid @@ -0,0 +1,6 @@ +flowchart TB + __start__(__start__) + execute(execute) + __end__(__end__) + __start__ --> execute + execute --> __end__ diff --git a/samples/deepagent-serverless/src/deepagent_serverless/__init__.py b/samples/deepagent-serverless/src/deepagent_serverless/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/samples/deepagent-serverless/src/deepagent_serverless/graph.py b/samples/deepagent-serverless/src/deepagent_serverless/graph.py new file mode 100644 index 000000000..f76785b14 --- /dev/null +++ b/samples/deepagent-serverless/src/deepagent_serverless/graph.py @@ -0,0 +1,37 @@ +"""DeepAgent that delegates filesystem operations and code execution to a serverless sandbox.""" + +from langchain_tavily import TavilySearch +from uipath_langchain.chat import UiPathChatOpenAI + +from deepagents import create_deep_agent +from deepagent_serverless.serverless_backend import ServerlessBackend, ServerlessConfig + +llm = UiPathChatOpenAI() +tavily_tool = TavilySearch(max_results=5) + +MAIN_AGENT_PROMPT = """You are a coding assistant that can write and execute code on a remote serverless sandbox. + +When given a task: +1. Delegate research to the research_specialist subagent if you need information +2. Write code files using the write_file tool (always under /tmp/workspace/) +3. Execute them using the execute tool (e.g. "python /tmp/workspace/script.py") +4. Use the execution output to verify results""" + +RESEARCH_SUBAGENT_PROMPT = """You are a research specialist. Use web search to find relevant information. +Be thorough but concise. Cite your sources.""" + +research_subagent = { + "name": "research_specialist", + "description": "Specialized agent for gathering information using internet search", + "system_prompt": RESEARCH_SUBAGENT_PROMPT, + "tools": [tavily_tool], + "model": llm, +} + +deep_agent = create_deep_agent( + model=llm, + backend=ServerlessBackend(ServerlessConfig(sandbox_process_name="sandbox-deepagent")), + system_prompt=MAIN_AGENT_PROMPT, + tools=[tavily_tool], + subagents=[research_subagent], +) diff --git a/samples/deepagent-serverless/src/deepagent_serverless/sandbox.py b/samples/deepagent-serverless/src/deepagent_serverless/sandbox.py new file mode 100644 index 000000000..b8689505e --- /dev/null +++ b/samples/deepagent-serverless/src/deepagent_serverless/sandbox.py @@ -0,0 +1,150 @@ +"""Serverless sandbox - a non-LLM graph that executes batched operations. + +Receives a list of operations (file ops + shell execution), dispatches them +sequentially to FilesystemBackend / subprocess on the same pod, and returns +all results. This ensures writes and executes happen on the same filesystem. +""" + +import dataclasses +import subprocess +from enum import Enum +from typing import Any + +from deepagents.backends.filesystem import FilesystemBackend +from langgraph.constants import END, START +from langgraph.graph import StateGraph +from pydantic import BaseModel, Field + +_DEFAULT_TIMEOUT = 120 +_MAX_OUTPUT_BYTES = 100_000 +_ROOT_DIR = "/tmp/workspace" + + +def _serialize(obj: Any) -> Any: + """Convert protocol objects (dataclasses, lists of dataclasses) to JSON-safe dicts.""" + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + return dataclasses.asdict(obj) + if isinstance(obj, list): + return [_serialize(item) for item in obj] + return obj + + +class Operation(str, Enum): + LS = "ls" + READ = "read" + WRITE = "write" + EDIT = "edit" + GREP = "grep" + GLOB = "glob" + EXECUTE = "execute" + + +class OperationRequest(BaseModel): + operation: Operation + args: dict[str, Any] = Field(default_factory=dict) + + +class OperationResult(BaseModel): + result: Any = None + error: str | None = None + + +class SandboxInput(BaseModel): + operations: list[OperationRequest] + + +class SandboxOutput(BaseModel): + results: list[OperationResult] + + +_backend = FilesystemBackend(root_dir=_ROOT_DIR) + + +def _execute_shell(args: dict[str, Any]) -> dict[str, Any]: + """Run a shell command and return output, exit_code, truncated.""" + command = args["command"] + timeout = args.get("timeout") or _DEFAULT_TIMEOUT + try: + proc = subprocess.run( + command, + shell=True, + capture_output=True, + timeout=timeout, + cwd=_ROOT_DIR, + ) + stdout = proc.stdout.decode("utf-8", errors="replace") + stderr = proc.stderr.decode("utf-8", errors="replace") + parts = [] + if stdout: + parts.append(stdout) + if stderr: + parts.extend(f"[stderr] {line}" for line in stderr.splitlines()) + output = "\n".join(parts) + truncated = len(output.encode()) > _MAX_OUTPUT_BYTES + if truncated: + output = output.encode()[:_MAX_OUTPUT_BYTES].decode("utf-8", errors="replace") + return {"output": output, "exit_code": proc.returncode, "truncated": truncated} + except subprocess.TimeoutExpired: + return {"output": f"Command timed out after {timeout}s", "exit_code": 124, "truncated": False} + except Exception as e: + return {"output": str(e), "exit_code": 1, "truncated": False} + + +def _dispatch_one(op: OperationRequest) -> OperationResult: + """Dispatch a single operation.""" + try: + match op.operation: + case Operation.LS: + result = _backend.ls_info(op.args["path"]) + case Operation.READ: + result = _backend.read( + op.args["file_path"], + offset=op.args.get("offset", 0), + limit=op.args.get("limit", 2000), + ) + case Operation.WRITE: + result = _backend.write( + op.args["file_path"], + op.args["content"], + ) + case Operation.EDIT: + result = _backend.edit( + op.args["file_path"], + op.args["old_string"], + op.args["new_string"], + replace_all=op.args.get("replace_all", False), + ) + case Operation.GREP: + result = _backend.grep_raw( + op.args["pattern"], + path=op.args.get("path"), + glob=op.args.get("glob"), + ) + case Operation.GLOB: + result = _backend.glob_info( + op.args["pattern"], + path=op.args.get("path", "/"), + ) + case Operation.EXECUTE: + result = _execute_shell(op.args) + return OperationResult(result=_serialize(result)) + except Exception as e: + return OperationResult(error=str(e)) + + +async def execute(input: SandboxInput) -> SandboxOutput: + """Execute all operations sequentially on the same pod.""" + results = [_dispatch_one(op) for op in input.operations] + return SandboxOutput(results=results) + + +builder = StateGraph( + state_schema=SandboxInput, + input=SandboxInput, + output=SandboxOutput, +) +builder.add_node("execute", execute) +builder.add_edge(START, "execute") +builder.add_edge("execute", END) + +graph = builder.compile() diff --git a/samples/deepagent-serverless/src/deepagent_serverless/serverless_backend.py b/samples/deepagent-serverless/src/deepagent_serverless/serverless_backend.py new file mode 100644 index 000000000..c0abd97dd --- /dev/null +++ b/samples/deepagent-serverless/src/deepagent_serverless/serverless_backend.py @@ -0,0 +1,125 @@ +"""BackendProtocol implementation that delegates to the serverless sandbox. + +Operations are sent as interrupt(InvokeProcess(...)) to the sandbox entry point. +Write/edit operations are accumulated and flushed as a batch together with +read or execute operations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from deepagents.backends.protocol import ( + EditResult, + ExecuteResponse, + FileInfo, + GrepMatch, + SandboxBackendProtocol, + WriteResult, +) +from langgraph.types import interrupt +from uipath.platform.common import InvokeProcess + + +@dataclass +class ServerlessConfig: + sandbox_process_name: str = "sandbox-deepagent" + + +class ServerlessBackend(SandboxBackendProtocol): + """Routes BackendProtocol calls to the sandbox via InvokeProcess interrupt. + + When a read-type operation (read, ls, grep, glob) or execute is called, + all pending writes are flushed together with the new operation in a single InvokeProcess call. + """ + + def __init__(self, config: ServerlessConfig | None = None) -> None: + self._config = config or ServerlessConfig() + self._pending: list[dict[str, Any]] = [] + + def _call_sandbox_batch(self, operations: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Send a batch of operations to the sandbox in a single InvokeProcess.""" + response = interrupt( + InvokeProcess( + name=self._config.sandbox_process_name, + input_arguments={"operations": operations}, + ) + ) + results = response.get("results", []) + for r in results: + if r.get("error"): + raise RuntimeError(r["error"]) + return results + + def _flush_with(self, operation: str, args: dict[str, Any]) -> Any: + """Flush pending writes + this operation as a single batch. Return last result.""" + ops = self._pending + [{"operation": operation, "args": args}] + self._pending = [] + results = self._call_sandbox_batch(ops) + return results[-1]["result"] + + def _enqueue_write(self, operation: str, args: dict[str, Any]) -> None: + """Buffer a write/edit operation for later flushing.""" + self._pending.append({"operation": operation, "args": args}) + + # --- Write operations (buffered) --- + + def write(self, file_path: str, content: str) -> WriteResult: + self._enqueue_write("write", {"file_path": file_path, "content": content}) + return WriteResult(path=file_path, files_update=None) + + def edit( + self, + file_path: str, + old_string: str, + new_string: str, + replace_all: bool = False, + ) -> EditResult: + self._enqueue_write("edit", { + "file_path": file_path, + "old_string": old_string, + "new_string": new_string, + "replace_all": replace_all, + }) + return EditResult(path=file_path, files_update=None) + + # --- Read operations (flush pending first) --- + + def ls_info(self, path: str) -> list[FileInfo]: + return self._flush_with("ls", {"path": path}) + + def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str: + return self._flush_with("read", { + "file_path": file_path, + "offset": offset, + "limit": limit, + }) + + def grep_raw( + self, pattern: str, path: str | None = None, glob: str | None = None + ) -> list[GrepMatch] | str: + return self._flush_with("grep", { + "pattern": pattern, + "path": path, + "glob": glob, + }) + + def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]: + return self._flush_with("glob", { + "pattern": pattern, + "path": path, + }) + + # --- Execute (flush pending writes + run command on same pod) --- + + def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse: + result = self._flush_with("execute", { + "command": command, + "timeout": timeout, + }) + return ExecuteResponse( + output=result.get("output", ""), + exit_code=result.get("exit_code"), + truncated=result.get("truncated", False), + ) diff --git a/samples/deepagent-serverless/uipath.json b/samples/deepagent-serverless/uipath.json new file mode 100644 index 000000000..d00dc27bf --- /dev/null +++ b/samples/deepagent-serverless/uipath.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://cloud.uipath.com/draft/2024-12/uipath", + "runtimeOptions": { + "isConversational": false + }, + "packOptions": { + "fileExtensionsIncluded": [], + "filesIncluded": [], + "filesExcluded": [], + "directoriesExcluded": [], + "includeUvLock": true + }, + "functions": {}, + "agents": {} +} \ No newline at end of file