Skip to content

Commit a09bb94

Browse files
feat: address PR comments
1 parent e625887 commit a09bb94

34 files changed

Lines changed: 639 additions & 489 deletions

src/uipath_langchain/agent/react/agent.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from pydantic import BaseModel
99
from uipath.platform.guardrails import BaseGuardrail
1010

11+
from uipath_langchain.chat.types import UiPathPassthroughChatModel
12+
1113
from ..guardrails.actions import GuardrailAction
1214
from .guardrails.guardrails_subgraph import (
1315
create_agent_init_guardrails_subgraph,
@@ -33,6 +35,7 @@
3335
AgentGraphConfig,
3436
AgentGraphNode,
3537
AgentGraphState,
38+
AgentSettings,
3639
)
3740
from .utils import create_state_with_input
3841

@@ -62,6 +65,17 @@ def create_agent(
6265
"""
6366
from ..tools import create_tool_node
6467

68+
if not isinstance(model, UiPathPassthroughChatModel):
69+
raise TypeError(
70+
f"Model {type(model).__name__} does not implement UiPathPassthroughChatModel. "
71+
"The model must have llm_provider and api_flavor properties."
72+
)
73+
74+
agent_settings = AgentSettings(
75+
llm_provider=model.llm_provider,
76+
api_flavor=model.api_flavor,
77+
)
78+
6579
if config is None:
6680
config = AgentGraphConfig()
6781

@@ -71,7 +85,9 @@ def create_agent(
7185
)
7286
llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools]
7387

74-
init_node = create_init_node(messages, input_schema, config.is_conversational)
88+
init_node = create_init_node(
89+
messages, input_schema, config.is_conversational, agent_settings
90+
)
7591

7692
tool_nodes = create_tool_node(agent_tools)
7793
tool_nodes_with_guardrails = create_tools_guardrails_subgraph(

src/uipath_langchain/agent/react/init_node.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
from .job_attachments import (
1010
get_job_attachments,
1111
)
12+
from .types import AgentSettings
1213

1314

1415
def create_init_node(
1516
messages: Sequence[SystemMessage | HumanMessage]
1617
| Callable[[Any], Sequence[SystemMessage | HumanMessage]],
1718
input_schema: type[BaseModel] | None,
1819
is_conversational: bool = False,
20+
agent_settings: AgentSettings | None = None,
1921
):
2022
def graph_state_init(state: Any) -> Any:
2123
resolved_messages: Sequence[SystemMessage | HumanMessage] | Overwrite
@@ -46,6 +48,7 @@ def graph_state_init(state: Any) -> Any:
4648
"messages": resolved_messages,
4749
"inner_state": {
4850
"job_attachments": job_attachments_dict,
51+
"agent_settings": agent_settings,
4952
},
5053
}
5154

src/uipath_langchain/agent/react/llm_node.py

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,22 @@
11
"""LLM node for ReAct Agent graph."""
22

3-
from typing import Any, Sequence
3+
from typing import Sequence
44

55
from langchain_core.language_models import BaseChatModel
66
from langchain_core.messages import AIMessage, AnyMessage, ToolCall
77
from langchain_core.tools import BaseTool
88
from uipath.runtime.errors import UiPathErrorCategory, UiPathErrorCode
99

10+
from uipath_langchain.llm import get_payload_handler
11+
1012
from ..exceptions import AgentTerminationException
1113
from .constants import (
1214
DEFAULT_MAX_CONSECUTIVE_THINKING_MESSAGES,
1315
DEFAULT_MAX_LLM_MESSAGES,
1416
)
1517
from .types import FLOW_CONTROL_TOOLS, AgentGraphState
16-
from uipath_langchain.chat.types import APIFlavor
17-
18-
from .constants import MAX_CONSECUTIVE_THINKING_MESSAGES
19-
from .types import AgentGraphState
2018
from .utils import count_consecutive_thinking_messages
2119

22-
OPENAI_COMPATIBLE_CHAT_MODELS = (
23-
"UiPathChatOpenAI",
24-
"AzureChatOpenAI",
25-
"ChatOpenAI",
26-
"UiPathChat",
27-
"UiPathAzureChatOpenAI",
28-
)
29-
30-
31-
def _get_required_tool_choice_by_model(
32-
model: BaseChatModel,
33-
) -> str | dict[str, Any]:
34-
"""Get the appropriate tool_choice value to enforce tool usage based on model type.
35-
36-
Returns:
37-
- "required" for OpenAI compatible models
38-
- "any" for Bedrock Converse and Vertex models (string format)
39-
- {"type": "any"} for Bedrock Invoke API (dict format required)
40-
"""
41-
model_class_name = model.__class__.__name__
42-
if model_class_name in OPENAI_COMPATIBLE_CHAT_MODELS:
43-
return "required"
44-
45-
api_flavor = getattr(model, "api_flavor", None)
46-
if api_flavor == APIFlavor.AWS_BEDROCK_INVOKE:
47-
return {"type": "any"}
48-
49-
return "any"
50-
5120

5221
def _filter_control_flow_tool_calls(
5322
tool_calls: list[ToolCall],
@@ -81,7 +50,8 @@ def create_llm_node(
8150
"""
8251
bindable_tools = list(tools) if tools else []
8352
base_llm = model.bind_tools(bindable_tools) if bindable_tools else model
84-
tool_choice_required_value = _get_required_tool_choice_by_model(model)
53+
payload_handler = get_payload_handler(model)
54+
tool_choice_required_value = payload_handler.get_required_tool_choice()
8555

8656
async def llm_node(state: AgentGraphState):
8757
messages: list[AnyMessage] = state.messages

src/uipath_langchain/agent/react/llm_with_files.py

Lines changed: 0 additions & 71 deletions
This file was deleted.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Multimodal LLM input handling (images, PDFs, etc.)."""
2+
3+
from .invoke import build_file_content_block, llm_call_with_files
4+
from .types import IMAGE_MIME_TYPES, FileInfo
5+
from .utils import download_file_base64, is_image, is_pdf, sanitize_filename
6+
7+
__all__ = [
8+
"FileInfo",
9+
"IMAGE_MIME_TYPES",
10+
"build_file_content_block",
11+
"download_file_base64",
12+
"is_image",
13+
"is_pdf",
14+
"llm_call_with_files",
15+
"sanitize_filename",
16+
]
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""LLM invocation with multimodal file attachments."""
2+
3+
from typing import Any
4+
5+
from langchain_core.language_models import BaseChatModel
6+
from langchain_core.messages import (
7+
AIMessage,
8+
AnyMessage,
9+
DataContentBlock,
10+
HumanMessage,
11+
)
12+
from langchain_core.messages.content import create_file_block, create_image_block
13+
14+
from .types import FileInfo
15+
from .utils import download_file_base64, is_image, is_pdf, sanitize_filename
16+
17+
18+
async def build_file_content_block(
19+
file_info: FileInfo,
20+
) -> DataContentBlock:
21+
"""Build a LangChain content block for a file attachment.
22+
23+
Args:
24+
file_info: File URL, name, and MIME type.
25+
26+
Returns:
27+
A DataContentBlock for the file (image or PDF).
28+
29+
Raises:
30+
ValueError: If the MIME type is not supported.
31+
"""
32+
base64_file = await download_file_base64(file_info.url)
33+
34+
if is_image(file_info.mime_type):
35+
return create_image_block(base64=base64_file, mime_type=file_info.mime_type)
36+
if is_pdf(file_info.mime_type):
37+
return create_file_block(
38+
base64=base64_file,
39+
mime_type=file_info.mime_type,
40+
filename=sanitize_filename(file_info.name),
41+
)
42+
43+
raise ValueError(f"Unsupported mime_type={file_info.mime_type}")
44+
45+
46+
async def llm_call_with_files(
47+
messages: list[AnyMessage],
48+
files: list[FileInfo],
49+
model: BaseChatModel,
50+
) -> AIMessage:
51+
"""Invoke an LLM with file attachments.
52+
53+
Downloads files, creates content blocks, and appends them as a HumanMessage.
54+
If no files are provided, equivalent to model.ainvoke().
55+
56+
Args:
57+
messages: The conversation messages to send to the LLM.
58+
files: List of file attachments to include.
59+
model: The LLM model to invoke.
60+
61+
Returns:
62+
The AIMessage response from the LLM.
63+
64+
Raises:
65+
TypeError: If the LLM returns something other than AIMessage.
66+
"""
67+
if not files:
68+
response = await model.ainvoke(messages)
69+
if not isinstance(response, AIMessage):
70+
raise TypeError(
71+
f"LLM returned {type(response).__name__} instead of AIMessage"
72+
)
73+
return response
74+
75+
content_blocks: list[Any] = []
76+
for file_info in files:
77+
content_block = await build_file_content_block(file_info)
78+
content_blocks.append(content_block)
79+
80+
file_message = HumanMessage(content_blocks=content_blocks)
81+
all_messages = list(messages) + [file_message]
82+
83+
response = await model.ainvoke(all_messages)
84+
if not isinstance(response, AIMessage):
85+
raise TypeError(f"LLM returned {type(response).__name__} instead of AIMessage")
86+
return response
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Types and constants for multimodal LLM input handling."""
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass
7+
class FileInfo:
8+
"""File information for LLM file attachments."""
9+
10+
url: str
11+
name: str
12+
mime_type: str
13+
14+
15+
IMAGE_MIME_TYPES: set[str] = {
16+
"image/png",
17+
"image/jpeg",
18+
"image/gif",
19+
"image/webp",
20+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Utility functions for multimodal file handling."""
2+
3+
import base64
4+
import re
5+
6+
import httpx
7+
from uipath._utils._ssl_context import get_httpx_client_kwargs
8+
9+
from .types import IMAGE_MIME_TYPES
10+
11+
12+
def sanitize_filename(filename: str) -> str:
13+
"""Sanitize a filename to conform to provider document naming requirements.
14+
15+
Bedrock only allows: alphanumeric characters, whitespace, hyphens,
16+
parentheses, and square brackets. No consecutive whitespace allowed.
17+
"""
18+
if not filename or filename.isspace():
19+
return "document"
20+
21+
sanitized = re.sub(r"[^a-zA-Z0-9\s\-\(\)\[\]]", "-", filename)
22+
sanitized = re.sub(r"\s+", " ", sanitized)
23+
sanitized = re.sub(r"-+", "-", sanitized)
24+
sanitized = sanitized.strip(" -")
25+
26+
return sanitized if sanitized else "document"
27+
28+
29+
def is_pdf(mime_type: str) -> bool:
30+
"""Check if the MIME type represents a PDF document."""
31+
return mime_type.lower() == "application/pdf"
32+
33+
34+
def is_image(mime_type: str) -> bool:
35+
"""Check if the MIME type represents a supported image format."""
36+
return mime_type.lower() in IMAGE_MIME_TYPES
37+
38+
39+
async def download_file_base64(url: str) -> str:
40+
"""Download a file from a URL and return its content as a base64 string."""
41+
async with httpx.AsyncClient(**get_httpx_client_kwargs()) as client:
42+
response = await client.get(url)
43+
response.raise_for_status()
44+
file_content = response.content
45+
return base64.b64encode(file_content).decode("utf-8")

0 commit comments

Comments
 (0)