diff --git a/.cursorrules b/.cursorrules index 32bae6c..03f95a6 100644 --- a/.cursorrules +++ b/.cursorrules @@ -39,7 +39,7 @@ For any python file, be sure to ALWAYS add typing annotations to each function o Make sure you keep any comments that exist in a file. -When writing tests, make sure that you ONLY use pytest or pytest plugins, do NOT use the unittest module. All tests should have typing annotations as well. All tests should be in ./tests. Be sure to create all necessary files and folders. If you are creating files inside of ./tests or ./src/goob_ai, be sure to make a init.py file if one does not exist. +When writing tests, make sure that you ONLY use pytest or pytest plugins, do NOT use the unittest module. All tests should have typing annotations as well. All tests should be in ./tests. Be sure to create all necessary files and folders. If you are creating files inside of ./tests or ./src/uipath, be sure to make a __init__.py file if one does not exist. All tests should be fully annotated and should contain docstrings. Be sure to import the following if TYPE_CHECKING: diff --git a/CLAUDE.md b/CLAUDE.md index 49ea3f5..b1062cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,14 +17,18 @@ gates. - `src/uipath/runtime/` — Core protocols and contracts - `src/uipath/runtime/base.py` — `UiPathRuntimeProtocol`, `UiPathExecutionRuntime` +- `src/uipath/runtime/context.py` — `UiPathRuntimeContext` (execution context, file I/O, log capture) +- `src/uipath/runtime/result.py` — `UiPathRuntimeResult`, `UiPathRuntimeStatus` - `src/uipath/runtime/factory.py` — `UiPathRuntimeFactoryProtocol` - `src/uipath/runtime/registry.py` — `UiPathRuntimeFactoryRegistry` +- `src/uipath/runtime/schema.py` — Schema models (`UiPathRuntimeGraph`, nodes, edges) +- `src/uipath/runtime/storage.py` — `UiPathRuntimeStorageProtocol` (key-value storage) - `src/uipath/runtime/events/` — Event types (`UiPathRuntimeStateEvent`, `UiPathRuntimeMessageEvent`) +- `src/uipath/runtime/errors/` — Error contracts and categories - `src/uipath/runtime/resumable/` — HITL support (`UiPathResumableRuntime`) - `src/uipath/runtime/debug/` — Debugger support (`UiPathDebugRuntime`, breakpoints) - `src/uipath/runtime/chat/` — Chat bridge support (`UiPathChatRuntime`) -- `src/uipath/runtime/schema.py` — Schema models (`UiPathRuntimeGraph`, nodes, edges) -- `src/uipath/runtime/errors/` — Error contracts and categories +- `src/uipath/runtime/logging/` — Log interception, file handlers, context-aware filters - `INTEGRATION_GENOME.md` — Complete guide for building new integrations ## Development @@ -38,5 +42,11 @@ uv run pytest ## Reference Integrations -- `uipath-langchain-python` — LangGraph integration (FULL tier) -- `uipath-integrations-python` — OpenAI Agents, LlamaIndex, Google ADK, Agent Framework +| Package | Repository | Tier | +|---------|-----------|------| +| `uipath-langchain` | `uipath-langchain-python` | FULL (streaming, HITL, LLM Gateway) | +| `uipath-openai-agents` | `uipath-integrations-python` | CORE (streaming, LLM Gateway) | +| `uipath-llamaindex` | `uipath-integrations-python` | FULL (streaming, HITL, LLM Gateway) | +| `uipath-google-adk` | `uipath-integrations-python` | CORE (streaming, LLM Gateway) | +| `uipath-agent-framework` | `uipath-integrations-python` | FULL (streaming, HITL) | +| `uipath-mcp` | `uipath-mcp-python` | MCP integration | diff --git a/INTEGRATION_GENOME.md b/INTEGRATION_GENOME.md index dc61415..1f6cc68 100644 --- a/INTEGRATION_GENOME.md +++ b/INTEGRATION_GENOME.md @@ -20,6 +20,127 @@ from real integrations. --- +## Coding Principles + +These apply to ALL code generated by this genome. Violations will be caught in quality gates. + +### 1. Prefer Strong Typing Over Dynamic Attribute Access + +**Always use `isinstance()` checks with concrete types.** Import the actual framework types +and check against them. Never use `getattr()`/`hasattr()` to guess at an object's interface. + +```python +# GOOD — strongly typed, verifiable, IDE-navigable +from crewai import Agent, Task, Crew +from crewai.tools import BaseTool + +if isinstance(agent, Agent): + tools = agent.tools # type checker knows this exists + for tool in tools: + if isinstance(tool, BaseTool): + name = tool.name # type checker knows this exists + +# BAD — dynamic, fragile, no type safety +tools = getattr(agent, "tools", []) +name = getattr(tool, "name", "unknown") +has_tools = hasattr(agent, "tools") +``` + +**Why this matters:** +- Type checkers (mypy) catch errors at build time, not runtime +- IDE autocompletion and navigation work correctly +- Refactoring is safe — rename in framework gets caught +- Code is self-documenting — the import tells you exactly what type you expect + +**When `getattr()` is acceptable:** +- `getattr(module, variable_name)` in the dynamic loader (Step 2) — unavoidable since the + variable name comes from config +- `__getattr__` in `__init__.py` for lazy imports — standard Python pattern +- Accessing truly optional/deprecated framework attributes where `isinstance` isn't possible + +### 2. Import From Actual Module Paths + +Import from the framework's concrete module paths, not from top-level `__init__.py` re-exports +which may change between versions. + +```python +# GOOD — stable, direct import +from openai_agents.agent import Agent +from google.adk.agents import LlmAgent +from agent_framework.workflows import Workflow + +# RISKY — re-exports may change +from openai_agents import Agent +``` + +### 3. Use `json.loads(serialize_json(...))` for Serialization + +The `serialize_json()` function returns a JSON **string**. UiPath runtime expects `dict` objects +for `output`, `payload`, etc. Always wrap with `json.loads()`: + +```python +from uipath.core.serialization import serialize_json +import json + +# CORRECT — returns dict +output = json.loads(serialize_json(framework_result)) + +# WRONG — returns string, will fail validation +output = serialize_json(framework_result) +``` + +### 4. Type-Annotate All Public Methods + +All runtime, factory, and schema functions must have complete type annotations. +Use `from __future__ import annotations` for forward references. + +### 5. Handle Framework-Specific Types Before serialize_json + +`serialize_json()` handles standard Python types, Pydantic models, dataclasses, and enums. +But frameworks may have custom wrapper types that need special handling first. Implement a +`_serialize_output()` helper that handles framework-specific types before falling through: + +```python +def _serialize_output(output: Any) -> Any: + """Handle framework-specific types before standard serialization.""" + # Example: LangGraph's Overwrite wrapper + # if isinstance(output, Overwrite): + # return _serialize_output(output.value) + + # Example: Framework models with alias support + # if isinstance(output, FrameworkModel): + # return _serialize_output(output.model_dump(by_alias=True)) + + if isinstance(output, dict): + return {k: _serialize_output(v) for k, v in output.items()} + if isinstance(output, (list, tuple)): + return [_serialize_output(v) for v in output] + + # Fall through to standard serialization + return json.loads(serialize_json(output)) +``` + +Only implement this if the framework has types that `serialize_json()` cannot handle. + +### 6. Validate Agent Paths Against Working Directory + +The dynamic agent loader must validate that loaded file paths stay within the project +directory. Never allow path traversal: + +```python +cwd = os.path.abspath(os.getcwd()) +abs_file_path = os.path.abspath(os.path.normpath(file_path)) +if not abs_file_path.startswith(cwd): + raise RuntimeError( + code=ErrorCode.AGENT_LOAD_FAILURE, + title="Invalid agent file path", + detail=f"Agent file path must be within the working directory.", + category=UiPathErrorCategory.USER, + ) +``` + +--- + ## Architecture Overview ### What an Integration Is @@ -80,6 +201,39 @@ STARTED → UPDATED (0..N times) → COMPLETED - `breakpoints: "*"` — step mode, suspend before every node - `breakpoints: None` — no breakpoints, free-running +### UiPathRuntimeContext — Key Fields Your Factory Uses + +The CLI creates a `UiPathRuntimeContext` and passes it to your factory. Key fields: + +| Field | Type | Usage | +|-------|------|-------| +| `entrypoint` | `str \| None` | Which agent to run | +| `resume` | `bool` | Whether this is a resume call (HITL) | +| `job_id` | `str \| None` | Orchestrator job ID (cloud execution) | +| `conversation_id` | `str \| None` | Chat conversation ID (enables chat bridge) | +| `state_file_path` | `str \| None` | Full path for state database | +| `runtime_dir` | `str \| None` | Directory for runtime files (default: `__uipath`) | +| `keep_state_file` | `bool` | Prevent deletion of state file between runs | +| `trace_manager` | `UiPathTraceManager \| None` | For instrumentation setup | + +Your factory uses these to determine storage paths, whether to clean stale state, and +whether instrumentation should be configured. + +### Session / Conversation State + +For multi-turn conversational agents, your integration may need to persist conversation +history across calls. Three patterns exist in reference integrations: + +| Pattern | Used By | When | +|---------|---------|------| +| Framework-native session service | Google ADK (`SqliteSessionService`) | Framework manages conversations natively | +| KV storage namespace | Agent Framework (session in KV) | Framework needs session injected per-executor | +| Shared checkpointer | LangGraph (`AsyncSqliteSaver`) | Framework has built-in checkpoint support | + +**Key rule:** If the framework supports multi-turn conversations, persist session state +in storage (not in-memory) so it survives across CLI invocations. Use `runtime_id` as +the session key. + --- ## Phase 0 — Framework Discovery @@ -148,6 +302,22 @@ b) Partial — input types but output is unstructured c) No — accepts/returns generic dicts or strings ``` +**Q8: Multi-Turn Conversation Support** +``` +Does the framework support multi-turn conversations natively? +a) Yes — built-in session/memory service (e.g., ADK SqliteSessionService, LangGraph checkpointer) +b) Partial — conversation history must be injected per-call +c) No — each call is independent +``` + +**Q9: Observability / Instrumentation** +``` +Does the framework have an OpenTelemetry or OpenInference instrumentor? +a) Yes — official instrumentor (e.g., openinference-instrumentation-langchain) +b) Yes — community instrumentor +c) No — manual tracing needed +``` + ### Output: integration_config.yaml ```yaml @@ -166,6 +336,8 @@ streaming: "callback" # async_generator | callback | none hitl: "none" # builtin | tool_callback | none message_format: "openai" # openai | framework | string | custom typed_schemas: "partial" # yes | partial | no +multi_turn: "builtin" # builtin | inject | none +instrumentor: "official" # official | community | none capability_tier: "CORE" # Derived: FULL | CORE | MINIMAL ``` @@ -417,10 +589,15 @@ from __future__ import annotations import importlib import inspect +import os import sys from pathlib import Path from typing import Any, Self +from uipath.runtime.errors import UiPathErrorCategory + +from .errors import UiPathErrorCode, UiPathRuntimeError + class AgentLoader: """Loads an agent from a module_path:variable_name string.""" @@ -429,6 +606,7 @@ class AgentLoader: self._module_path = module_path self._variable_name = variable_name self._agent: Any = None + self._context_manager: Any = None @classmethod def from_entrypoint(cls, entrypoint: str, base_path: str = ".") -> Self: @@ -445,10 +623,24 @@ class AgentLoader: async def load(self) -> Any: """Load and return the agent object.""" - # Add parent directory to sys.path for imports - module_dir = str(Path(self._module_path).parent.resolve()) - if module_dir not in sys.path: - sys.path.insert(0, module_dir) + # SECURITY: Validate path is within working directory (Coding Principle 6) + cwd = os.path.abspath(os.getcwd()) + abs_file_path = os.path.abspath(os.path.normpath(self._module_path)) + if not abs_file_path.startswith(cwd): + raise UiPathRuntimeError( + code=UiPathErrorCode.AGENT_LOAD_FAILURE, + title="Invalid agent file path", + detail=f"Agent file path '{self._module_path}' must be within " + f"the current working directory.", + category=UiPathErrorCategory.USER, + ) + + # Set up Python path for imports (support both flat and src-layout) + if cwd not in sys.path: + sys.path.insert(0, cwd) + src_dir = os.path.join(cwd, "src") + if os.path.isdir(src_dir) and src_dir not in sys.path: + sys.path.insert(0, src_dir) # Import the module module_name = Path(self._module_path).stem @@ -462,7 +654,7 @@ class AgentLoader: sys.modules[module_name] = module spec.loader.exec_module(module) - # Get the variable + # Get the variable (getattr is acceptable here — variable name comes from config) agent = getattr(module, self._variable_name, None) if agent is None: raise AttributeError( @@ -474,8 +666,9 @@ class AgentLoader: if callable(agent) and not isinstance(agent, type): agent = agent() if not inspect.iscoroutinefunction(agent) else await agent() - # Handle async context managers - if hasattr(agent, "__aenter__"): + # Handle async context managers (for setup/teardown resources) + if hasattr(agent, "__aenter__") and callable(agent.__aenter__): + self._context_manager = agent agent = await agent.__aenter__() self._agent = agent @@ -483,8 +676,9 @@ class AgentLoader: async def cleanup(self) -> None: """Cleanup resources (async context managers).""" - if self._agent and hasattr(self._agent, "__aexit__"): - await self._agent.__aexit__(None, None, None) + if self._context_manager is not None: + await self._context_manager.__aexit__(None, None, None) + self._context_manager = None ``` #### errors.py — Reference Implementation @@ -552,16 +746,19 @@ Schema inference serves two purposes: #### Key Principles -1. **Use `isinstance` checks, not `getattr` guessing** — Verify actual agent types before - accessing framework-specific attributes. +1. **Use `isinstance()` with concrete framework types** — Import the actual classes from the + framework and use `isinstance()` checks. Never use `getattr()` or `hasattr()` to probe + for capabilities. For Pydantic model detection, use `inspect.isclass(t) and issubclass(t, BaseModel)`. 2. **Import from actual module paths** — Not from `__init__.py` re-exports which may be unstable. 3. **Use helper utilities** — `transform_references()`, `transform_nullable_types()`, `transform_attachments()` from `uipath.runtime.schema`. -4. **Typed input replaces messages** — If agent defines `input_schema`, use it as the FULL +4. **Use `TypeAdapter` for Pydantic schemas** — When extracting JSON schemas from Pydantic + models, use `TypeAdapter(ModelClass).json_schema()` instead of `ModelClass.model_json_schema()`. +5. **Typed input replaces messages** — If agent defines `input_schema`, use it as the FULL input (don't merge with a generic messages field). -5. **Typed output replaces generic result** — If agent defines `output_schema`, use it as +6. **Typed output replaces generic result** — If agent defines `output_schema`, use it as the FULL output. -6. **Fallback to defaults** — Only use `{"messages": string}` for conversational agents +7. **Fallback to defaults** — Only use `{"messages": string}` for conversational agents WITHOUT typed input. #### Default Schemas (Conversational Agent) @@ -603,48 +800,57 @@ from uipath.runtime.schema import ( ) -def build_graph(agent: Any) -> UiPathRuntimeGraph | None: +def build_graph(agent: FrameworkAgent) -> UiPathRuntimeGraph | None: """Build a visualization graph from the framework agent. Node IDs become breakpoint targets. The debug bridge uses them to: - Display execution progress (which node is running) - Set breakpoints (pause before/after specific nodes) - Show the agent's structure in the UI + + IMPORTANT: Use isinstance() checks with concrete framework types. + Import the actual types from the framework and check against them. """ nodes: list[UiPathRuntimeNode] = [] edges: list[UiPathRuntimeEdge] = [] - # Example: Extract tools as nodes - for tool in getattr(agent, "tools", []): - nodes.append(UiPathRuntimeNode( - id=tool.name, # ← This ID is used for breakpoints - name=tool.name, - type="tool", - metadata={"description": getattr(tool, "description", None)}, - )) - - # Example: Extract sub-agents as nodes with subgraphs - for sub_agent in getattr(agent, "agents", []): - sub_graph = build_graph(sub_agent) # Recursive - nodes.append(UiPathRuntimeNode( - id=sub_agent.name, - name=sub_agent.name, - type="agent", - subgraph=sub_graph, # ← Nested graph for composite nodes - )) - - # Example: Add the main agent node + # Import concrete framework types for isinstance checks + # from .tools import BaseTool, AgentTool + # from .agents import SubAgent + + # Example: Extract tools as nodes (strongly typed) + if isinstance(agent, FrameworkAgent): # Always check type first + for tool in agent.tools: # Access attribute directly on known type + # Use isinstance to differentiate tool types + if isinstance(tool, AgentTool): + # Tool that wraps another agent → node with subgraph + sub_graph = build_graph(tool.agent) + nodes.append(UiPathRuntimeNode( + id=tool.agent.name, + name=tool.agent.name, + type="agent", + subgraph=sub_graph, # ← Nested graph for composite nodes + )) + elif isinstance(tool, BaseTool): + nodes.append(UiPathRuntimeNode( + id=tool.name, # ← This ID is used for breakpoints + name=tool.name, + type="tool", + metadata={"description": tool.description}, + )) + + # Add the main agent node nodes.insert(0, UiPathRuntimeNode( - id="agent", - name=agent.name if hasattr(agent, "name") else "agent", + id=agent.name, # Known attribute on FrameworkAgent + name=agent.name, type="model", - metadata={"model": getattr(agent, "model", None)}, + metadata={"model": agent.model}, # Known attribute on FrameworkAgent )) # Build edges (agent → tools, agent → sub-agents) for node in nodes[1:]: edges.append(UiPathRuntimeEdge( - source="agent", + source=agent.name, target=node.id, label=None, )) @@ -655,13 +861,71 @@ def build_graph(agent: Any) -> UiPathRuntimeGraph | None: return UiPathRuntimeGraph(nodes=nodes, edges=edges) ``` +#### Graph Building Best Practices + +**1. Tool aggregation:** When an agent has many function tools, aggregate them into a +single `{agent_name}_tools` node instead of creating one node per tool. Include tool +names in metadata. This keeps the graph readable: + +```python +if regular_tools: + tool_names = [tool.name for tool in regular_tools] + nodes.append(UiPathRuntimeNode( + id=f"{agent.name}_tools", + name="tools", + type="tool", + metadata={"tool_names": tool_names, "tool_count": len(tool_names)}, + )) +``` + +**2. Control nodes:** Add `__start__` and `__end__` nodes for graph completeness: + +```python +nodes.insert(0, UiPathRuntimeNode(id="__start__", name="__start__", type="__start__")) +nodes.append(UiPathRuntimeNode(id="__end__", name="__end__", type="__end__")) +edges.insert(0, UiPathRuntimeEdge(source="__start__", target=first_node_id, label="input")) +edges.append(UiPathRuntimeEdge(source=last_node_id, target="__end__", label="output")) +``` + +**3. Synthetic nodes for implicit concepts:** If the framework has human-in-the-loop +without an explicit "human step", create a synthetic node to make it visible in the graph: + +```python +# LlamaIndex creates an "external_step" node for human interaction +if framework_has_human_interaction: + nodes.append(UiPathRuntimeNode( + id="external_step", name="external_step", type="external", + )) +``` + +**4. Handoff tools as edges:** If the framework has tools that transfer control to another +agent (e.g., `handoff_to_agent_b`), represent these as graph edges rather than tool nodes +to avoid double-representation. + +**5. Circular reference prevention:** Use a `visited: set[str]` to prevent infinite +recursion when agents reference each other: + +```python +def build_graph(agent, visited: set[str] | None = None) -> UiPathRuntimeGraph | None: + if visited is None: + visited = set() + if agent.name in visited: + return None + visited.add(agent.name) + # ... build graph +``` + +**6. Middleware/internal node filtering:** Some frameworks have internal nodes +(e.g., `GuardrailsMiddleware.hook_name`) that should be hidden from the user-visible +graph. Filter these during graph building or in state event emission. + #### Schema Types Reference ```python class UiPathRuntimeNode(BaseModel): id: str # Unique ID — used as breakpoint target name: str # Display name - type: str # "tool", "model", "agent", "node" + type: str # "tool", "model", "agent", "node", "__start__", "__end__", "external" subgraph: UiPathRuntimeGraph | None = None # Nested graph for hierarchical agents metadata: dict[str, Any] | None = None # Framework-specific metadata @@ -687,7 +951,10 @@ class UiPathRuntimeSchema(BaseModel): #### Complete get_schema() Implementation Pattern ```python +import inspect import uuid + +from pydantic import BaseModel, TypeAdapter from uipath.runtime.schema import ( UiPathRuntimeSchema, transform_references, @@ -695,28 +962,44 @@ from uipath.runtime.schema import ( transform_attachments, ) +# Import concrete framework types +# from .agents import FrameworkAgent + + +def _is_pydantic_model(type_hint: Any) -> bool: + """Check if a type hint is a Pydantic model class.""" + return inspect.isclass(type_hint) and issubclass(type_hint, BaseModel) + + +def _get_schema_from_type(type_hint: type) -> dict[str, Any]: + """Extract JSON schema from a Pydantic model or type hint.""" + raw = TypeAdapter(type_hint).json_schema() + return transform_attachments( + transform_nullable_types( + transform_references(raw) + ) + ) + async def get_schema(self) -> UiPathRuntimeSchema: """Extract schema from the loaded agent.""" - # 1. Try typed input schema - input_schema = self._get_typed_input_schema() - if input_schema: - input_schema = transform_attachments( - transform_nullable_types( - transform_references(input_schema) - ) - ) + # 1. Try typed input schema (use isinstance, not getattr) + input_type = None + if isinstance(self._agent, FrameworkAgent): + input_type = self._agent.input_schema # Access known attribute on known type + + if input_type and _is_pydantic_model(input_type): + input_schema = _get_schema_from_type(input_type) else: input_schema = DEFAULT_INPUT_SCHEMA # 2. Try typed output schema - output_schema = self._get_typed_output_schema() - if output_schema: - output_schema = transform_attachments( - transform_nullable_types( - transform_references(output_schema) - ) - ) + output_type = None + if isinstance(self._agent, FrameworkAgent): + output_type = self._agent.output_schema + + if output_type and _is_pydantic_model(output_type): + output_schema = _get_schema_from_type(output_type) else: output_schema = DEFAULT_OUTPUT_SCHEMA @@ -735,26 +1018,36 @@ async def get_schema(self) -> UiPathRuntimeSchema: #### Reference: How Existing Integrations Build Graphs -**OpenAI Agents** — Extracts agents, handoffs (sub-agents), tools: +All existing integrations use **`isinstance()` checks with concrete framework types**. + +**OpenAI Agents** — Uses `isinstance(content, Agent)`, `isinstance(tool, Agent)`: ```python -# Agents become nodes with type="agent" -# Handoffs become edges between agent nodes -# Tools become nodes with type="tool" -# Nested agents get subgraph with recursive extraction +from agents import Agent +from agents.tool import FunctionTool + +# isinstance(tool, Agent) → AgentTool wrapping sub-agent → node with subgraph +# isinstance(tool, FunctionTool) → tool node +# Extracts agent-as-tool via __closure__ inspection ``` -**LangGraph** — Extracts from compiled StateGraph: +**Google ADK** — Uses `isinstance(agent, LlmAgent)`, `isinstance(tool, AgentTool)`: ```python -# Graph nodes → UiPathRuntimeNode (type detected from node content) -# Graph edges → UiPathRuntimeEdge (conditional edges get labels) -# Subgraphs → nested UiPathRuntimeGraph via node.subgraph +from google.adk.agents import LlmAgent, BaseAgent +from google.adk.tools import AgentTool, BaseTool + +# isinstance(agent, LlmAgent) → model node with tools +# isinstance(tool, AgentTool) → extracts tool.agent → recursive subgraph +# isinstance(tool, BaseTool) → tool node ``` -**Google ADK** — Extracts from LlmAgent tree: +**Agent Framework** — Uses `isinstance(agent, WorkflowAgent)`, `isinstance(executor, AgentExecutor)`: ```python -# Agent → node with type="model" -# sub_agents → recursive nodes with subgraphs -# tools → nodes with type="tool" +from agent_framework.workflows import Workflow, WorkflowAgent +from agent_framework.executors import AgentExecutor + +# isinstance(agent, WorkflowAgent) → extract workflow.executors +# isinstance(executor, AgentExecutor) → extract executor._agent tools +# Edges from workflow.edge_groups with condition labels ``` #### QUALITY GATE 3 @@ -787,7 +1080,7 @@ async def execute( whatever the framework expects. **Output:** Always a `UiPathRuntimeResult` with: -- `output` — JSON-serializable result (use `serialize_json()`) +- `output` — JSON-serializable dict (use `json.loads(serialize_json(result))`) - `status` — `SUCCESSFUL`, `FAULTED`, or `SUSPENDED` - `error` — `UiPathErrorContract` if faulted @@ -810,6 +1103,7 @@ which node is executing. #### Implementation Pattern ```python +import json import traceback from typing import Any @@ -823,13 +1117,16 @@ from uipath.runtime.errors import UiPathErrorCategory from .errors import UiPathErrorCode, UiPathRuntimeError +# Import concrete framework types for isinstance checks +from import Agent as FrameworkAgent # Adjust to actual framework types + class UiPathRuntime: """UiPath runtime implementation for .""" def __init__( self, - agent: Any, + agent: FrameworkAgent, # Use concrete type, not Any runtime_id: str, entrypoint: str, ) -> None: @@ -850,8 +1147,8 @@ class UiPathRuntime: # 2. Run the agent result = await self._run_agent(framework_input, options) - # 3. Serialize output - output = serialize_json(result) + # 3. Serialize output (serialize_json returns str, wrap with json.loads) + output = json.loads(serialize_json(result)) return UiPathRuntimeResult( output=output, @@ -860,12 +1157,7 @@ class UiPathRuntime: except UiPathRuntimeError: raise # Re-raise our own errors except Exception as e: - raise UiPathRuntimeError( - code=UiPathErrorCode.AGENT_EXECUTION_ERROR, - title="Agent execution failed", - detail=f"{type(e).__name__}: {e}\n{traceback.format_exc()}", - category=UiPathErrorCategory.SYSTEM, - ) from e + raise self._create_runtime_error(e) from e def _map_input(self, input: dict[str, Any]) -> Any: """Map UiPath input dict to framework-specific format.""" @@ -875,6 +1167,14 @@ class UiPathRuntime: # For typed agents: return input + def _extract_output(self, result: Any) -> dict[str, Any]: + """Extract and serialize the framework result. + + Override this method if the framework needs special output extraction + (e.g., output_channels, output_key, session.state lookup). + """ + return json.loads(serialize_json(result)) + async def _run_agent(self, input: Any, options: UiPathExecuteOptions) -> Any: """Run the framework agent. Override per framework.""" # Example patterns: @@ -882,11 +1182,84 @@ class UiPathRuntime: # return await self._agent.kickoff(inputs=input) # return await Runner.run(self._agent, input) raise NotImplementedError + + def _create_runtime_error( + self, e: Exception + ) -> UiPathRuntimeError: + """Map framework exceptions to categorized runtime errors. + + Each framework has specific exceptions that should map to USER vs + SYSTEM categories. USER means the caller can fix it (bad input, + timeout config). SYSTEM means infrastructure failure. + """ + detail = f"{type(e).__name__}: {e}\n{traceback.format_exc()}" + + # Map framework-specific exceptions to proper categories + # Example patterns from real integrations: + # + # if isinstance(e, FrameworkTimeoutError): + # return UiPathRuntimeError( + # code=UiPathErrorCode.AGENT_TIMEOUT, + # title="Agent execution timed out", + # detail=detail, + # category=UiPathErrorCategory.USER, # User controls timeout config + # ) + # + # if isinstance(e, FrameworkRecursionError): + # return UiPathRuntimeError( + # code=UiPathErrorCode.AGENT_EXECUTION_ERROR, + # title="Recursion limit exceeded", + # detail=detail, + # category=UiPathErrorCategory.USER, + # ) + + if isinstance(e, json.JSONDecodeError): + return UiPathRuntimeError( + code=UiPathErrorCode.AGENT_EXECUTION_ERROR, + title="Invalid JSON input", + detail=detail, + category=UiPathErrorCategory.USER, + ) + + # Default: SYSTEM category for unexpected errors + return UiPathRuntimeError( + code=UiPathErrorCode.AGENT_EXECUTION_ERROR, + title="Agent execution failed", + detail=detail, + category=UiPathErrorCategory.SYSTEM, + ) ``` +#### Output Extraction Strategies + +The simple case is `json.loads(serialize_json(result))`. But frameworks often need more +sophisticated output extraction. Override `_extract_output()` based on the framework: + +| Framework Pattern | Strategy | +|---|---| +| Single result object | `json.loads(serialize_json(result))` | +| Output channels (LangGraph) | Extract only declared `output_channels` from result dict | +| Output key in session state (ADK) | Read `session.state[output_key]`, parse if `output_schema` defined | +| Typed output class (LlamaIndex) | Use `workflow.output_cls` to validate/extract | +| Wrapper types (LangGraph `Overwrite`) | Unwrap framework-specific wrappers first (Coding Principle 5) | + +#### Error Category Decision Guide + +| Exception Type | Category | Reasoning | +|---|---|---| +| Timeout | `USER` | User controls timeout configuration | +| Recursion/loop limit | `USER` | User controls graph depth | +| Invalid input / JSON error | `USER` | Caller provided bad data | +| Agent not found / config missing | `DEPLOYMENT` | Project misconfiguration | +| Import error / module missing | `DEPLOYMENT` | Missing dependency | +| Framework internal error | `SYSTEM` | Unexpected failure | +| Default / unknown | `SYSTEM` | Cannot determine cause | + #### Serialization Rules -Always use `serialize_json()` from `uipath.core.serialization`. It handles: +Always use `json.loads(serialize_json(...))` from `uipath.core.serialization`. The +`serialize_json()` function returns a JSON **string** — wrap it with `json.loads()` to get +a dict that UiPath runtime expects. It handles: - Pydantic models → dict - Dataclasses → dict - Enums → value @@ -894,13 +1267,15 @@ Always use `serialize_json()` from `uipath.core.serialization`. It handles: - Custom objects → best-effort serialization ```python +import json from uipath.core.serialization import serialize_json # DO: -output = serialize_json(framework_result) +output = json.loads(serialize_json(framework_result)) # DON'T: -output = framework_result.model_dump() # May miss nested types +output = serialize_json(framework_result) # Returns string, not dict! +output = framework_result.model_dump() # May miss nested types output = json.loads(json.dumps(result, default=str)) # Lossy ``` @@ -924,7 +1299,7 @@ async def execute(self, input, options): ``` ✓ execute() returns UiPathRuntimeResult with status SUCCESSFUL on happy path ✓ execute() returns UiPathRuntimeResult with status FAULTED on error (via exception) -✓ Output is serialized via serialize_json() +✓ Output is serialized via json.loads(serialize_json()) ✓ Errors are wrapped in UiPathRuntimeError with proper category ✓ Input mapping correctly handles both "messages" and typed inputs ✓ Resume flag is respected (if HITL supported) @@ -958,11 +1333,13 @@ There are two event types you emit during streaming: **1. `UiPathRuntimeStateEvent`** — Node lifecycle events for graph visualization ```python +import json +from uipath.core.serialization import serialize_json from uipath.runtime.events import UiPathRuntimeStateEvent, UiPathRuntimeStatePhase # When a node starts executing: yield UiPathRuntimeStateEvent( - payload={"input": node_input}, # State data (what went into the node) + payload=json.loads(serialize_json(node_input)), # Serialize framework objects to dict node_name="agent_node", # Simple node name qualified_node_name="parent:agent_node",# Full path (for subgraph hierarchy) phase=UiPathRuntimeStatePhase.STARTED, # Node is starting @@ -970,7 +1347,7 @@ yield UiPathRuntimeStateEvent( # When a node's state updates mid-execution: yield UiPathRuntimeStateEvent( - payload={"partial_output": partial}, + payload=json.loads(serialize_json(partial)), node_name="agent_node", qualified_node_name="parent:agent_node", phase=UiPathRuntimeStatePhase.UPDATED, @@ -978,7 +1355,7 @@ yield UiPathRuntimeStateEvent( # When a node completes: yield UiPathRuntimeStateEvent( - payload={"output": node_output}, + payload=json.loads(serialize_json(node_output)), node_name="agent_node", qualified_node_name="parent:agent_node", phase=UiPathRuntimeStatePhase.COMPLETED, @@ -1007,13 +1384,94 @@ from uipath.runtime.events import UiPathRuntimeMessageEvent # When the LLM produces output (text chunk, tool call, etc.) yield UiPathRuntimeMessageEvent( - payload=message_object, # Framework-specific message object + payload=json.loads(serialize_json(message_object)), # Serialize framework message to dict ) ``` The `payload` of message events is framework-specific. The `UiPathChatRuntime` wrapper (applied by the CLI) will consume these and forward them to the chat bridge for UI display. +#### Message Format Mapping (messages.py) + +For CORE+ integrations, implement a `messages.py` module that handles bidirectional +conversion between UiPath conversation format and the framework's message types. + +**Why this is critical:** The chat bridge expects `UiPathConversationMessageEvent` +objects with a specific lifecycle. A stateless mapper loses context (e.g., which AI +message generated which tool call). All reference integrations use stateful mappers. + +```python +"""Message format mapper for integration.""" + +from __future__ import annotations + +from typing import Any + +from uipath.runtime.events import UiPathRuntimeMessageEvent + + +class ChatMessagesMapper: + """Stateful bidirectional message mapper. + + Maintains state across streaming chunks to correctly correlate + tool calls with their originating AI messages. + """ + + def __init__(self, runtime_id: str, storage: Any = None) -> None: + self._runtime_id = runtime_id + self._storage = storage + # Stateful tracking across streaming chunks + self._current_message_id: str | None = None + self._message_started: bool = False + self._pending_tool_calls: dict[str, str] = {} # call_id → message_id + + # Inbound: UiPath messages → framework format + def map_input_to_framework(self, messages: Any) -> Any: + """Convert UiPath input messages to framework format.""" + # Handle: list[UiPathConversationMessage], list[dict], str + ... + + # Outbound: Framework events → UiPath conversation events + def map_framework_event(self, event: Any) -> list[Any] | None: + """Convert framework streaming event to UiPath conversation events. + + Message lifecycle emitted: + MessageStart → ContentPartChunk (0..N) → ToolCallStart (0..N) → MessageEnd + + Returns None for events that should not be forwarded to chat. + """ + # Distinguish between: + # - LLM text output → MessageStart + ContentPartChunk + MessageEnd + # - Tool calls → ToolCallStart + ToolCallEnd (correlated with parent message) + # - Internal framework events → filter out (return None) + ... +``` + +**Key patterns from reference integrations:** +- **LangGraph**: Storage-backed tool call ID → message ID mapping (survives across chunks) +- **Google ADK**: Filters `transfer_to_agent` internal calls from tool call events +- **Agent Framework**: Tracks message lifecycle across `executor_completed` events +- **OpenAI Agents**: Separates "message" events from "state" events by event name + +#### Middleware / Internal Node Filtering + +Some frameworks have internal nodes (middleware, guardrails, internal routing) that +should not appear in state events or the user-visible graph. Filter them: + +```python +def _detect_middleware_nodes(self) -> set[str]: + """Detect framework-internal nodes to filter from state events.""" + middleware_nodes: set[str] = set() + for node_name in self._graph.nodes.keys(): + if "Middleware" in node_name: # Framework-specific pattern + middleware_nodes.add(node_name) + return middleware_nodes + +# In stream(), filter state events: +if not self._is_middleware_node(node_name): + yield UiPathRuntimeStateEvent(...) +``` + #### Breakpoint Support in Streaming When `options.breakpoints` is set, your stream should check if the current node matches @@ -1057,7 +1515,7 @@ async def stream(self, input, options: UiPathStreamOptions): # ALWAYS yield final result yield UiPathRuntimeResult( - output=serialize_json(final_output), + output=json.loads(serialize_json(final_output)), status=UiPathRuntimeStatus.SUCCESSFUL, ) ``` @@ -1121,6 +1579,8 @@ async def stream(self, input, options): ✓ Breakpoints are checked when options.breakpoints is set ✓ UiPathBreakpointResult includes breakpoint_node, current_state, next_nodes ✓ Message events are yielded for LLM output (text chunks, tool calls) +✓ Middleware/internal framework nodes are filtered from state events +✓ messages.py mapper correctly converts between UiPath and framework message formats ``` --- @@ -1163,13 +1623,21 @@ class SqliteStorage: async def setup(self) -> None: Path(self._db_path).parent.mkdir(parents=True, exist_ok=True) self._db = await aiosqlite.connect(self._db_path) + + # Production SQLite pragmas for concurrency + await self._db.execute("PRAGMA journal_mode=WAL") # Write-ahead logging + await self._db.execute("PRAGMA busy_timeout=30000") # 30s timeout + await self._db.execute("PRAGMA synchronous=NORMAL") # Balance safety/speed + await self._db.execute( "CREATE TABLE IF NOT EXISTS __uipath_resume_triggers " - "(runtime_id TEXT, data TEXT)" + "(runtime_id TEXT, data TEXT, " + "timestamp DATETIME DEFAULT CURRENT_TIMESTAMP)" ) await self._db.execute( "CREATE TABLE IF NOT EXISTS __uipath_runtime_kv " "(runtime_id TEXT, namespace TEXT, key TEXT, value TEXT, " + "timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, " "PRIMARY KEY (runtime_id, namespace, key))" ) await self._db.commit() @@ -1180,7 +1648,7 @@ class SqliteStorage: assert self._db for trigger in triggers: await self._db.execute( - "INSERT INTO __uipath_resume_triggers VALUES (?, ?)", + "INSERT INTO __uipath_resume_triggers (runtime_id, data) VALUES (?, ?)", (runtime_id, trigger.model_dump_json()), ) await self._db.commit() @@ -1256,12 +1724,9 @@ if framework_signals_interrupt(event): trigger = UiPathResumeTrigger( interrupt_id=str(event.interrupt_id), trigger_type=UiPathResumeTriggerType.API, - trigger_name=UiPathResumeTriggerName( - name=event.tool_name or "human_input", - title=event.prompt or "Waiting for input", - ), + trigger_name=UiPathResumeTriggerName.API, api_resume=UiPathApiTrigger( - payload=event.context, # Data needed to resume + request=event.context, # Data needed to resume ), ) @@ -1274,6 +1739,44 @@ if framework_signals_interrupt(event): return # Stop streaming — wrapper handles persistence ``` +**Multiple concurrent triggers:** When the framework has multiple simultaneous interrupts +(e.g., LangGraph with multiple nodes waiting for human input), use `triggers=[...]`: + +```python +triggers = [] +for interrupt in framework_state.interrupts: + triggers.append(UiPathResumeTrigger( + interrupt_id=str(interrupt.id), + trigger_type=UiPathResumeTriggerType.API, + ... + )) + +yield UiPathRuntimeResult( + output=interrupt_context, + status=UiPathRuntimeStatus.SUSPENDED, + triggers=triggers, # Multiple triggers — all must be resolved to resume +) +``` + +#### Scoped Storage for Multi-Runtime Isolation + +When multiple runtimes share a single SQLite database, prevent cross-runtime data +collisions by scoping storage with the `runtime_id`: + +```python +class ScopedCheckpointStorage: + """Wraps checkpoint storage with runtime-scoped key prefix.""" + + def __init__(self, delegate: FrameworkCheckpointStorage, runtime_id: str) -> None: + self._delegate = delegate + self._scope = f"{runtime_id}::" + + async def get_latest(self, *, workflow_name: str) -> Any: + return await self._delegate.get_latest( + workflow_name=f"{self._scope}{workflow_name}" + ) +``` + #### Wrapping with UiPathResumableRuntime In your factory (Step 7), wrap the base runtime: @@ -1337,6 +1840,10 @@ from .loader import AgentLoader from .runtime import UiPathRuntime from .errors import UiPathErrorCode, UiPathRuntimeError +# Import concrete framework types +from import Agent as FrameworkAgent # Adjust to actual framework types +from uipath.runtime.errors import UiPathErrorCategory + class UiPathRuntimeFactory: """Factory for creating runtime instances.""" @@ -1344,9 +1851,32 @@ class UiPathRuntimeFactory: def __init__(self, context: UiPathRuntimeContext) -> None: self._context = context self._config = Config() - self._agent_cache: dict[str, Any] = {} + self._agent_cache: dict[str, FrameworkAgent] = {} # Use concrete type self._agent_lock = asyncio.Lock() self._storage: SqliteStorage | None = None # FULL tier only + self._instrumented = False + + # Set up instrumentation on construction + self._setup_instrumentation(context.trace_manager) + + def _setup_instrumentation( + self, trace_manager: Any = None + ) -> None: + """Set up OpenTelemetry instrumentation for the framework. + + Every integration should instrument its framework if an instrumentor + is available. This enables distributed tracing without user code changes. + """ + if self._instrumented: + return + try: + # Replace with your framework's instrumentor: + # from openinference.instrumentation. import Instrumentor + # Instrumentor().instrument() + pass + except ImportError: + pass # Instrumentation is optional + self._instrumented = True def discover_entrypoints(self) -> list[str]: """List available agents from config file.""" @@ -1391,16 +1921,13 @@ class UiPathRuntimeFactory: return runtime - async def _resolve_agent(self, entrypoint: str) -> Any: + async def _resolve_agent(self, entrypoint: str) -> FrameworkAgent: """Load and optionally cache the agent for the given entrypoint. IMPORTANT: Only cache agents if the framework allows the same instance to be executed concurrently. Some frameworks (e.g., Agent Framework) maintain internal state per execution, so each runtime must get a fresh agent instance. When in doubt, don't cache. - - Cache-safe: OpenAI Agents, Google ADK, LangGraph (compiled graphs are reusable) - NOT safe: Agent Framework (workflow instances hold execution state) """ # Option A: With caching (if framework supports concurrent reuse) async with self._agent_lock: @@ -1473,17 +2000,36 @@ During `uipath init`, the CLI also: 3. Generates mermaid diagrams from the graph 4. Generates AGENTS.md documentation +#### Agent Caching Decision Framework + +| Question | If YES → Cache | If NO → Don't Cache | +|---|---|---| +| Is the agent object stateless between executions? | Cache with `asyncio.Lock` | Fresh instance per runtime | +| Can the same instance run concurrently? | Cache | Don't cache | +| Does the agent hold `_is_running` flags or event queues? | Don't cache | N/A | + +**Known caching decisions from reference integrations:** + +| Framework | Cache Safe? | Why | +|---|---|---| +| OpenAI Agents | **Yes** | Agent config is immutable, no execution state | +| Google ADK | **Yes** | Agent definitions are stateless | +| LangGraph | **Yes** | Compiled graphs are reusable across executions | +| LlamaIndex | **No** | Workflow instances hold context and event queues | +| Agent Framework | **No** | `Workflow._is_running` flag prevents concurrent use | + +**Default:** If unsure, don't cache. The cost of re-loading is low; the cost of sharing +a stateful instance (race conditions, "already running" errors) is high. + #### QUALITY GATE 7 ``` ✓ Factory discovers entrypoints from config file ✓ new_runtime() returns a valid UiPathRuntimeProtocol implementation ✓ Registration function correctly registers with UiPathRuntimeFactoryRegistry -✓ Agent caching with asyncio.Lock IF the framework allows concurrent reuse of the same instance - (e.g., OpenAI Agents, Google ADK, LangGraph — compiled graphs/agents are stateless) -✓ NO caching if the framework maintains internal execution state per instance - (e.g., Agent Framework — workflow instances cannot be reused concurrently) -✓ Unknown entrypoint raises proper error -✓ dispose() cleans up all resources +✓ Agent caching decision is correct for the framework (see decision framework above) +✓ Instrumentation is set up in factory constructor (if instrumentor available) +✓ Unknown entrypoint raises proper error with USER category +✓ dispose() cleans up all resources (storage, cache, loaders) ``` --- @@ -1770,6 +2316,52 @@ def __getattr__(name: str): raise AttributeError(f"module {__name__!r} has no attribute {name!r}") ``` +#### Optional Dependencies in pyproject.toml + +Declare LLM providers as optional dependency groups so users only install what they need: + +```toml +[project.optional-dependencies] +bedrock = [ + "boto3>=1.28.0", + "aiobotocore>=2.5.0", + ">=X.Y.Z", +] +vertex = [ + "google-genai>=1.0.0", + ">=X.Y.Z", +] +``` + +Each provider module should check its dependencies at import time with a clear message: + +```python +import importlib.util + +def _check_bedrock_dependencies() -> None: + missing = [] + if importlib.util.find_spec("boto3") is None: + missing.append("boto3") + if missing: + raise ImportError( + f"Missing dependencies for Bedrock support: {', '.join(missing)}. " + f"Install with: pip install uipath-[bedrock]" + ) +``` + +#### Provider Implementation Strategies + +Different frameworks require different approaches for LLM gateway integration: + +| Strategy | When to Use | Example | +|---|---|---| +| **URL-rewriting transport** | Framework uses httpx internally | OpenAI Agents, Gemini | +| **Subclass framework's LLM class** | Framework has a base LLM class with `_client` property | LangGraph (ChatOpenAI) | +| **Implement framework's LLM protocol from scratch** | Framework's protocol is simple or no base class | Google ADK (BaseLlm for OpenAI) | +| **HTTP event handler** | Framework uses boto3 or non-httpx HTTP | LlamaIndex (Bedrock via boto3 event) | + +Choose the strategy that matches how the framework's LLM client makes HTTP calls. + #### QUALITY GATE 8 ``` ✓ Gateway URL is correctly constructed with vendor and model @@ -1779,6 +2371,8 @@ def __getattr__(name: str): ✓ SSL context uses get_httpx_client_kwargs() ✓ Provider classes are lazily imported ✓ API key is set to placeholder (gateway handles actual auth) +✓ Optional dependencies declared in pyproject.toml extras +✓ Clear ImportError message if optional dependency missing ``` --- @@ -2066,9 +2660,14 @@ from uipath.runtime import ( # Execution wrapper (you don't implement this — the CLI applies it) from uipath.runtime import UiPathExecutionRuntime -# Serialization +# Serialization (returns JSON string — always wrap with json.loads()) +import json from uipath.core.serialization import serialize_json +# Type checking helpers +import inspect +from pydantic import BaseModel, TypeAdapter + # Errors from uipath.runtime.errors import ( UiPathBaseRuntimeError, @@ -2088,11 +2687,15 @@ from uipath._utils._ssl_context import get_httpx_client_kwargs ### Required (all tiers) - [ ] `pyproject.toml` with entry points for `uipath.runtime.factories` and `uipath.middlewares` - [ ] Config file parser (`.json`) -- [ ] Agent loader (dynamic import from `file.py:variable`) -- [ ] Error codes and exception class +- [ ] Agent loader with path security validation (Coding Principle 6) +- [ ] Agent loader supports src-layout projects (`src/` on sys.path) +- [ ] Error codes and exception class with framework-specific exception mapping - [ ] `get_schema()` returning `UiPathRuntimeSchema` with input/output schemas - [ ] `execute()` returning `UiPathRuntimeResult` +- [ ] `_create_runtime_error()` mapping framework exceptions to proper `UiPathErrorCategory` - [ ] Factory with `discover_entrypoints()` and `new_runtime()` +- [ ] Factory instrumentation setup (OpenTelemetry, if instrumentor available) +- [ ] Correct agent caching decision (see caching decision framework) - [ ] Registration function for `UiPathRuntimeFactoryRegistry` - [ ] CLI middleware for `uipath new` @@ -2103,20 +2706,29 @@ from uipath._utils._ssl_context import get_httpx_client_kwargs - [ ] `UiPathRuntimeMessageEvent` for LLM output - [ ] Final event is always `UiPathRuntimeResult` - [ ] Graph building with nodes and edges for visualization +- [ ] `messages.py` with stateful bidirectional message mapper +- [ ] Middleware/internal framework nodes filtered from state events +- [ ] `__start__` and `__end__` control nodes in graph ### Required for FULL tier -- [ ] SQLite storage for resume triggers +- [ ] SQLite storage with WAL mode and production pragmas - [ ] Suspension detection (framework interrupts → SUSPENDED status) - [ ] `UiPathResumeTrigger` creation with proper trigger types +- [ ] Support for multiple concurrent triggers (`triggers=[...]`) - [ ] `UiPathResumableRuntime` wrapping in factory - [ ] Resume support (`options.resume=True`) +- [ ] Scoped storage if multiple runtimes share a database ### Optional (recommended) - [ ] Breakpoint support (`UiPathBreakpointResult` for node-level debugging) - [ ] LLM provider classes routing through UiPath Gateway +- [ ] Optional LLM dependencies in `pyproject.toml` extras with import-time checks - [ ] `qualified_node_name` for subgraph hierarchy in state events -- [ ] Agent caching with `asyncio.Lock` in factory (only if framework allows concurrent reuse) - [ ] Lazy imports for optional dependencies +- [ ] Custom `_serialize_output()` for framework-specific types (Coding Principle 5) +- [ ] Session/conversation state persistence for multi-turn agents +- [ ] Tool aggregation nodes (single `{agent}_tools` node instead of N tool nodes) +- [ ] Circular reference prevention in graph building - [ ] Sample project in `samples/` - [ ] Integration tests @@ -2126,10 +2738,21 @@ from uipath._utils._ssl_context import get_httpx_client_kwargs Study these for real-world patterns: -| Integration | Package | Config File | Streaming | HITL | LLM Gateway | -|-------------|---------|-------------|-----------|------|-------------| -| **LangGraph** | `uipath-langchain-python` | `langgraph.json` | Yes (async gen) | Yes | Yes (OpenAI) | -| **OpenAI Agents** | `uipath-openai-agents` | `openai_agents.json` | Yes (async gen) | No | Yes (OpenAI) | -| **LlamaIndex** | `uipath-llamaindex` | `llama_index.json` | Yes (async gen) | Yes | Yes (Bedrock, Vertex) | -| **Google ADK** | `uipath-google-adk` | `google_adk.json` | Yes (async gen) | Partial | Yes (OpenAI, Anthropic, Gemini) | -| **Agent Framework** | `uipath-agent-framework` | `agent_framework.json` | Yes (async gen) | Yes | No | +| Integration | Package | Config File | Streaming | HITL | LLM Gateway | Agent Caching | +|---|---|---|---|---|---|---| +| **LangGraph** | `uipath-langchain-python` | `langgraph.json` | Yes (async gen) | Yes | Yes (OpenAI) | Yes | +| **OpenAI Agents** | `uipath-openai-agents` | `openai_agents.json` | Yes (async gen) | No | Yes (OpenAI) | Yes | +| **LlamaIndex** | `uipath-llamaindex` | `llama_index.json` | Yes (async gen) | Yes | Yes (Bedrock, Vertex) | No | +| **Google ADK** | `uipath-google-adk` | `google_adk.json` | Yes (async gen) | Partial | Yes (OpenAI, Anthropic, Gemini) | Yes | +| **Agent Framework** | `uipath-agent-framework` | `agent_framework.json` | Yes (async gen) | Yes | No | No | + +### Key Patterns by Integration + +| Pattern | LangGraph | OpenAI Agents | Google ADK | LlamaIndex | Agent Framework | +|---|---|---|---|---|---| +| **Message mapper** | Storage-backed, stateful | Dual input (messages + context) | Bidirectional with tool tracking | Event filtering | Lifecycle tracking | +| **Graph building** | Runnable unwrapping via closures | Agent-as-tool closure introspection | Recursive composite agents | Event signature inference | Edge groups + handoff tools | +| **Output extraction** | output_channels | Direct result | output_key → session.state | output_cls | Chat vs typed paths | +| **Session mgmt** | Shared AsyncSqliteSaver | None | SqliteSessionService | JsonPickleSerializer | KV storage namespace | +| **Instrumentation** | LangChainInstrumentor | OpenAIAgentsInstrumentor | GoogleADKInstrumentor | Custom span processor | Via runtime | +| **Custom serialization** | serialize_output() for Overwrite | Standard | Standard | JsonPickleSerializer | Standard | diff --git a/README.md b/README.md index 60c4e54..7c5a65c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This package is typically used as a dependency by higher-level SDKs such as: | [`uipath-llamaindex`](https://github.com/uipath/uipath-integrations-python/tree/main/packages/uipath-llamaindex) | [![Downloads](https://img.shields.io/pypi/dm/uipath-llamaindex?label=)](https://pypi.org/project/uipath-llamaindex/) | [![PyPI](https://img.shields.io/pypi/v/uipath-llamaindex?label=pypi)](https://pypi.org/project/uipath-llamaindex/) | | [`uipath-google-adk`](https://github.com/uipath/uipath-integrations-python/tree/main/packages/uipath-google-adk) | [![Downloads](https://img.shields.io/pypi/dm/uipath-google-adk?label=)](https://pypi.org/project/uipath-google-adk/) | [![PyPI](https://img.shields.io/pypi/v/uipath-google-adk?label=pypi)](https://pypi.org/project/uipath-google-adk/) | | [`uipath-openai-agents`](https://github.com/uipath/uipath-integrations-python/tree/main/packages/uipath-openai-agents) | [![Downloads](https://img.shields.io/pypi/dm/uipath-openai-agents?label=)](https://pypi.org/project/uipath-openai-agents/) | [![PyPI](https://img.shields.io/pypi/v/uipath-openai-agents?label=pypi)](https://pypi.org/project/uipath-openai-agents/) | +| [`uipath-agent-framework`](https://github.com/uipath/uipath-integrations-python/tree/main/packages/uipath-agent-framework) | [![Downloads](https://img.shields.io/pypi/dm/uipath-agent-framework?label=)](https://pypi.org/project/uipath-agent-framework/) | [![PyPI](https://img.shields.io/pypi/v/uipath-agent-framework?label=pypi)](https://pypi.org/project/uipath-agent-framework/) | | [`uipath-mcp`](https://github.com/uipath/uipath-mcp-python) | [![Downloads](https://img.shields.io/pypi/dm/uipath-mcp?label=)](https://pypi.org/project/uipath-mcp/) | [![PyPI](https://img.shields.io/pypi/v/uipath-mcp?label=pypi)](https://pypi.org/project/uipath-mcp/) | You would use this directly only if you're building custom runtime implementations. @@ -413,3 +414,10 @@ async def main() -> None: ``` + +## Building a New Integration + +This repository includes a complete blueprint for building new framework integrations using Claude Code (or any AI coding assistant): + +- **[INTEGRATION_GENOME.md](INTEGRATION_GENOME.md)** — A structured, phase-by-phase specification that guides you through building a full UiPath runtime integration for any Python agentic framework. Covers project scaffolding, config/loader, schema inference, execute/stream, HITL, factory registration, LLM Gateway, and CLI middleware. +- **[CLAUDE.md](CLAUDE.md)** — Project instructions and structure overview for AI-assisted development. diff --git a/src/uipath/runtime/errors/contract.py b/src/uipath/runtime/errors/contract.py index eb84071..fec0bd6 100644 --- a/src/uipath/runtime/errors/contract.py +++ b/src/uipath/runtime/errors/contract.py @@ -18,7 +18,7 @@ class UiPathErrorContract(BaseModel): """Standard error contract used across the runtime.""" code: str # Human-readable code uniquely identifying this error type across the platform. - # Format: . (e.g. LangGraph.InvaliGraphReference) + # Format: . (e.g. LangGraph.InvalidGraphReference) # Only use alphanumeric characters [A-Za-z0-9] and periods. No whitespace allowed. title: str # Short, human-readable summary of the problem that should remain consistent