Skip to content

Commit 58d7fc8

Browse files
Merge branch 'main' into feat/add-analyze-file-tool
2 parents 97c8a74 + b6b23c2 commit 58d7fc8

File tree

23 files changed

+1415
-201
lines changed

23 files changed

+1415
-201
lines changed

.github/workflows/integration_tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
uses: actions/checkout@v4
2222

2323
- name: Discover testcases
24+
# Skip common directory - it's shared utilities, not a testcase
2425
id: discover
2526
run: |
2627
# Find all testcase folders (excluding common folders like README, etc.)

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,5 +180,6 @@ cython_debug/
180180
**/__uipath/
181181
**/.langgraph_api
182182
**/testcases/**/uipath.json
183-
184-
/playground.py
183+
184+
/playground.py
185+
/.claude/settings.local.json

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.1.35"
3+
version = "0.1.37"
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: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,22 @@ async def node(
149149
state, guardrail, payload_generator
150150
)
151151
else:
152-
raise AgentTerminationException(
153-
code=UiPathErrorCode.EXECUTION_ERROR,
154-
title="Unsupported guardrail type",
155-
detail=f"Guardrail type '{type(guardrail).__name__}' is not supported. "
156-
f"Expected DeterministicGuardrail or BuiltInValidatorGuardrail.",
157-
)
152+
# Provide specific error message for DeterministicGuardrails with wrong scope
153+
if isinstance(guardrail, DeterministicGuardrail):
154+
raise AgentTerminationException(
155+
code=UiPathErrorCode.EXECUTION_ERROR,
156+
title="Invalid guardrail scope",
157+
detail=f"DeterministicGuardrail '{guardrail.name}' can only be used with TOOL scope. "
158+
f"Current scope: {scope.name}. "
159+
f"Please configure this guardrail to use only TOOL scope.",
160+
)
161+
else:
162+
raise AgentTerminationException(
163+
code=UiPathErrorCode.EXECUTION_ERROR,
164+
title="Unsupported guardrail type",
165+
detail=f"Guardrail type '{type(guardrail).__name__}' is not supported. "
166+
f"Expected DeterministicGuardrail (TOOL scope only) or BuiltInValidatorGuardrail.",
167+
)
158168

159169
return _create_validation_command(result, success_node, failure_node)
160170

src/uipath_langchain/agent/guardrails/guardrails_factory.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
UniversalRule,
2525
WordRule,
2626
)
27-
from uipath.platform.guardrails import BaseGuardrail
27+
from uipath.platform.guardrails import BaseGuardrail, GuardrailScope
2828

2929
from uipath_langchain.agent.guardrails.actions import (
3030
BlockAction,
@@ -233,6 +233,19 @@ def build_guardrails_with_actions(
233233
converted_guardrail = _convert_agent_custom_guardrail_to_deterministic(
234234
guardrail
235235
)
236+
# Validate that DeterministicGuardrails only have TOOL scope
237+
non_tool_scopes = [
238+
scope
239+
for scope in converted_guardrail.selector.scopes
240+
if scope != GuardrailScope.TOOL
241+
]
242+
243+
if non_tool_scopes:
244+
raise ValueError(
245+
f"Deterministic guardrail '{converted_guardrail.name}' can only be used with TOOL scope. "
246+
f"Found invalid scopes: {[scope.name for scope in non_tool_scopes]}. "
247+
f"Please configure this guardrail to use only TOOL scope."
248+
)
236249

237250
action = guardrail.action
238251

src/uipath_langchain/agent/react/guardrails/guardrails_subgraph.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from langgraph._internal._runnable import RunnableCallable
55
from langgraph.constants import END, START
66
from langgraph.graph import StateGraph
7+
from uipath.core.guardrails import DeterministicGuardrail
78
from uipath.platform.guardrails import (
89
BaseGuardrail,
910
BuiltInValidatorGuardrail,
@@ -207,6 +208,7 @@ def create_llm_guardrails_subgraph(
207208
(guardrail, _)
208209
for (guardrail, _) in (guardrails or [])
209210
if GuardrailScope.LLM in guardrail.selector.scopes
211+
and not isinstance(guardrail, DeterministicGuardrail)
210212
]
211213
if applicable_guardrails is None or len(applicable_guardrails) == 0:
212214
return llm_node[1]
@@ -247,6 +249,7 @@ def create_agent_init_guardrails_subgraph(
247249
(guardrail, _)
248250
for (guardrail, _) in (guardrails or [])
249251
if GuardrailScope.AGENT in guardrail.selector.scopes
252+
and not isinstance(guardrail, DeterministicGuardrail)
250253
]
251254
if applicable_guardrails is None or len(applicable_guardrails) == 0:
252255
return init_node[1]
@@ -277,6 +280,7 @@ def terminate_wrapper(state: Any) -> dict[str, Any]:
277280
(guardrail, _)
278281
for (guardrail, _) in (guardrails or [])
279282
if GuardrailScope.AGENT in guardrail.selector.scopes
283+
and not isinstance(guardrail, DeterministicGuardrail)
280284
]
281285
if applicable_guardrails is None or len(applicable_guardrails) == 0:
282286
return terminate_node[1]

src/uipath_langchain/chat/mapper.py

Lines changed: 60 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class UiPathChatMessagesMapper:
4141
def __init__(self):
4242
"""Initialize the mapper with empty state."""
4343
self.tool_call_to_ai_message: dict[str, str] = {}
44+
self.current_message: AIMessageChunk
4445
self.seen_message_ids: set[str] = set()
4546

4647
def _extract_text(self, content: Any) -> str:
@@ -141,7 +142,7 @@ def _map_messages_internal(
141142
def map_event(
142143
self,
143144
message: BaseMessage,
144-
) -> UiPathConversationMessageEvent | None:
145+
) -> list[UiPathConversationMessageEvent] | None:
145146
"""Convert LangGraph BaseMessage (chunk or full) into a UiPathConversationMessageEvent.
146147
147148
Args:
@@ -168,16 +169,45 @@ def map_event(
168169

169170
# Check if this is the last chunk by examining chunk_position
170171
if message.chunk_position == "last":
172+
events: list[UiPathConversationMessageEvent] = []
173+
174+
# Loop through all content_blocks in current_message and create toolCallStart events for each tool_call_chunk
175+
if self.current_message and self.current_message.content_blocks:
176+
for block in self.current_message.content_blocks:
177+
if block.get("type") == "tool_call_chunk":
178+
tool_chunk_block = cast(ToolCallChunk, block)
179+
tool_call_id = tool_chunk_block.get("id")
180+
tool_name = tool_chunk_block.get("name")
181+
tool_args = tool_chunk_block.get("args")
182+
183+
if tool_call_id:
184+
tool_event = UiPathConversationMessageEvent(
185+
message_id=message.id,
186+
tool_call=UiPathConversationToolCallEvent(
187+
tool_call_id=tool_call_id,
188+
start=UiPathConversationToolCallStartEvent(
189+
tool_name=tool_name,
190+
timestamp=timestamp,
191+
input=UiPathInlineValue(inline=tool_args),
192+
),
193+
),
194+
)
195+
events.append(tool_event)
196+
197+
# Create the final event for the message
171198
msg_event.end = UiPathConversationMessageEndEvent(timestamp=timestamp)
172199
msg_event.content_part = UiPathConversationContentPartEvent(
173200
content_part_id=f"chunk-{message.id}-0",
174201
end=UiPathConversationContentPartEndEvent(),
175202
)
176-
return msg_event
203+
events.append(msg_event)
204+
205+
return events
177206

178207
# For every new message_id, start a new message
179208
if message.id not in self.seen_message_ids:
180209
self.seen_message_ids.add(message.id)
210+
self.current_message = message
181211
msg_event.start = UiPathConversationMessageStartEvent(
182212
role="assistant", timestamp=timestamp
183213
)
@@ -200,7 +230,6 @@ def map_event(
200230
content_part_id=f"chunk-{message.id}-0",
201231
chunk=UiPathConversationContentPartChunkEvent(
202232
data=text,
203-
content_part_sequence=0,
204233
),
205234
)
206235

@@ -210,19 +239,10 @@ def map_event(
210239
tool_call_id = tool_chunk_block.get("id")
211240
if tool_call_id:
212241
# Track tool_call_id -> ai_message_id mapping
213-
self.tool_call_to_ai_message[str(tool_call_id)] = message.id
214-
215-
args = tool_chunk_block.get("args") or ""
242+
self.tool_call_to_ai_message[tool_call_id] = message.id
216243

217-
msg_event.content_part = UiPathConversationContentPartEvent(
218-
content_part_id=f"chunk-{message.id}-0",
219-
chunk=UiPathConversationContentPartChunkEvent(
220-
data=args,
221-
content_part_sequence=0,
222-
),
223-
)
224-
# Continue so that multiple tool_call_chunks in the same block list
225-
# are handled correctly
244+
# Accumulate the message chunk
245+
self.current_message = self.current_message + message
226246
continue
227247

228248
# Fallback: raw string content on the chunk (rare when using content_blocks)
@@ -231,7 +251,6 @@ def map_event(
231251
content_part_id=f"content-{message.id}",
232252
chunk=UiPathConversationContentPartChunkEvent(
233253
data=message.content,
234-
content_part_sequence=0,
235254
),
236255
)
237256

@@ -241,7 +260,7 @@ def map_event(
241260
or msg_event.tool_call
242261
or msg_event.end
243262
):
244-
return msg_event
263+
return [msg_event]
245264

246265
return None
247266

@@ -275,35 +294,34 @@ def map_event(
275294
# Keep as string if not valid JSON
276295
pass
277296

278-
return UiPathConversationMessageEvent(
279-
message_id=result_message_id or str(uuid4()),
280-
tool_call=UiPathConversationToolCallEvent(
281-
tool_call_id=message.tool_call_id,
282-
start=UiPathConversationToolCallStartEvent(
283-
tool_name=message.name,
284-
arguments=None,
285-
timestamp=timestamp,
297+
return [
298+
UiPathConversationMessageEvent(
299+
message_id=result_message_id or str(uuid4()),
300+
tool_call=UiPathConversationToolCallEvent(
301+
tool_call_id=message.tool_call_id,
302+
end=UiPathConversationToolCallEndEvent(
303+
timestamp=timestamp,
304+
output=UiPathInlineValue(inline=content_value),
305+
),
286306
),
287-
end=UiPathConversationToolCallEndEvent(
288-
timestamp=timestamp,
289-
output=UiPathInlineValue(inline=content_value),
290-
),
291-
),
292-
)
307+
)
308+
]
293309

294310
# --- Fallback for other BaseMessage types ---
295311
text_content = self._extract_text(message.content)
296-
return UiPathConversationMessageEvent(
297-
message_id=message.id,
298-
start=UiPathConversationMessageStartEvent(
299-
role="assistant", timestamp=timestamp
300-
),
301-
content_part=UiPathConversationContentPartEvent(
302-
content_part_id=f"cp-{message.id}",
303-
chunk=UiPathConversationContentPartChunkEvent(data=text_content),
304-
),
305-
end=UiPathConversationMessageEndEvent(),
306-
)
312+
return [
313+
UiPathConversationMessageEvent(
314+
message_id=message.id,
315+
start=UiPathConversationMessageStartEvent(
316+
role="assistant", timestamp=timestamp
317+
),
318+
content_part=UiPathConversationContentPartEvent(
319+
content_part_id=f"cp-{message.id}",
320+
chunk=UiPathConversationContentPartChunkEvent(data=text_content),
321+
),
322+
end=UiPathConversationMessageEndEvent(),
323+
)
324+
]
307325

308326

309327
__all__ = ["UiPathChatMessagesMapper"]

src/uipath_langchain/runtime/runtime.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any, AsyncGenerator
44
from uuid import uuid4
55

6+
from langchain_core.callbacks import BaseCallbackHandler
67
from langchain_core.runnables.config import RunnableConfig
78
from langgraph.errors import EmptyInputError, GraphRecursionError, InvalidUpdateError
89
from langgraph.graph.state import CompiledStateGraph
@@ -41,6 +42,7 @@ def __init__(
4142
graph: CompiledStateGraph[Any, Any, Any, Any],
4243
runtime_id: str | None = None,
4344
entrypoint: str | None = None,
45+
callbacks: list[BaseCallbackHandler] | None = None,
4446
):
4547
"""
4648
Initialize the runtime.
@@ -53,6 +55,7 @@ def __init__(
5355
self.graph: CompiledStateGraph[Any, Any, Any, Any] = graph
5456
self.runtime_id: str = runtime_id or "default"
5557
self.entrypoint: str | None = entrypoint
58+
self.callbacks: list[BaseCallbackHandler] = callbacks or []
5659
self.chat = UiPathChatMessagesMapper()
5760
self._middleware_node_names: set[str] = self._detect_middleware_nodes()
5861

@@ -135,10 +138,17 @@ async def stream(
135138
if chunk_type == "messages":
136139
if isinstance(data, tuple):
137140
message, _ = data
138-
event = UiPathRuntimeMessageEvent(
139-
payload=self.chat.map_event(message),
140-
)
141-
yield event
141+
try:
142+
events = self.chat.map_event(message)
143+
except Exception as e:
144+
logger.warning(f"Error mapping message event: {e}")
145+
events = None
146+
if events:
147+
for mapped_event in events:
148+
event = UiPathRuntimeMessageEvent(
149+
payload=mapped_event,
150+
)
151+
yield event
142152

143153
# Emit UiPathRuntimeStateEvent for state updates
144154
elif chunk_type == "updates":
@@ -189,7 +199,7 @@ def _get_graph_config(self) -> RunnableConfig:
189199
"""Build graph execution configuration."""
190200
graph_config: RunnableConfig = {
191201
"configurable": {"thread_id": self.runtime_id},
192-
"callbacks": [],
202+
"callbacks": self.callbacks,
193203
}
194204

195205
# Add optional config from environment

testcases/common/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Common testing utilities for UiPath testcases."""
2+
3+
from testcases.common.console import (
4+
ConsoleTest,
5+
PromptTest,
6+
strip_ansi,
7+
read_log,
8+
)
9+
10+
__all__ = [
11+
"ConsoleTest",
12+
"PromptTest",
13+
"strip_ansi",
14+
"read_log",
15+
]

0 commit comments

Comments
 (0)