Skip to content

Commit 6589850

Browse files
Merge pull request #384 from UiPath/feat/observability-metadata
feat: add observability metadata to tools and guardrails
2 parents 000d61b + 5574928 commit 6589850

File tree

8 files changed

+260
-3
lines changed

8 files changed

+260
-3
lines changed

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-langchain"
3-
version = "0.2.3"
3+
version = "0.2.4"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath_langchain/agent/guardrails/guardrail_nodes.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,11 @@ def _create_guardrail_node(
118118
| None = None,
119119
output_data_extractor: Callable[[AgentGuardrailsGraphState], dict[str, Any]]
120120
| None = None,
121+
tool_name: str | None = None,
121122
) -> tuple[str, Callable[[AgentGuardrailsGraphState], Any]]:
122123
"""Private factory for guardrail evaluation nodes.
123124
124-
Returns a node that evaluates the guardrail and routes via Command:
125+
Returns a node with observability metadata attached as __metadata__ attribute:
125126
- goto success_node on validation pass
126127
- goto failure_node on validation fail
127128
"""
@@ -177,6 +178,15 @@ async def node(
177178
)
178179
raise
179180

181+
# Attach observability metadata as function attribute
182+
node.__metadata__ = { # type: ignore[attr-defined]
183+
"guardrail_name": guardrail.name,
184+
"guardrail_description": getattr(guardrail, "description", None),
185+
"guardrail_scope": scope.value,
186+
"guardrail_stage": execution_stage.value,
187+
"tool_name": tool_name,
188+
}
189+
180190
return node_name, node
181191

182192

@@ -310,4 +320,5 @@ def _output_data_extractor(state: AgentGuardrailsGraphState) -> dict[str, Any]:
310320
failure_node,
311321
_input_data_extractor,
312322
_output_data_extractor,
323+
tool_name,
313324
)

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ async def escalation_tool_fn(
108108
description=resource.description,
109109
args_schema=input_model,
110110
coroutine=escalation_tool_fn,
111+
metadata={
112+
"tool_type": "escalation",
113+
"display_name": channel.properties.app_name,
114+
"channel_type": channel.type,
115+
"assignee": assignee,
116+
},
111117
)
112118

113119
return tool

src/uipath_langchain/agent/tools/process_tool.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ async def process_tool_fn(**kwargs: Any):
4646
args_schema=input_model,
4747
coroutine=process_tool_fn,
4848
output_type=output_model,
49+
metadata={
50+
"tool_type": "process",
51+
"display_name": process_name,
52+
"folder_path": folder_path,
53+
},
4954
)
5055

5156
return tool

tests/agent/guardrails/test_guardrail_nodes.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,3 +509,78 @@ async def test_unsupported_guardrail_type_raises_error(self):
509509
assert "MagicMock" in error_message
510510
assert "DeterministicGuardrail" in error_message
511511
assert "BuiltInValidatorGuardrail" in error_message
512+
513+
514+
class TestGuardrailNodeMetadata:
515+
"""Tests for guardrail node __metadata__ attribute for observability."""
516+
517+
def test_llm_guardrail_node_has_metadata(self):
518+
"""Test that LLM guardrail node has __metadata__ attribute."""
519+
guardrail = MagicMock(spec=BuiltInValidatorGuardrail)
520+
guardrail.name = "TestGuardrail"
521+
guardrail.description = "Test description"
522+
523+
_, node = create_llm_guardrail_node(
524+
guardrail=guardrail,
525+
execution_stage=ExecutionStage.PRE_EXECUTION,
526+
success_node="ok",
527+
failure_node="nope",
528+
)
529+
530+
assert hasattr(node, "__metadata__")
531+
assert isinstance(node.__metadata__, dict)
532+
533+
def test_llm_guardrail_node_metadata_fields(self):
534+
"""Test that LLM guardrail node has correct metadata fields."""
535+
guardrail = MagicMock(spec=BuiltInValidatorGuardrail)
536+
guardrail.name = "TestGuardrail"
537+
guardrail.description = "Test description"
538+
539+
_, node = create_llm_guardrail_node(
540+
guardrail=guardrail,
541+
execution_stage=ExecutionStage.PRE_EXECUTION,
542+
success_node="ok",
543+
failure_node="nope",
544+
)
545+
546+
metadata = getattr(node, "__metadata__", None)
547+
assert metadata is not None
548+
assert metadata["guardrail_name"] == "TestGuardrail"
549+
assert metadata["guardrail_scope"] == "Llm"
550+
assert metadata["guardrail_stage"] == "preExecution"
551+
assert metadata["tool_name"] is None
552+
553+
def test_tool_guardrail_node_has_tool_name(self):
554+
"""Test that TOOL scope guardrail has tool_name in metadata."""
555+
guardrail = MagicMock(spec=BuiltInValidatorGuardrail)
556+
guardrail.name = "TestGuardrail"
557+
558+
_, node = create_tool_guardrail_node(
559+
guardrail=guardrail,
560+
execution_stage=ExecutionStage.PRE_EXECUTION,
561+
success_node="ok",
562+
failure_node="nope",
563+
tool_name="my_tool",
564+
)
565+
566+
metadata = getattr(node, "__metadata__", None)
567+
assert metadata is not None
568+
assert metadata["guardrail_scope"] == "Tool"
569+
assert metadata["tool_name"] == "my_tool"
570+
571+
def test_agent_init_guardrail_node_metadata(self):
572+
"""Test that AGENT init guardrail has correct scope in metadata."""
573+
guardrail = MagicMock(spec=BuiltInValidatorGuardrail)
574+
guardrail.name = "TestGuardrail"
575+
576+
_, node = create_agent_init_guardrail_node(
577+
guardrail=guardrail,
578+
execution_stage=ExecutionStage.POST_EXECUTION,
579+
success_node="ok",
580+
failure_node="nope",
581+
)
582+
583+
metadata = getattr(node, "__metadata__", None)
584+
assert metadata is not None
585+
assert metadata["guardrail_scope"] == "Agent"
586+
assert metadata["guardrail_stage"] == "postExecution"
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Tests for escalation_tool.py metadata."""
2+
3+
import pytest
4+
from uipath.agent.models.agent import (
5+
AgentEscalationChannel,
6+
AgentEscalationChannelProperties,
7+
AgentEscalationRecipientType,
8+
AgentEscalationResourceConfig,
9+
StandardRecipient,
10+
)
11+
12+
from uipath_langchain.agent.tools.escalation_tool import create_escalation_tool
13+
14+
15+
class TestEscalationToolMetadata:
16+
"""Test that escalation tool has correct metadata for observability."""
17+
18+
@pytest.fixture
19+
def escalation_resource(self):
20+
"""Create a minimal escalation tool resource config."""
21+
return AgentEscalationResourceConfig(
22+
name="approval",
23+
description="Request approval",
24+
channels=[
25+
AgentEscalationChannel(
26+
name="action_center",
27+
type="actionCenter",
28+
description="Action Center channel",
29+
input_schema={"type": "object", "properties": {}},
30+
output_schema={"type": "object", "properties": {}},
31+
properties=AgentEscalationChannelProperties(
32+
app_name="ApprovalApp",
33+
app_version=1,
34+
resource_key="test-key",
35+
),
36+
recipients=[
37+
StandardRecipient(
38+
type=AgentEscalationRecipientType.USER_EMAIL,
39+
value="user@example.com",
40+
)
41+
],
42+
)
43+
],
44+
)
45+
46+
@pytest.fixture
47+
def escalation_resource_no_recipient(self):
48+
"""Create escalation resource without recipients."""
49+
return AgentEscalationResourceConfig(
50+
name="approval",
51+
description="Request approval",
52+
channels=[
53+
AgentEscalationChannel(
54+
name="action_center",
55+
type="actionCenter",
56+
description="Action Center channel",
57+
input_schema={"type": "object", "properties": {}},
58+
output_schema={"type": "object", "properties": {}},
59+
properties=AgentEscalationChannelProperties(
60+
app_name="ApprovalApp",
61+
app_version=1,
62+
resource_key="test-key",
63+
),
64+
recipients=[],
65+
)
66+
],
67+
)
68+
69+
def test_escalation_tool_has_metadata(self, escalation_resource):
70+
"""Test that escalation tool has metadata dict."""
71+
tool = create_escalation_tool(escalation_resource)
72+
73+
assert tool.metadata is not None
74+
assert isinstance(tool.metadata, dict)
75+
76+
def test_escalation_tool_metadata_has_tool_type(self, escalation_resource):
77+
"""Test that metadata contains tool_type for span detection."""
78+
tool = create_escalation_tool(escalation_resource)
79+
assert tool.metadata is not None
80+
assert tool.metadata["tool_type"] == "escalation"
81+
82+
def test_escalation_tool_metadata_has_display_name(self, escalation_resource):
83+
"""Test that metadata contains display_name from app_name."""
84+
tool = create_escalation_tool(escalation_resource)
85+
assert tool.metadata is not None
86+
assert tool.metadata["display_name"] == "ApprovalApp"
87+
88+
def test_escalation_tool_metadata_has_channel_type(self, escalation_resource):
89+
"""Test that metadata contains channel_type for span attributes."""
90+
tool = create_escalation_tool(escalation_resource)
91+
assert tool.metadata is not None
92+
assert tool.metadata["channel_type"] == "actionCenter"
93+
94+
def test_escalation_tool_metadata_has_assignee(self, escalation_resource):
95+
"""Test that metadata contains assignee when recipient is USER_EMAIL."""
96+
tool = create_escalation_tool(escalation_resource)
97+
assert tool.metadata is not None
98+
assert tool.metadata["assignee"] == "user@example.com"
99+
100+
def test_escalation_tool_metadata_assignee_none_when_no_recipients(
101+
self, escalation_resource_no_recipient
102+
):
103+
"""Test that assignee is None when no recipients configured."""
104+
tool = create_escalation_tool(escalation_resource_no_recipient)
105+
assert tool.metadata is not None
106+
assert tool.metadata["assignee"] is None
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Tests for process_tool.py metadata."""
2+
3+
import pytest
4+
from uipath.agent.models.agent import (
5+
AgentProcessToolProperties,
6+
AgentProcessToolResourceConfig,
7+
AgentToolType,
8+
)
9+
10+
from uipath_langchain.agent.tools.process_tool import create_process_tool
11+
12+
13+
class TestProcessToolMetadata:
14+
"""Test that process tool has correct metadata for observability."""
15+
16+
@pytest.fixture
17+
def process_resource(self):
18+
"""Create a minimal process tool resource config."""
19+
return AgentProcessToolResourceConfig(
20+
type=AgentToolType.PROCESS,
21+
name="test_process",
22+
description="Test process description",
23+
input_schema={"type": "object", "properties": {}},
24+
output_schema={"type": "object", "properties": {}},
25+
properties=AgentProcessToolProperties(
26+
process_name="MyProcess",
27+
folder_path="/Shared/MyFolder",
28+
),
29+
)
30+
31+
def test_process_tool_has_metadata(self, process_resource):
32+
"""Test that process tool has metadata dict."""
33+
tool = create_process_tool(process_resource)
34+
35+
assert tool.metadata is not None
36+
assert isinstance(tool.metadata, dict)
37+
38+
def test_process_tool_metadata_has_tool_type(self, process_resource):
39+
"""Test that metadata contains tool_type for span detection."""
40+
tool = create_process_tool(process_resource)
41+
assert tool.metadata is not None
42+
assert tool.metadata["tool_type"] == "process"
43+
44+
def test_process_tool_metadata_has_display_name(self, process_resource):
45+
"""Test that metadata contains display_name from process_name."""
46+
tool = create_process_tool(process_resource)
47+
assert tool.metadata is not None
48+
assert tool.metadata["display_name"] == "MyProcess"
49+
50+
def test_process_tool_metadata_has_folder_path(self, process_resource):
51+
"""Test that metadata contains folder_path for span attributes."""
52+
tool = create_process_tool(process_resource)
53+
assert tool.metadata is not None
54+
assert tool.metadata["folder_path"] == "/Shared/MyFolder"

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)