Skip to content

Commit fdb86c3

Browse files
feat: add hitl decorator
1 parent 384ac13 commit fdb86c3

6 files changed

Lines changed: 204 additions & 10 deletions

File tree

packages/uipath-llamaindex/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-llamaindex"
3-
version = "0.5.6"
3+
version = "0.5.7"
44
description = "Python SDK that enables developers to build and deploy LlamaIndex agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath-llamaindex/samples/chat-agent/main.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import os
2+
from typing import List, Optional
23

3-
from llama_index.core.agent.workflow import FunctionAgent, AgentWorkflow
4+
from llama_index.core.agent.workflow import AgentWorkflow
5+
from llama_index.core.schema import Document
46
from llama_index.llms.openai import OpenAI
57
from llama_index.tools.tavily_research import TavilyToolSpec
68

9+
from uipath_llamaindex.chat.tools import requires_approval
10+
711
llm = OpenAI(model="gpt-4o-mini")
812
tavily_tool = TavilyToolSpec(api_key=os.environ["TAVILY_API_KEY"])
913

@@ -24,10 +28,28 @@
2428
"Remember previous messages and maintain context across the discussion."
2529
)
2630

31+
@requires_approval
32+
def search(query: str, max_results: Optional[int] = 6) -> List[Document]:
33+
"""
34+
Run query through Tavily Search and return metadata.
35+
36+
Args:
37+
query: The query to search for.
38+
max_results: The maximum number of results to return.
39+
40+
Returns:
41+
results: A list of dictionaries containing the results:
42+
url: The url of the result.
43+
content: The content of the result.
44+
45+
"""
46+
return tavily_tool.search(query, max_results=max_results)
47+
48+
2749
agent = AgentWorkflow.from_tools_or_functions(
28-
tools_or_functions=tavily_tool.to_tool_list(),
50+
tools_or_functions=[search],
2951
llm=llm,
30-
system_prompt=SYSTEM_PROMPT
52+
system_prompt=SYSTEM_PROMPT,
3153
)
3254

3355
async def chat(user_input: str) -> str:

packages/uipath-llamaindex/samples/chat-agent/pyproject.toml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ version = "0.0.1"
44
description = "chat-agent"
55
authors = [{ name = "John Doe", email = "john.doe@myemail.com" }]
66
dependencies = [
7-
"uipath-llamaindex>=0.5.0, <0.6.0",
7+
"uipath-llamaindex==0.5.6.dev1001950928",
88
"llama-index-llms-openai>=0.6.10",
99
"llama-index-tools-tavily-research>=0.4.2",
1010
"llama-index-llms-openai>=0.6.18"
@@ -16,3 +16,12 @@ requires-python = ">=3.11"
1616
dev = [
1717
"uipath-dev>=0.0.19",
1818
]
19+
20+
[[tool.uv.index]]
21+
name = "testpypi"
22+
url = "https://test.pypi.org/simple/"
23+
publish-url = "https://test.pypi.org/legacy/"
24+
explicit = true
25+
26+
[tool.uv.sources]
27+
uipath-llamaindex = { index = "testpypi" }
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from uipath_llamaindex.chat.tools import requires_approval
2+
3+
__all__ = ["requires_approval"]
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""Human-in-the-loop (HITL) support for LlamaIndex tools.
2+
3+
Provides the ``requires_approval`` decorator for marking tool functions
4+
that need human approval before execution.
5+
6+
Example::
7+
8+
from uipath_llamaindex.chat.tools import requires_approval
9+
10+
@requires_approval
11+
def transfer_funds(from_account: str, to_account: str, amount: float) -> str:
12+
\"\"\"Transfer funds between accounts.\"\"\"
13+
return f"Transferred ${amount:.2f} from {from_account} to {to_account}"
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import functools
19+
import inspect
20+
import json
21+
from collections.abc import Callable
22+
from typing import Any
23+
from uuid import uuid4
24+
25+
from llama_index.core.tools import FunctionTool
26+
from llama_index.core.tools.utils import create_schema_from_function
27+
from llama_index.core.workflow import Context
28+
from uipath.core.chat import UiPathConversationToolCallConfirmationValue
29+
from workflows.events import HumanResponseEvent, InputRequiredEvent
30+
31+
_CANCELLED_MESSAGE = "Cancelled by user"
32+
33+
34+
def _is_context_param(annotation: Any) -> bool:
35+
from typing import get_origin
36+
37+
return annotation is Context or get_origin(annotation) is Context
38+
39+
40+
def requires_approval(
41+
func: Callable[..., Any] | None = None,
42+
) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]:
43+
"""Decorator that marks a tool function as requiring human approval.
44+
45+
When the agent calls a tool decorated with ``@requires_approval``,
46+
execution suspends and waits for a human to approve, edit, or reject
47+
the tool call before proceeding.
48+
49+
Can be used with or without parentheses::
50+
51+
@requires_approval
52+
def my_tool(arg: str) -> str: ...
53+
54+
@requires_approval()
55+
def my_tool(arg: str) -> str: ...
56+
57+
Args:
58+
func: The tool function to wrap. If None, returns a decorator.
59+
60+
Returns:
61+
A FunctionTool that suspends for human approval before executing.
62+
"""
63+
64+
def decorator(fn: Callable[..., Any]) -> FunctionTool:
65+
is_async = inspect.iscoroutinefunction(fn)
66+
67+
# Determine if the original function has a ctx parameter
68+
original_sig = inspect.signature(fn)
69+
ctx_param_name = next(
70+
(
71+
p.name
72+
for p in original_sig.parameters.values()
73+
if _is_context_param(p.annotation)
74+
),
75+
None,
76+
)
77+
78+
# Build the schema from the original function (excluding ctx if present)
79+
ignore = [ctx_param_name] if ctx_param_name else []
80+
fn_schema = create_schema_from_function(fn.__name__, fn, ignore_fields=ignore)
81+
82+
@functools.wraps(fn)
83+
async def wrapper(ctx: Context, **kwargs: Any) -> Any:
84+
tool_call_id = str(uuid4())
85+
input_schema = fn_schema.model_json_schema() if fn_schema else {}
86+
87+
confirmation = UiPathConversationToolCallConfirmationValue(
88+
tool_call_id=tool_call_id,
89+
tool_name=fn.__name__,
90+
input_schema=input_schema,
91+
input_value=kwargs,
92+
)
93+
interrupt_payload = json.dumps(
94+
{
95+
"type": "uipath_cas_tool_call_confirmation",
96+
"value": confirmation.model_dump(by_alias=True),
97+
}
98+
)
99+
100+
response: HumanResponseEvent = await ctx.wait_for_event(
101+
HumanResponseEvent,
102+
waiter_id=f"approval_{tool_call_id}",
103+
waiter_event=InputRequiredEvent(prefix=interrupt_payload),
104+
timeout=None,
105+
)
106+
107+
# Parse resume payload:
108+
# {"type": "uipath_cas_tool_call_confirmation", "value": {"approved": bool, "input": ...}}
109+
response_data: Any = response.response
110+
if isinstance(response_data, str):
111+
try:
112+
response_data = json.loads(response_data)
113+
except json.JSONDecodeError:
114+
pass
115+
116+
if isinstance(response_data, dict):
117+
end_value = response_data.get("value", response_data)
118+
if not end_value.get("approved", True):
119+
return _CANCELLED_MESSAGE
120+
approved_kwargs: dict[str, Any] = end_value.get("input") or kwargs
121+
else:
122+
approved_kwargs = kwargs
123+
124+
if ctx_param_name:
125+
approved_kwargs[ctx_param_name] = ctx
126+
127+
if is_async:
128+
return await fn(**approved_kwargs)
129+
return fn(**approved_kwargs)
130+
131+
return FunctionTool.from_defaults(
132+
async_fn=wrapper,
133+
name=fn.__name__,
134+
description=fn.__doc__ or "",
135+
fn_schema=fn_schema,
136+
)
137+
138+
if func is not None:
139+
return decorator(func)
140+
return decorator
141+
142+
143+
__all__ = ["requires_approval"]

packages/uipath-llamaindex/uv.lock

Lines changed: 22 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)