Skip to content

Commit d590ddc

Browse files
authored
test(uipath-agents): coded HITL / process / RAG / tracing tests (#475)
- skill-agent-coded-hitl-create-task (e2e) — LangGraph agent with interrupt(CreateTask(app_name="ExpenseReview", app_folder_path="Finance", ...)) and a MemorySaver checkpointer. Asserts the imports, the call shape, the checkpointer wiring, and the `app` binding for ExpenseReview/Finance. - skill-agent-coded-process-invoke (e2e) — LangGraph agent with interrupt(InvokeProcess(name="DataScraper", process_folder_path="Workflows", ...)). Asserts the call shape and the `process` binding. - skill-agent-coded-rag-langgraph (e2e) — LangGraph RAG agent with ContextGroundingRetriever(index_name="company_docs", folder_path="Shared") inside a node. Asserts the import path, retriever args, lazy-init, and the `index` binding. - skill-agent-coded-tracing-redaction (e2e) — Simple Function agent whose `main` is decorated with @Traced(name=..., input_processor=..., output_processor=...). Asserts all three kwargs are present and rejects the conflicting hide_input/ hide_output combo.
1 parent d50dac8 commit d590ddc

8 files changed

Lines changed: 729 additions & 0 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env python3
2+
"""HITL coded-agent shape check.
3+
4+
Asserts the primitives that together make
5+
`interrupt(CreateEscalation)` work end-to-end:
6+
7+
1. `main.py` imports `interrupt` from `langgraph.types`.
8+
2. `main.py` imports `CreateEscalation` from
9+
`uipath.platform.common` and references it inside `interrupt(...)`.
10+
The prompt is an explicit escalation — the skill prescribes
11+
`CreateEscalation` for that path (`CreateTask` is the general
12+
form for non-escalation HITL).
13+
3. `bindings.json` declares the `app` resource for the Action
14+
Center app the escalation targets — `app_name=ExpenseReview`,
15+
`app_folder_path=Finance`. Without this binding,
16+
`uipath push` would not create the virtual placeholder.
17+
18+
Also runs the lazy-LLM-init AST scan as a hygiene check.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import ast
24+
import json
25+
import os
26+
import re
27+
import sys
28+
from pathlib import Path
29+
30+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
31+
from _shared.project_root import find_project_root # noqa: E402
32+
33+
ROOT = find_project_root("expense-approver")
34+
35+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
36+
from _shared.bindings_assertions import ( # noqa: E402
37+
load_bindings,
38+
find_resource,
39+
assert_value_field,
40+
assert_metadata_field,
41+
)
42+
from _shared.ast_lazy_init_check import find_module_level_llm_clients # noqa: E402
43+
44+
45+
def _read_text(path: Path) -> str:
46+
if not path.is_file():
47+
sys.exit(f"FAIL: Missing {path}")
48+
return path.read_text(encoding="utf-8")
49+
50+
51+
def find_graph_module() -> Path:
52+
for candidate in ("main.py", "graph.py"):
53+
path = ROOT / candidate
54+
if path.is_file():
55+
return path
56+
sys.exit(f"FAIL: neither main.py nor graph.py found under {ROOT}")
57+
58+
59+
def _module_constants(tree: ast.Module) -> dict[str, object]:
60+
"""Collect module-level `<Name> = <Constant>` assignments.
61+
62+
Resolves the common pattern where the agent extracts string literals
63+
into constants (e.g. ``ACTION_CENTER_APP = "ExpenseReview"``).
64+
"""
65+
consts: dict[str, object] = {}
66+
for node in tree.body:
67+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant):
68+
for tgt in node.targets:
69+
if isinstance(tgt, ast.Name):
70+
consts[tgt.id] = node.value.value
71+
return consts
72+
73+
74+
def _resolve_kwarg(value: ast.expr, consts: dict[str, object]) -> object | None:
75+
if isinstance(value, ast.Constant):
76+
return value.value
77+
if isinstance(value, ast.Name) and value.id in consts:
78+
return consts[value.id]
79+
return None
80+
81+
82+
def _find_create_escalation_call(tree: ast.Module) -> ast.Call | None:
83+
"""Return the inner `CreateEscalation(...)` call wrapped by `interrupt(...)`."""
84+
for node in ast.walk(tree):
85+
if not isinstance(node, ast.Call):
86+
continue
87+
func = node.func
88+
if not (isinstance(func, ast.Name) and func.id == "interrupt"):
89+
continue
90+
if not node.args:
91+
continue
92+
inner = node.args[0]
93+
if not isinstance(inner, ast.Call):
94+
continue
95+
inner_func = inner.func
96+
if isinstance(inner_func, ast.Name) and inner_func.id == "CreateEscalation":
97+
return inner
98+
return None
99+
100+
101+
def check_imports_and_calls(text: str, tree: ast.Module) -> None:
102+
if not re.search(r"from\s+langgraph\.types\s+import\s+[^\n]*\binterrupt\b", text):
103+
sys.exit("FAIL: missing `from langgraph.types import interrupt`")
104+
print("OK: imports `interrupt` from langgraph.types")
105+
if not re.search(r"from\s+uipath\.platform\.common\s+import\s+[^\n]*\bCreateEscalation\b", text):
106+
sys.exit(
107+
"FAIL: missing `from uipath.platform.common import CreateEscalation`. "
108+
"The prompt describes an explicit escalation — the skill prescribes "
109+
"`CreateEscalation` for that path."
110+
)
111+
print("OK: imports CreateEscalation from uipath.platform.common")
112+
call = _find_create_escalation_call(tree)
113+
if call is None:
114+
sys.exit("FAIL: no `interrupt(CreateEscalation(...))` call site found")
115+
print("OK: graph node calls interrupt(CreateEscalation(...))")
116+
consts = _module_constants(tree)
117+
kwargs = {kw.arg: kw.value for kw in call.keywords if kw.arg is not None}
118+
expected = {"app_name": "ExpenseReview", "app_folder_path": "Finance"}
119+
for kw, want in expected.items():
120+
if kw not in kwargs:
121+
sys.exit(f'FAIL: `CreateEscalation(...)` is missing `{kw}=`')
122+
got = _resolve_kwarg(kwargs[kw], consts)
123+
if got != want:
124+
sys.exit(
125+
f'FAIL: `CreateEscalation({kw}=...)` resolves to {got!r}, expected {want!r}.'
126+
)
127+
print('OK: escalation targets app_name="ExpenseReview" / app_folder_path="Finance"')
128+
129+
130+
def check_app_binding() -> None:
131+
doc = load_bindings(ROOT / "bindings.json")
132+
entry = find_resource(doc, resource="app", key="ExpenseReview.Finance")
133+
assert_value_field(entry, field="name", expected="ExpenseReview")
134+
assert_value_field(entry, field="folderPath", expected="Finance")
135+
assert_metadata_field(entry, field="ActivityName", expected="create_async")
136+
assert_metadata_field(entry, field="DisplayLabel", expected="ExpenseReview")
137+
print("OK: bindings.json declares the ExpenseReview/Finance `app` resource")
138+
139+
140+
def main() -> None:
141+
if not ROOT.is_dir():
142+
sys.exit(f"FAIL: project directory {ROOT} does not exist")
143+
module = find_graph_module()
144+
text = _read_text(module)
145+
tree = ast.parse(text, filename=str(module))
146+
check_imports_and_calls(text, tree)
147+
violations = find_module_level_llm_clients(module)
148+
if violations:
149+
sys.exit("FAIL: " + " | ".join(violations))
150+
print("OK: no module-level UiPath* construction")
151+
check_app_binding()
152+
153+
154+
if __name__ == "__main__":
155+
main()
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
task_id: skill-agent-coded-hitl-create-task
2+
description: >
3+
Coded-agent HITL via `interrupt(CreateEscalation(...))`. Verifies
4+
the agent imports `interrupt` from `langgraph.types` and
5+
`CreateEscalation` from `uipath.platform.common`, compiles the
6+
graph with a `MemorySaver` checkpointer, and emits the `app`
7+
binding for the Action Center app the escalation targets.
8+
tags: [uipath-agents, e2e, coded, lifecycle:generate, feature:hitl-coded]
9+
max_iterations: 1
10+
11+
agent:
12+
type: claude-code
13+
permission_mode: acceptEdits
14+
allowed_tools: ["Skill", "Bash", "Read", "Write", "Edit", "Glob", "Grep"]
15+
turn_timeout: 1200
16+
17+
sandbox:
18+
driver: tempdir
19+
python: {}
20+
21+
initial_prompt: |
22+
Build a LangGraph UiPath coded agent named `expense-approver` that
23+
escalates expense-reimbursement requests over `$1000` to a human
24+
reviewer in Action Center. Below the threshold, the agent
25+
auto-approves.
26+
27+
The escalation should target an Action Center app named
28+
`ExpenseReview` in folder `Finance`, passing the full request
29+
payload to the reviewer. The graph must support pause/resume so
30+
the escalation can wait for the human response.
31+
32+
Input: `amount` (number), `description` (string), `submitter`
33+
(string).
34+
Output: `approved` (bool), `reviewer_decision` (string|null).
35+
36+
Sync `bindings.json` so the Action Center resource is declared.
37+
38+
Take the agent through scaffold → init. Do NOT run, publish,
39+
upload, or deploy. Do NOT call `uip login`. Do NOT pause between
40+
planning and implementation. Complete end-to-end in a single pass.
41+
42+
success_criteria:
43+
- type: command_executed
44+
description: "Agent scaffolded the project with uip codedagent new"
45+
tool_name: "Bash"
46+
command_pattern: 'uip\s+codedagent\s+new'
47+
min_count: 1
48+
weight: 1.0
49+
pass_threshold: 1.0
50+
51+
- type: command_executed
52+
description: "Agent ran uip codedagent init"
53+
tool_name: "Bash"
54+
command_pattern: 'uip\s+codedagent\s+init'
55+
min_count: 1
56+
weight: 1.5
57+
pass_threshold: 1.0
58+
59+
- type: run_command
60+
description: "interrupt(CreateEscalation) shape, MemorySaver checkpointer, app binding"
61+
command: "python3 $TASK_DIR/check_hitl_create_task.py"
62+
timeout: 30
63+
expected_exit_code: 0
64+
weight: 6.0
65+
pass_threshold: 1.0
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env python3
2+
"""Process-invocation coded-agent shape check.
3+
4+
Asserts:
5+
1. ``main.py`` imports ``interrupt`` from ``langgraph.types`` and
6+
``InvokeProcess`` from ``uipath.platform.common``.
7+
2. A graph node calls ``interrupt(InvokeProcess(...))`` whose
8+
``name=`` and ``process_folder_path=`` resolve (through any
9+
module-level constant the agent extracted) to ``DataScraper``
10+
and ``Workflows``.
11+
3. ``bindings.json`` declares the ``process`` resource for
12+
DataScraper / Workflows with ``ActivityName=invoke_async``.
13+
4. No module-level UiPath* construction.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import ast
19+
import os
20+
import re
21+
import sys
22+
from pathlib import Path
23+
24+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
25+
from _shared.project_root import find_project_root # noqa: E402
26+
27+
ROOT = find_project_root("data-orchestrator")
28+
29+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
30+
from _shared.bindings_assertions import ( # noqa: E402
31+
load_bindings,
32+
find_resource,
33+
assert_value_field,
34+
assert_metadata_field,
35+
)
36+
from _shared.ast_lazy_init_check import find_module_level_llm_clients # noqa: E402
37+
38+
39+
def _read_text(path: Path) -> str:
40+
if not path.is_file():
41+
sys.exit(f"FAIL: Missing {path}")
42+
return path.read_text(encoding="utf-8")
43+
44+
45+
def find_graph_module() -> Path:
46+
for candidate in ("main.py", "graph.py"):
47+
path = ROOT / candidate
48+
if path.is_file():
49+
return path
50+
sys.exit(f"FAIL: neither main.py nor graph.py found under {ROOT}")
51+
52+
53+
def _module_constants(tree: ast.Module) -> dict[str, object]:
54+
consts: dict[str, object] = {}
55+
for node in tree.body:
56+
if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant):
57+
for tgt in node.targets:
58+
if isinstance(tgt, ast.Name):
59+
consts[tgt.id] = node.value.value
60+
return consts
61+
62+
63+
def _resolve(value: ast.expr, consts: dict[str, object]) -> object | None:
64+
if isinstance(value, ast.Constant):
65+
return value.value
66+
if isinstance(value, ast.Name) and value.id in consts:
67+
return consts[value.id]
68+
return None
69+
70+
71+
def _kwarg(call: ast.Call, name: str) -> ast.expr | None:
72+
for kw in call.keywords:
73+
if kw.arg == name:
74+
return kw.value
75+
return None
76+
77+
78+
def _find_invoke_process_call(tree: ast.Module) -> ast.Call | None:
79+
"""Return the inner ``InvokeProcess(...)`` call wrapped by ``interrupt(...)``."""
80+
for node in ast.walk(tree):
81+
if not isinstance(node, ast.Call):
82+
continue
83+
func = node.func
84+
if not (isinstance(func, ast.Name) and func.id == "interrupt"):
85+
continue
86+
if not node.args:
87+
continue
88+
inner = node.args[0]
89+
if not isinstance(inner, ast.Call):
90+
continue
91+
inner_func = inner.func
92+
if isinstance(inner_func, ast.Name) and inner_func.id == "InvokeProcess":
93+
return inner
94+
return None
95+
96+
97+
def check_invocation(text: str, tree: ast.Module) -> None:
98+
if not re.search(r"from\s+langgraph\.types\s+import\s+[^\n]*\binterrupt\b", text):
99+
sys.exit("FAIL: missing `from langgraph.types import interrupt`")
100+
if not re.search(
101+
r"from\s+uipath\.platform\.common\s+import\s+[^\n]*\bInvokeProcess\b",
102+
text,
103+
):
104+
sys.exit("FAIL: missing `from uipath.platform.common import InvokeProcess`")
105+
106+
call = _find_invoke_process_call(tree)
107+
if call is None:
108+
sys.exit("FAIL: no `interrupt(InvokeProcess(...))` call site found")
109+
110+
consts = _module_constants(tree)
111+
expected = {"name": "DataScraper", "process_folder_path": "Workflows"}
112+
for kw, want in expected.items():
113+
node = _kwarg(call, kw)
114+
if node is None:
115+
sys.exit(f"FAIL: `InvokeProcess(...)` is missing `{kw}=`")
116+
got = _resolve(node, consts)
117+
if got != want:
118+
sys.exit(
119+
f"FAIL: `InvokeProcess({kw}=...)` resolves to {got!r}, expected {want!r}."
120+
)
121+
print(
122+
'OK: graph node calls `interrupt(InvokeProcess(name="DataScraper", '
123+
'process_folder_path="Workflows", ...))`'
124+
)
125+
126+
127+
def check_process_binding() -> None:
128+
doc = load_bindings(ROOT / "bindings.json")
129+
entry = find_resource(doc, resource="process", key="DataScraper.Workflows")
130+
assert_value_field(entry, field="name", expected="DataScraper")
131+
assert_value_field(entry, field="folderPath", expected="Workflows")
132+
assert_metadata_field(entry, field="ActivityName", expected="invoke_async")
133+
print("OK: bindings.json declares the DataScraper/Workflows `process` resource")
134+
135+
136+
def main() -> None:
137+
if not ROOT.is_dir():
138+
sys.exit(f"FAIL: project directory {ROOT} does not exist")
139+
module = find_graph_module()
140+
text = _read_text(module)
141+
tree = ast.parse(text, filename=str(module))
142+
check_invocation(text, tree)
143+
violations = find_module_level_llm_clients(module)
144+
if violations:
145+
sys.exit("FAIL: " + " | ".join(violations))
146+
print("OK: no module-level UiPath* construction")
147+
check_process_binding()
148+
149+
150+
if __name__ == "__main__":
151+
main()

0 commit comments

Comments
 (0)