Skip to content

Commit abd24ef

Browse files
valentinabojanValentina Bojan
andauthored
fix: multiple fixes on sent data to guardrail escalation task (#434)
Co-authored-by: Valentina Bojan <valentina.bojan@uipath.com>
1 parent 1a609a5 commit abd24ef

8 files changed

Lines changed: 280 additions & 45 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.4.14"
3+
version = "0.4.15"
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"
77
dependencies = [
8-
"uipath>=2.5.15,<2.6.0",
8+
"uipath>=2.5.17,<2.6.0",
99
"uipath-runtime>=0.5.1,<0.6.0",
1010
"langgraph>=1.0.0, <2.0.0",
1111
"langchain-core>=1.2.5, <2.0.0",

src/uipath_langchain/agent/guardrails/actions/escalate_action.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async def _node(
8282
data: Dict[str, Any] = {
8383
"GuardrailName": guardrail.name,
8484
"GuardrailDescription": guardrail.description,
85-
"Component": scope.name.lower(),
85+
"Component": _build_component_name(scope, guarded_component_name),
8686
"ExecutionStage": _execution_stage_to_string(execution_stage),
8787
"GuardrailResult": state.inner_state.guardrail_validation_result,
8888
}
@@ -331,10 +331,16 @@ def _process_llm_escalation_response(
331331
if not reviewed_outputs_json:
332332
return {}
333333

334-
reviewed_tool_calls_list = json.loads(reviewed_outputs_json)
335-
if not reviewed_tool_calls_list:
334+
reviewed_tool_calls_obj = json.loads(reviewed_outputs_json)
335+
if not reviewed_tool_calls_obj:
336336
return {}
337337

338+
reviewed_tool_calls_list = (
339+
reviewed_tool_calls_obj.get("tool_calls")
340+
if "tool_calls" in reviewed_tool_calls_obj
341+
else None
342+
)
343+
338344
# Track if tool calls were successfully processed
339345
tool_calls_processed = False
340346

@@ -534,7 +540,7 @@ def _extract_agent_escalation_content(
534540
- POST_EXECUTION: a JSON-serialized representation of `state.agent_result`.
535541
"""
536542
if execution_stage == ExecutionStage.PRE_EXECUTION:
537-
return get_message_content(cast(AnyMessage, message))
543+
return json.dumps(get_message_content(cast(AnyMessage, message)))
538544

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

561-
return get_message_content(cast(AnyMessage, message))
567+
return json.dumps(get_message_content(cast(AnyMessage, message)))
562568

563569
# For AI messages, process tool calls if present
564570
if isinstance(message, AIMessage):
@@ -572,10 +578,11 @@ def _extract_llm_escalation_content(
572578
"args": tool_call.get("args"),
573579
}
574580
content_list.append(tool_call_data)
575-
return json.dumps(content_list)
581+
tool_calls_obj = {"tool_calls": content_list}
582+
return json.dumps(tool_calls_obj)
576583

577584
# Fallback for other message types
578-
return get_message_content(cast(AnyMessage, message))
585+
return json.dumps(get_message_content(cast(AnyMessage, message)))
579586

580587

581588
def _extract_tool_escalation_content(
@@ -634,6 +641,25 @@ def _execution_stage_to_escalation_field(
634641
return "Inputs" if execution_stage == ExecutionStage.PRE_EXECUTION else "Outputs"
635642

636643

644+
def _build_component_name(scope: GuardrailScope, guarded_component_name: str) -> str:
645+
"""Build component name based on guardrail scope and guarded component name.
646+
647+
Args:
648+
scope: The guardrail scope (LLM/AGENT/TOOL).
649+
guarded_component_name: Name of the guarded component.
650+
651+
Returns:
652+
"Agent" for AGENT scope, "LLM call" for LLM scope, or guarded_component_name for TOOL scope.
653+
"""
654+
match scope:
655+
case GuardrailScope.AGENT:
656+
return "Agent"
657+
case GuardrailScope.LLM:
658+
return "LLM call"
659+
case GuardrailScope.TOOL:
660+
return guarded_component_name
661+
662+
637663
def _execution_stage_to_string(
638664
execution_stage: ExecutionStage,
639665
) -> Literal["PreExecution", "PostExecution"]:

src/uipath_langchain/agent/guardrails/guardrail_nodes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ def _create_validation_command(
108108
if guardrail_result.result == GuardrailValidationResultType.PASSED:
109109
return Command(
110110
goto=success_node,
111-
update={"inner_state": {"guardrail_validation_result": None}},
111+
update={
112+
"inner_state": {"guardrail_validation_result": guardrail_result.reason}
113+
},
112114
)
113115

114116
if guardrail_result.result == GuardrailValidationResultType.VALIDATION_FAILED:

src/uipath_langchain/agent/guardrails/guardrails_factory.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,72 @@ def _create_word_rule_func(
155155
raise ValueError(f"Unsupported word operator: {operator}")
156156

157157

158+
def _build_field_selector_description(field_selector: AgentFieldSelector) -> str:
159+
"""Build a human-readable selector description for field selector.
160+
161+
Args:
162+
field_selector: The field selector describing which fields this rule applies to.
163+
164+
Returns:
165+
A string describing the selector, using:
166+
- \"All\" for `AgentAllFieldsSelector`
167+
- Comma-separated field paths for `SpecificFieldsSelector`
168+
- ``str(field_selector)`` as a fallback.
169+
"""
170+
if isinstance(field_selector, AgentAllFieldsSelector):
171+
return "All fields"
172+
if isinstance(field_selector, SpecificFieldsSelector):
173+
field_paths = [field.path for field in field_selector.fields]
174+
return ", ".join(field_paths)
175+
return str(field_selector)
176+
177+
178+
def _build_rule_description(
179+
operator: AgentWordOperator | AgentNumberOperator | AgentBooleanOperator,
180+
value: str | float | bool | None,
181+
field_selector: AgentFieldSelector,
182+
) -> str:
183+
"""Build the full human-readable description for a word rule.
184+
185+
Args:
186+
operator: The word operator to describe.
187+
value: The comparison value, if applicable for the operator.
188+
field_selector: The field selector describing which fields this rule applies to.
189+
190+
Returns:
191+
A string describing the rule, combining selector, operator, and value.
192+
"""
193+
selector_description = _build_field_selector_description(field_selector)
194+
195+
if operator in {
196+
AgentWordOperator.CONTAINS,
197+
AgentWordOperator.DOES_NOT_CONTAIN,
198+
AgentWordOperator.EQUALS,
199+
AgentWordOperator.DOES_NOT_EQUAL,
200+
AgentWordOperator.STARTS_WITH,
201+
AgentWordOperator.DOES_NOT_START_WITH,
202+
AgentWordOperator.ENDS_WITH,
203+
AgentWordOperator.DOES_NOT_END_WITH,
204+
AgentWordOperator.MATCHES_REGEX,
205+
AgentNumberOperator.EQUALS,
206+
AgentNumberOperator.DOES_NOT_EQUAL,
207+
AgentNumberOperator.GREATER_THAN,
208+
AgentNumberOperator.GREATER_THAN_OR_EQUAL,
209+
AgentNumberOperator.LESS_THAN,
210+
AgentNumberOperator.LESS_THAN_OR_EQUAL,
211+
AgentBooleanOperator.EQUALS,
212+
}:
213+
return f"{selector_description} {operator.value} {value!r}"
214+
215+
if operator in {
216+
AgentWordOperator.IS_EMPTY,
217+
AgentWordOperator.IS_NOT_EMPTY,
218+
}:
219+
return f"{selector_description} {operator.value}"
220+
221+
raise ValueError(f"Unsupported word operator: {operator}")
222+
223+
158224
def _create_number_rule_func(
159225
operator: AgentNumberOperator, value: float
160226
) -> Callable[[float], bool]:
@@ -314,6 +380,9 @@ def _convert_agent_rule_to_deterministic(
314380
detects_violation=_create_word_rule_func(
315381
agent_rule.operator, agent_rule.value
316382
),
383+
rule_description=_build_rule_description(
384+
agent_rule.operator, agent_rule.value, agent_rule.field_selector
385+
),
317386
)
318387

319388
if isinstance(agent_rule, AgentNumberRule):
@@ -325,6 +394,9 @@ def _convert_agent_rule_to_deterministic(
325394
detects_violation=_create_number_rule_func(
326395
agent_rule.operator, agent_rule.value
327396
),
397+
rule_description=_build_rule_description(
398+
agent_rule.operator, agent_rule.value, agent_rule.field_selector
399+
),
328400
)
329401

330402
if isinstance(agent_rule, AgentBooleanRule):
@@ -336,6 +408,9 @@ def _convert_agent_rule_to_deterministic(
336408
detects_violation=_create_boolean_rule_func(
337409
agent_rule.operator, agent_rule.value
338410
),
411+
rule_description=_build_rule_description(
412+
agent_rule.operator, agent_rule.value, agent_rule.field_selector
413+
),
339414
)
340415

341416
raise ValueError(f"Unsupported agent rule type: {type(agent_rule)}")

tests/agent/guardrails/actions/test_escalate_action.py

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,11 @@ async def test_node_interrupts_with_correct_message_data(
159159
assert call_args.data["GuardrailResult"] == "Validation failed"
160160

161161
if stage == ExecutionStage.PRE_EXECUTION:
162-
assert call_args.data["Inputs"] == "Test message"
162+
assert call_args.data["Inputs"] == '"Test message"'
163163
assert "Outputs" not in call_args.data
164164
else:
165-
assert call_args.data["Inputs"] == "Test message"
166-
assert call_args.data["Outputs"] == "Output message"
165+
assert call_args.data["Inputs"] == '"Test message"'
166+
assert call_args.data["Outputs"] == '"Output message"'
167167

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

225-
assert call_args.data["Inputs"] == "User prompt message"
225+
assert call_args.data["Inputs"] == '"User prompt message"'
226226
assert call_args.data["Outputs"] == '{"ok": true}'
227227

228228
@pytest.mark.asyncio
@@ -489,12 +489,13 @@ async def test_post_execution_ai_message_with_tool_calls_extraction(
489489

490490
# Verify interrupt was called with tool calls (name and args) in Outputs and Inputs
491491
call_args = mock_interrupt.call_args[0][0]
492-
assert call_args.data["Inputs"] == "Input message"
492+
assert call_args.data["Inputs"] == '"Input message"'
493493
tool_outputs = call_args.data["Outputs"]
494-
parsed = json.loads(tool_outputs)
495-
assert len(parsed) == 1 # Tool call data with name and args
496-
assert parsed[0]["name"] == "test_tool"
497-
assert parsed[0]["args"] == {"content": {"input": "test"}}
494+
parsed_obj = json.loads(tool_outputs)
495+
parsed_list = parsed_obj["tool_calls"]
496+
assert len(parsed_list) == 1 # Tool call data with name and args
497+
assert parsed_list[0]["name"] == "test_tool"
498+
assert parsed_list[0]["args"] == {"content": {"input": "test"}}
498499

499500
@pytest.mark.asyncio
500501
@pytest.mark.parametrize(
@@ -614,7 +615,9 @@ async def test_post_execution_ai_message_with_reviewed_outputs_and_tool_calls(
614615
guardrail.description = "Test description"
615616

616617
reviewed_tool_args = {"updated": "tool_content"}
617-
reviewed_outputs = [{"name": "test_tool", "args": reviewed_tool_args}]
618+
reviewed_outputs = {
619+
"tool_calls": [{"name": "test_tool", "args": reviewed_tool_args}]
620+
}
618621
mock_escalation_result = MagicMock()
619622
mock_escalation_result.action = "Approve"
620623
mock_escalation_result.data = {"ReviewedOutputs": json.dumps(reviewed_outputs)}
@@ -822,7 +825,7 @@ async def test_node_interrupts_with_correct_data_pre_tool(self, mock_interrupt):
822825
call_args = mock_interrupt.call_args[0][0]
823826

824827
assert call_args.data["GuardrailName"] == "Test Guardrail"
825-
assert call_args.data["Component"] == "tool"
828+
assert call_args.data["Component"] == "test_tool"
826829
assert call_args.data["ExecutionStage"] == "PreExecution"
827830
assert call_args.data["Inputs"] == '{"input": "test"}'
828831

@@ -1422,7 +1425,7 @@ async def test_extract_llm_content_pre_execution_empty_content(self):
14221425
ai_message, ExecutionStage.PRE_EXECUTION
14231426
)
14241427

1425-
assert result == ""
1428+
assert result == '""'
14261429

14271430
@pytest.mark.asyncio
14281431
async def test_extract_llm_content_post_execution_tool_calls_no_content_field(self):
@@ -1447,11 +1450,12 @@ async def test_extract_llm_content_post_execution_tool_calls_no_content_field(se
14471450
)
14481451

14491452
assert isinstance(result, str)
1450-
parsed = json.loads(result)
1453+
parsed_obj = json.loads(result)
1454+
parsed_list = parsed_obj["tool_calls"]
14511455
# Should extract tool call data with name and args
1452-
assert len(parsed) == 1
1453-
assert parsed[0]["name"] == "tool_without_content"
1454-
assert parsed[0]["args"] == {"param": "value"}
1456+
assert len(parsed_list) == 1
1457+
assert parsed_list[0]["name"] == "tool_without_content"
1458+
assert parsed_list[0]["args"] == {"param": "value"}
14551459

14561460
@pytest.mark.asyncio
14571461
async def test_validate_message_count_empty_messages_raises_exception(self):

0 commit comments

Comments
 (0)