Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[project]
name = "uipath-langchain"
version = "0.4.14"
version = "0.4.15"
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
dependencies = [
"uipath>=2.5.15,<2.6.0",
"uipath>=2.5.17,<2.6.0",
"uipath-runtime>=0.5.1,<0.6.0",
"langgraph>=1.0.0, <2.0.0",
"langchain-core>=1.2.5, <2.0.0",
Expand Down
40 changes: 33 additions & 7 deletions src/uipath_langchain/agent/guardrails/actions/escalate_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ async def _node(
data: Dict[str, Any] = {
"GuardrailName": guardrail.name,
"GuardrailDescription": guardrail.description,
"Component": scope.name.lower(),
"Component": _build_component_name(scope, guarded_component_name),
"ExecutionStage": _execution_stage_to_string(execution_stage),
"GuardrailResult": state.inner_state.guardrail_validation_result,
}
Expand Down Expand Up @@ -331,10 +331,16 @@ def _process_llm_escalation_response(
if not reviewed_outputs_json:
return {}

reviewed_tool_calls_list = json.loads(reviewed_outputs_json)
if not reviewed_tool_calls_list:
reviewed_tool_calls_obj = json.loads(reviewed_outputs_json)
if not reviewed_tool_calls_obj:
return {}

reviewed_tool_calls_list = (
reviewed_tool_calls_obj.get("tool_calls")
if "tool_calls" in reviewed_tool_calls_obj
else None
)

# Track if tool calls were successfully processed
tool_calls_processed = False

Expand Down Expand Up @@ -534,7 +540,7 @@ def _extract_agent_escalation_content(
- POST_EXECUTION: a JSON-serialized representation of `state.agent_result`.
"""
if execution_stage == ExecutionStage.PRE_EXECUTION:
return get_message_content(cast(AnyMessage, message))
return json.dumps(get_message_content(cast(AnyMessage, message)))

output_content = state.inner_state.agent_result or ""
return json.dumps(output_content)
Expand All @@ -558,7 +564,7 @@ def _extract_llm_escalation_content(
if isinstance(message, ToolMessage):
return message.content

return get_message_content(cast(AnyMessage, message))
return json.dumps(get_message_content(cast(AnyMessage, message)))

# For AI messages, process tool calls if present
if isinstance(message, AIMessage):
Expand All @@ -572,10 +578,11 @@ def _extract_llm_escalation_content(
"args": tool_call.get("args"),
}
content_list.append(tool_call_data)
return json.dumps(content_list)
tool_calls_obj = {"tool_calls": content_list}
return json.dumps(tool_calls_obj)

# Fallback for other message types
return get_message_content(cast(AnyMessage, message))
return json.dumps(get_message_content(cast(AnyMessage, message)))


def _extract_tool_escalation_content(
Expand Down Expand Up @@ -634,6 +641,25 @@ def _execution_stage_to_escalation_field(
return "Inputs" if execution_stage == ExecutionStage.PRE_EXECUTION else "Outputs"


def _build_component_name(scope: GuardrailScope, guarded_component_name: str) -> str:
"""Build component name based on guardrail scope and guarded component name.

Args:
scope: The guardrail scope (LLM/AGENT/TOOL).
guarded_component_name: Name of the guarded component.

Returns:
"Agent" for AGENT scope, "LLM call" for LLM scope, or guarded_component_name for TOOL scope.
"""
match scope:
case GuardrailScope.AGENT:
return "Agent"
case GuardrailScope.LLM:
return "LLM call"
case GuardrailScope.TOOL:
return guarded_component_name


def _execution_stage_to_string(
execution_stage: ExecutionStage,
) -> Literal["PreExecution", "PostExecution"]:
Expand Down
4 changes: 3 additions & 1 deletion src/uipath_langchain/agent/guardrails/guardrail_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ def _create_validation_command(
if guardrail_result.result == GuardrailValidationResultType.PASSED:
return Command(
goto=success_node,
update={"inner_state": {"guardrail_validation_result": None}},
update={
"inner_state": {"guardrail_validation_result": guardrail_result.reason}
},
)

if guardrail_result.result == GuardrailValidationResultType.VALIDATION_FAILED:
Expand Down
75 changes: 75 additions & 0 deletions src/uipath_langchain/agent/guardrails/guardrails_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,72 @@ def _create_word_rule_func(
raise ValueError(f"Unsupported word operator: {operator}")


def _build_field_selector_description(field_selector: AgentFieldSelector) -> str:
"""Build a human-readable selector description for field selector.

Args:
field_selector: The field selector describing which fields this rule applies to.

Returns:
A string describing the selector, using:
- \"All\" for `AgentAllFieldsSelector`
- Comma-separated field paths for `SpecificFieldsSelector`
- ``str(field_selector)`` as a fallback.
"""
if isinstance(field_selector, AgentAllFieldsSelector):
return "All fields"
if isinstance(field_selector, SpecificFieldsSelector):
field_paths = [field.path for field in field_selector.fields]
return ", ".join(field_paths)
return str(field_selector)


def _build_rule_description(
operator: AgentWordOperator | AgentNumberOperator | AgentBooleanOperator,
value: str | float | bool | None,
field_selector: AgentFieldSelector,
) -> str:
"""Build the full human-readable description for a word rule.

Args:
operator: The word operator to describe.
value: The comparison value, if applicable for the operator.
field_selector: The field selector describing which fields this rule applies to.

Returns:
A string describing the rule, combining selector, operator, and value.
"""
selector_description = _build_field_selector_description(field_selector)

if operator in {
AgentWordOperator.CONTAINS,
AgentWordOperator.DOES_NOT_CONTAIN,
AgentWordOperator.EQUALS,
AgentWordOperator.DOES_NOT_EQUAL,
AgentWordOperator.STARTS_WITH,
AgentWordOperator.DOES_NOT_START_WITH,
AgentWordOperator.ENDS_WITH,
AgentWordOperator.DOES_NOT_END_WITH,
AgentWordOperator.MATCHES_REGEX,
AgentNumberOperator.EQUALS,
AgentNumberOperator.DOES_NOT_EQUAL,
AgentNumberOperator.GREATER_THAN,
AgentNumberOperator.GREATER_THAN_OR_EQUAL,
AgentNumberOperator.LESS_THAN,
AgentNumberOperator.LESS_THAN_OR_EQUAL,
AgentBooleanOperator.EQUALS,
}:
return f"{selector_description} {operator.value} {value!r}"

if operator in {
AgentWordOperator.IS_EMPTY,
AgentWordOperator.IS_NOT_EMPTY,
}:
return f"{selector_description} {operator.value}"

raise ValueError(f"Unsupported word operator: {operator}")


def _create_number_rule_func(
operator: AgentNumberOperator, value: float
) -> Callable[[float], bool]:
Expand Down Expand Up @@ -314,6 +380,9 @@ def _convert_agent_rule_to_deterministic(
detects_violation=_create_word_rule_func(
agent_rule.operator, agent_rule.value
),
rule_description=_build_rule_description(
agent_rule.operator, agent_rule.value, agent_rule.field_selector
),
)

if isinstance(agent_rule, AgentNumberRule):
Expand All @@ -325,6 +394,9 @@ def _convert_agent_rule_to_deterministic(
detects_violation=_create_number_rule_func(
agent_rule.operator, agent_rule.value
),
rule_description=_build_rule_description(
agent_rule.operator, agent_rule.value, agent_rule.field_selector
),
)

if isinstance(agent_rule, AgentBooleanRule):
Expand All @@ -336,6 +408,9 @@ def _convert_agent_rule_to_deterministic(
detects_violation=_create_boolean_rule_func(
agent_rule.operator, agent_rule.value
),
rule_description=_build_rule_description(
agent_rule.operator, agent_rule.value, agent_rule.field_selector
),
)

raise ValueError(f"Unsupported agent rule type: {type(agent_rule)}")
Expand Down
36 changes: 20 additions & 16 deletions tests/agent/guardrails/actions/test_escalate_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,11 @@ async def test_node_interrupts_with_correct_message_data(
assert call_args.data["GuardrailResult"] == "Validation failed"

if stage == ExecutionStage.PRE_EXECUTION:
assert call_args.data["Inputs"] == "Test message"
assert call_args.data["Inputs"] == '"Test message"'
assert "Outputs" not in call_args.data
else:
assert call_args.data["Inputs"] == "Test message"
assert call_args.data["Outputs"] == "Output message"
assert call_args.data["Inputs"] == '"Test message"'
assert call_args.data["Outputs"] == '"Output message"'

@pytest.mark.asyncio
@patch("uipath_langchain.agent.guardrails.actions.escalate_action.interrupt")
Expand Down Expand Up @@ -222,7 +222,7 @@ async def test_node_post_agent_interrupts_with_correct_agent_result_data(
assert call_args.data["ExecutionStage"] == "PostExecution"
assert call_args.data["GuardrailResult"] == "Validation failed"

assert call_args.data["Inputs"] == "User prompt message"
assert call_args.data["Inputs"] == '"User prompt message"'
assert call_args.data["Outputs"] == '{"ok": true}'

@pytest.mark.asyncio
Expand Down Expand Up @@ -489,12 +489,13 @@ async def test_post_execution_ai_message_with_tool_calls_extraction(

# Verify interrupt was called with tool calls (name and args) in Outputs and Inputs
call_args = mock_interrupt.call_args[0][0]
assert call_args.data["Inputs"] == "Input message"
assert call_args.data["Inputs"] == '"Input message"'
tool_outputs = call_args.data["Outputs"]
parsed = json.loads(tool_outputs)
assert len(parsed) == 1 # Tool call data with name and args
assert parsed[0]["name"] == "test_tool"
assert parsed[0]["args"] == {"content": {"input": "test"}}
parsed_obj = json.loads(tool_outputs)
parsed_list = parsed_obj["tool_calls"]
assert len(parsed_list) == 1 # Tool call data with name and args
assert parsed_list[0]["name"] == "test_tool"
assert parsed_list[0]["args"] == {"content": {"input": "test"}}

@pytest.mark.asyncio
@pytest.mark.parametrize(
Expand Down Expand Up @@ -614,7 +615,9 @@ async def test_post_execution_ai_message_with_reviewed_outputs_and_tool_calls(
guardrail.description = "Test description"

reviewed_tool_args = {"updated": "tool_content"}
reviewed_outputs = [{"name": "test_tool", "args": reviewed_tool_args}]
reviewed_outputs = {
"tool_calls": [{"name": "test_tool", "args": reviewed_tool_args}]
}
mock_escalation_result = MagicMock()
mock_escalation_result.action = "Approve"
mock_escalation_result.data = {"ReviewedOutputs": json.dumps(reviewed_outputs)}
Expand Down Expand Up @@ -822,7 +825,7 @@ async def test_node_interrupts_with_correct_data_pre_tool(self, mock_interrupt):
call_args = mock_interrupt.call_args[0][0]

assert call_args.data["GuardrailName"] == "Test Guardrail"
assert call_args.data["Component"] == "tool"
assert call_args.data["Component"] == "test_tool"
assert call_args.data["ExecutionStage"] == "PreExecution"
assert call_args.data["Inputs"] == '{"input": "test"}'

Expand Down Expand Up @@ -1422,7 +1425,7 @@ async def test_extract_llm_content_pre_execution_empty_content(self):
ai_message, ExecutionStage.PRE_EXECUTION
)

assert result == ""
assert result == '""'

@pytest.mark.asyncio
async def test_extract_llm_content_post_execution_tool_calls_no_content_field(self):
Expand All @@ -1447,11 +1450,12 @@ async def test_extract_llm_content_post_execution_tool_calls_no_content_field(se
)

assert isinstance(result, str)
parsed = json.loads(result)
parsed_obj = json.loads(result)
parsed_list = parsed_obj["tool_calls"]
# Should extract tool call data with name and args
assert len(parsed) == 1
assert parsed[0]["name"] == "tool_without_content"
assert parsed[0]["args"] == {"param": "value"}
assert len(parsed_list) == 1
assert parsed_list[0]["name"] == "tool_without_content"
assert parsed_list[0]["args"] == {"param": "value"}

@pytest.mark.asyncio
async def test_validate_message_count_empty_messages_raises_exception(self):
Expand Down
Loading