Skip to content
Draft
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
14 changes: 13 additions & 1 deletion src/uipath_langchain/agent/react/llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ def create_llm_node(
tool_choice_required_value = _get_required_tool_choice_by_model(model)

async def llm_node(state: AgentGraphState):
from .router import filter_control_flow_tool_calls_from_state

Comment on lines +57 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

personally not the biggest fan of scoped imports, but that's just me.
I'd also move that utility function somewhere else, since llm_node importing from router feels weird if you think about separating concerns.

messages: list[AnyMessage] = state.messages

consecutive_thinking_messages = count_consecutive_thinking_messages(messages)
Expand All @@ -69,6 +71,16 @@ async def llm_node(state: AgentGraphState):
f"LLM returned {type(response).__name__} instead of AIMessage"
)

return {"messages": [response]}
# Create temporary state with the response to filter tool calls
temp_state = AgentGraphState(
messages=[*messages, response],
inner_state=state.inner_state,
)
Comment on lines +74 to +78
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the temp state?


# Filter control flow tool calls from the AIMessage
filtered_state = filter_control_flow_tool_calls_from_state(temp_state)

# Return only the (possibly filtered) last message
return {"messages": [filtered_state.messages[-1]]}

return llm_node
72 changes: 72 additions & 0 deletions src/uipath_langchain/agent/react/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,78 @@ def __filter_control_flow_tool_calls(
return [tc for tc in tool_calls if tc.get("name") not in FLOW_CONTROL_TOOLS]


def filter_control_flow_tool_calls_from_state(state: AgentGraphState) -> AgentGraphState:
"""Remove filtered control flow tool calls from AIMessage to prevent OpenAI API errors.

When multiple tools are called and one is a control flow tool (end_execution, raise_error),
the control flow tools are filtered out for execution. However, the AIMessage still
contains these tool calls, causing OpenAI to expect ToolMessages for them.

This node updates the AIMessage to only include tool calls that will actually be executed.
"""
messages = state.messages
if not messages:
return state

last_message = messages[-1]
if not isinstance(last_message, AIMessage) or not last_message.tool_calls:
return state

original_tool_calls = list(last_message.tool_calls)
if len(original_tool_calls) <= 1:
# No filtering needed for single tool calls
return state

# Check if any control flow tools would be filtered
has_control_flow = any(
tc.get("name") in FLOW_CONTROL_TOOLS for tc in original_tool_calls
)
if not has_control_flow:
# No control flow tools to filter
return state

# Filter out control flow tools
filtered_tool_calls = [
tc for tc in original_tool_calls if tc.get("name") not in FLOW_CONTROL_TOOLS
]

if len(filtered_tool_calls) == len(original_tool_calls):
# No filtering occurred
return state

# Filter content if it's a list of tool call dicts
filtered_content = last_message.content
if isinstance(last_message.content, list):
# Filter out control flow tools from content as well
filtered_ids = {tc["id"] for tc in filtered_tool_calls}
filtered_content = [
item
for item in last_message.content
if not (
isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("call_id") not in filtered_ids
)
]
Comment on lines +66 to +77
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this only works for OpenAPI


# Create new AIMessage with only non-control-flow tool calls
updated_message = AIMessage(
content=filtered_content,
tool_calls=filtered_tool_calls,
id=last_message.id,
additional_kwargs=last_message.additional_kwargs,
response_metadata=last_message.response_metadata,
usage_metadata=last_message.usage_metadata,
)

# Return updated state with modified message
updated_messages = list(messages[:-1]) + [updated_message]
return AgentGraphState(
messages=updated_messages,
inner_state=state.inner_state,
)


def __has_control_flow_tool(tool_calls: list[ToolCall]) -> bool:
"""Check if any tool call is of a control flow tool."""
return any(tc.get("name") in FLOW_CONTROL_TOOLS for tc in tool_calls)
Expand Down
Loading