diff --git a/pyproject.toml b/pyproject.toml index 833d19fbb..a56d82ec1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "uipath-langchain" -version = "0.13.18" +version = "0.13.19" 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.11.14, <2.12.0", - "uipath-core>=0.5.20, <0.6.0", - "uipath-platform>=0.1.86, <0.2.0", - "uipath-runtime>=0.11.4, <0.12.0", + "uipath>=2.12.4, <2.13.0", + "uipath-core>=0.5.28, <0.6.0", + "uipath-platform>=0.1.89, <0.2.0", + "uipath-runtime>=0.11.6, <0.12.0", "langgraph>=1.1.8, <2.0.0", "langchain-core>=1.2.27, <2.0.0", "langgraph-checkpoint-sqlite>=3.0.3, <4.0.0", diff --git a/src/uipath_langchain/governance/__init__.py b/src/uipath_langchain/governance/__init__.py new file mode 100644 index 000000000..c7bfd29bc --- /dev/null +++ b/src/uipath_langchain/governance/__init__.py @@ -0,0 +1,16 @@ +"""Governance integration for ``uipath-langchain``. + +Exposes :class:`GovernanceCallbackHandler` — a LangChain callback +handler that calls an :class:`~uipath.core.adapters.EvaluatorProtocol` +on the model and tool lifecycle. Wired into a run by passing an +``evaluator`` to :class:`UiPathLangGraphRuntimeFactory`; the factory +builds the handler and hands it to the runtime through the existing +``callbacks`` channel. + +Importing this module has no side effects: no adapter is registered, +no global state is mutated. +""" + +from .callbacks import GovernanceCallbackHandler + +__all__ = ["GovernanceCallbackHandler"] diff --git a/src/uipath_langchain/governance/callbacks.py b/src/uipath_langchain/governance/callbacks.py new file mode 100644 index 000000000..bf86a1fa9 --- /dev/null +++ b/src/uipath_langchain/governance/callbacks.py @@ -0,0 +1,388 @@ +"""LangChain governance callback handler. + +A :class:`langchain_core.callbacks.BaseCallbackHandler` that calls a +framework-agnostic :class:`~uipath.core.adapters.EvaluatorProtocol` +on the model and tool lifecycle. + +Wiring lives in :class:`UiPathLangGraphRuntimeFactory`: passing an +``evaluator`` to ``new_runtime`` causes the factory to build this +handler and hand it to :class:`UiPathLangGraphRuntime` through the +existing ``callbacks`` constructor arg. No adapter registry, no global +state, no import-time mutation. + +Intercepts: + +- ``on_llm_start`` / ``on_chat_model_start`` / ``on_llm_end`` → BEFORE_MODEL / AFTER_MODEL +- ``on_tool_start`` / ``on_tool_end`` → TOOL_CALL / AFTER_TOOL + +Chain-level boundaries (BEFORE_AGENT / AFTER_AGENT) are intentionally +*not* fired from here — they are owned by the governance host that +drives the agent. ``ignore_chain = True`` makes LangChain skip chain +notifications entirely, avoiding duplicate boundary evaluations. + +Audit emission and enforcement (raising +:class:`GovernanceBlockException` on DENY) are owned by the evaluator +itself. This module just hooks the framework callbacks, extracts the +data, and calls ``evaluator.evaluate_*``; block exceptions propagate, +everything else is logged and swallowed so a governance bug never +breaks an agent run. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Iterable + +from langchain_core.callbacks import BaseCallbackHandler +from langchain_core.outputs import ( + ChatGeneration, + ChatGenerationChunk, + Generation, + GenerationChunk, + LLMResult, +) +from uipath.core.adapters import EvaluatorProtocol +from uipath.core.governance.exceptions import GovernanceBlockException + +GenerationLike = Generation | ChatGeneration | GenerationChunk | ChatGenerationChunk + +logger = logging.getLogger(__name__) + +# Cap on the text scanned per model hook, so a long history / runaway +# response can't blow scan-time budgets. +_BEFORE_MODEL_TEXT_CAP = 64000 + + +class GovernanceCallbackHandler(BaseCallbackHandler): + """LangChain callback handler that fires governance evaluation. + + The evaluator owns audit emission and DENY-raising. Each ``on_*`` + callback only extracts the relevant payload and calls the matching + ``evaluate_*`` method; :class:`GovernanceBlockException` is allowed + to propagate, anything else is logged and swallowed. + """ + + run_inline: bool = True + raise_error: bool = True + ignore_llm: bool = False + # Chain-level events (BEFORE_AGENT / AFTER_AGENT) are owned by the + # governance host, so this handler skips them to avoid duplicate + # boundary evaluations. + ignore_chain: bool = True + ignore_agent: bool = False + ignore_retriever: bool = True + ignore_retry: bool = True + ignore_chat_model: bool = False + ignore_custom_event: bool = True + + def __init__( + self, + evaluator: EvaluatorProtocol, + agent_name: str, + session_id: str, + ) -> None: + self._evaluator = evaluator + self._agent_name = agent_name + self._session_id = session_id + # ``trace_id`` is intentionally NOT held here. Trace correlation + # is owned by the layer below: OTel-backed sinks read the live + # span via ``trace.get_current_span()`` on the caller's thread; + # HTTP-bound consumers resolve the canonical trace id at call + # time. The callback handler is env-free and just forwards + # extracted payload to the evaluator. + self._session_state: Dict[str, Any] = {"tool_calls": 0, "llm_calls": 0} + # Tool name lookup keyed by LangChain ``run_id`` so ``on_tool_end`` + # can report the actual tool name to AFTER_TOOL evaluation. + self._tool_runs: Dict[str, str] = {} + + # ----- LLM callbacks --------------------------------------------------- + + def on_llm_start( + self, + serialized: Dict[str, Any], + prompts: list[str], + **kwargs: Any, + ) -> None: + """Evaluate BEFORE_MODEL rules at LLM start (non-chat completion).""" + try: + self._session_state["llm_calls"] = ( + self._session_state.get("llm_calls", 0) + 1 + ) + # Take only the latest prompt. Re-scanning every prompt in a + # batched call would re-fire rules on prior turns' content + # that's still in the prompt for context. + model_input = (prompts[-1] if prompts else "")[:_BEFORE_MODEL_TEXT_CAP] + self._evaluator.evaluate_before_model( + model_input=model_input, + agent_name=self._agent_name, + runtime_id=self._session_id, + ) + except GovernanceBlockException: + raise + except Exception as e: + logger.warning("on_llm_start governance check failed (continuing): %s", e) + + def on_chat_model_start( + self, + serialized: Dict[str, Any], + messages: list[list[Any]], + **kwargs: Any, + ) -> None: + """Evaluate BEFORE_MODEL rules for chat models. + + Scans only the **latest message** in the prompt — not the full + chat history. The LLM still receives the entire history (this + callback doesn't mutate ``messages``), but the governance + evaluator focuses on the new content the agent is about to + respond to. Without this scoping, a violation in turn 3's user + message would keep re-firing on turns 4, 5, 6 ... because that + text stays in the prompt for context. + + List-of-blocks content (multimodal, function-call, tool_use, + extended thinking) is walked via :meth:`_extract_block_text` so + dict-syntax noise from ``str(list)`` doesn't leak into the + regex-scanned blob. + """ + try: + self._session_state["llm_calls"] = ( + self._session_state.get("llm_calls", 0) + 1 + ) + model_input = self._latest_message_input(messages) + self._evaluator.evaluate_before_model( + model_input=model_input, + agent_name=self._agent_name, + runtime_id=self._session_id, + ) + except GovernanceBlockException: + raise + except Exception as e: + logger.warning( + "on_chat_model_start governance check failed (continuing): %s", e + ) + + @staticmethod + def _latest_message_input(messages: list[list[Any]]) -> str: + """Extract content from the most-recent message in the prompt. + + ``messages`` is LangChain's nested shape ``list[list[BaseMessage]]`` + — the outer list is for batched calls (rare); the inner list is + the full message stack for one call. We take the last entry of + the last inner list. For string content, that's used directly; + for list-of-blocks content, :meth:`_extract_block_text` pulls + the text / arguments / input / thinking fields cleanly. + + Returns ``""`` (empty) when the message stack is empty or the + last message carries no extractable content. + """ + if not messages: + return "" + last_batch = messages[-1] + if not last_batch: + return "" + last_msg = last_batch[-1] + # BaseMessage exposes ``.content``; dict-shaped messages + # (LangGraph state, raw OpenAI format) carry it under the same + # key. + content = getattr(last_msg, "content", None) + if content is None and isinstance(last_msg, dict): + content = last_msg.get("content") + if isinstance(content, str): + return content[:_BEFORE_MODEL_TEXT_CAP] + if isinstance(content, list): + return GovernanceCallbackHandler._blocks_to_text(content) + return "" + + @staticmethod + def _blocks_to_text(content: list[Any]) -> str: + """Concatenate governance-relevant text from a list of content blocks. + + Walks list-of-blocks message content (multimodal, function-call, + tool_use, extended thinking) via :meth:`_extract_block_text`, + capping the joined result at ``_BEFORE_MODEL_TEXT_CAP``. + """ + pieces = ( + GovernanceCallbackHandler._extract_block_text(block) + for block in content + if isinstance(block, dict) + ) + return GovernanceCallbackHandler._join_within_cap(pieces, "\n") + + @staticmethod + def _join_within_cap(pieces: Iterable[str], sep: str) -> str: + """Join non-empty ``pieces`` with ``sep``, stopping at the text cap. + + Shared accumulator for the model-input/output scan blobs: appends + pieces until ``_BEFORE_MODEL_TEXT_CAP`` characters are reached + (counting the separator), then caps the joined result. + """ + out: list[str] = [] + remaining = _BEFORE_MODEL_TEXT_CAP + for piece in pieces: + if remaining <= 0: + break + if piece: + out.append(piece) + remaining -= len(piece) + len(sep) + return sep.join(out)[:_BEFORE_MODEL_TEXT_CAP] + + def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None: + """Evaluate AFTER_MODEL rules at LLM end. + + Concatenates text from every generation. The result is capped at + ``_BEFORE_MODEL_TEXT_CAP`` to match the BEFORE_MODEL budget, so + batched calls or a runaway single response can't blow scan budgets. + """ + try: + model_output = self._collect_generations_text(response) + self._evaluator.evaluate_after_model( + model_output=model_output, + agent_name=self._agent_name, + runtime_id=self._session_id, + ) + except GovernanceBlockException: + raise + except Exception as e: + logger.warning("on_llm_end governance check failed (continuing): %s", e) + + def _collect_generations_text(self, response: LLMResult) -> str: + """Concatenate text across all generations, capped at the text budget.""" + pieces = ( + self._extract_generation_text(gen) + for gen_list in response.generations + for gen in gen_list + ) + return self._join_within_cap(pieces, "") + + @staticmethod + def _extract_generation_text(gen: GenerationLike) -> str: + """Return the text payload of a LangChain generation. + + ``Generation.text`` is set from ``message.content`` only when content + is a plain ``str``. For chat models whose content is a list of + content blocks (multimodal, tool calls, "submit final answer" + function calls, extended thinking) ``.text`` is ``""``. In that case + walk ``gen.message.content`` so the governance evaluator sees the + actual assistant text. + """ + if isinstance(gen, (ChatGeneration, ChatGenerationChunk)): + content = gen.message.content + if isinstance(content, list): + parts = [ + GovernanceCallbackHandler._extract_block_text(block) + for block in content + if isinstance(block, dict) + ] + joined = "\n".join(p for p in parts if p) + if joined: + return joined + return gen.text or "" + + @staticmethod + def _extract_block_text(block: Dict[str, Any]) -> str: + """Return any governance-relevant text from a content block. + + Covers the common block shapes across providers: + + - ``{"type": "text", "text": "..."}`` — plain text block. + - ``{"type": "function_call", "arguments": ""}`` — OpenAI + function call; ``arguments`` is JSON-encoded and routinely + carries the user-visible reply (e.g. ``end_execution(content=...)`` + tools used as a "submit final answer" pattern). + - ``{"type": "tool_use", "input": {...}}`` — Anthropic tool use; + string values in ``input`` are the assistant's outgoing payload. + - ``{"type": "thinking", "thinking": "..."}`` — Claude extended + thinking (governance-relevant: hidden reasoning can also leak + commitments and PII). + + Metadata-only keys (``id``, ``call_id``, ``name``, ``status``, + ``type``, ...) are excluded so the scanned text isn't padded with + opaque identifiers that could false-positive a rule. + """ + parts: list[str] = [] + text_value = block.get("text") + if isinstance(text_value, str): + parts.append(text_value) + arguments_value = block.get("arguments") + if isinstance(arguments_value, str): + parts.append(arguments_value) + thinking_value = block.get("thinking") + if isinstance(thinking_value, str): + parts.append(thinking_value) + input_value = block.get("input") + if isinstance(input_value, dict): + parts.extend(v for v in input_value.values() if isinstance(v, str)) + return "\n".join(p for p in parts if p) + + def on_llm_error(self, error: BaseException, **kwargs: Any) -> None: + logger.warning("LLM error in governed session %s: %s", self._session_id, error) + + # ----- Tool callbacks -------------------------------------------------- + + def on_tool_start( + self, + serialized: Dict[str, Any], + input_str: str, + *, + inputs: Dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Evaluate TOOL_CALL rules at tool start. + + ``run_id → tool_name`` is recorded so ``on_tool_end`` / + ``on_tool_error`` can report the actual tool. If the evaluator + BLOCKS, the tool is aborted, ``on_tool_end`` will not fire, and + the mapping is dropped to keep ``_tool_runs`` from growing + unbounded across blocked turns. + """ + run_id = kwargs.get("run_id") + run_id_str = str(run_id) if run_id is not None else None + try: + self._session_state["tool_calls"] = ( + self._session_state.get("tool_calls", 0) + 1 + ) + tool_name = (serialized or {}).get("name", "unknown") + if run_id_str is not None: + self._tool_runs[run_id_str] = tool_name + tool_args = inputs or {"input": input_str} + self._evaluator.evaluate_tool_call( + tool_name=tool_name, + tool_args=tool_args, + agent_name=self._agent_name, + runtime_id=self._session_id, + session_state=self._session_state, + ) + except GovernanceBlockException: + # Tool will not run → no on_tool_end is coming. Drop the + # mapping so it does not accumulate across blocked turns. + if run_id_str is not None: + self._tool_runs.pop(run_id_str, None) + raise + except Exception as e: + logger.warning("on_tool_start governance check failed (continuing): %s", e) + + def on_tool_end(self, output: Any, **kwargs: Any) -> None: + """Evaluate AFTER_TOOL rules at tool end.""" + try: + run_id = kwargs.get("run_id") + tool_name = "unknown" + if run_id is not None: + tool_name = self._tool_runs.pop(str(run_id), "unknown") + tool_result = str(output) if output is not None else "" + self._evaluator.evaluate_after_tool( + tool_name=tool_name, + tool_result=tool_result, + agent_name=self._agent_name, + runtime_id=self._session_id, + ) + except GovernanceBlockException: + raise + except Exception as e: + logger.warning("on_tool_end governance check failed (continuing): %s", e) + + def on_tool_error(self, error: BaseException, **kwargs: Any) -> None: + # Tool errored out — on_tool_end will not fire. Pop the mapping + # so a session with many failing tool calls does not leak. + run_id = kwargs.get("run_id") + if run_id is not None: + self._tool_runs.pop(str(run_id), None) + logger.warning("Tool error in governed session %s: %s", self._session_id, error) diff --git a/src/uipath_langchain/runtime/factory.py b/src/uipath_langchain/runtime/factory.py index b8f6565f8..e30b82a9e 100644 --- a/src/uipath_langchain/runtime/factory.py +++ b/src/uipath_langchain/runtime/factory.py @@ -2,6 +2,7 @@ import os from typing import Any, AsyncContextManager +from langchain_core.callbacks import BaseCallbackHandler from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver from langgraph.graph.state import CompiledStateGraph, StateGraph from openinference.instrumentation.langchain import ( @@ -9,6 +10,7 @@ get_ancestor_spans, get_current_span, ) +from uipath.core.adapters import EvaluatorProtocol from uipath.core.tracing import UiPathSpanUtils, UiPathTraceManager from uipath.platform.resume_triggers import ( UiPathResumeTriggerHandler, @@ -23,6 +25,7 @@ from uipath.runtime.errors import UiPathErrorCategory from uipath_langchain._tracing import _instrument_traceable_attributes +from uipath_langchain.governance import GovernanceCallbackHandler from uipath_langchain.runtime.config import LangGraphConfig from uipath_langchain.runtime.errors import LangGraphErrorCode, LangGraphRuntimeError from uipath_langchain.runtime.graph import LangGraphLoader @@ -263,6 +266,10 @@ async def _create_runtime_instance( compiled_graph: The compiled graph runtime_id: Unique identifier for the runtime instance entrypoint: Graph entrypoint name + **kwargs: Forwarded factory kwargs. Recognized: + ``evaluator`` (``EvaluatorProtocol``) — when present, the + factory builds a :class:`GovernanceCallbackHandler` and + hands it to the runtime via its ``callbacks`` arg. Returns: Configured runtime instance @@ -271,10 +278,24 @@ async def _create_runtime_instance( storage = SqliteResumableStorage(memory) trigger_manager = UiPathResumeTriggerHandler() + evaluator: EvaluatorProtocol | None = kwargs.get("evaluator") + callbacks: list[BaseCallbackHandler] | None = ( + [ + GovernanceCallbackHandler( + evaluator=evaluator, + agent_name=entrypoint, + session_id=runtime_id, + ) + ] + if evaluator is not None + else None + ) + base_runtime = UiPathLangGraphRuntime( graph=compiled_graph, runtime_id=runtime_id, entrypoint=entrypoint, + callbacks=callbacks, storage=storage, ) @@ -286,7 +307,10 @@ async def _create_runtime_instance( ) async def new_runtime( - self, entrypoint: str, runtime_id: str, **kwargs + self, + entrypoint: str, + runtime_id: str, + **kwargs, ) -> UiPathRuntimeProtocol: """ Create a new LangGraph runtime instance. @@ -294,6 +318,10 @@ async def new_runtime( Args: entrypoint: Graph name from langgraph.json runtime_id: Unique identifier for the runtime instance + **kwargs: Forwarded factory kwargs. Recognized: + ``evaluator`` (``EvaluatorProtocol``) — when present, the + factory wires a :class:`GovernanceCallbackHandler` into + the runtime's callback list. Returns: Configured runtime instance with compiled graph diff --git a/tests/governance/__init__.py b/tests/governance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/governance/test_callbacks.py b/tests/governance/test_callbacks.py new file mode 100644 index 000000000..8fb1f0264 --- /dev/null +++ b/tests/governance/test_callbacks.py @@ -0,0 +1,574 @@ +"""Tests for the LangChain governance callback handler.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from langchain_core.callbacks import BaseCallbackHandler +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration, Generation, LLMResult +from uipath.core.governance.exceptions import GovernanceBlockException + +from uipath_langchain.governance import GovernanceCallbackHandler +from uipath_langchain.governance.callbacks import _BEFORE_MODEL_TEXT_CAP + +LOGGER_PATH = "uipath_langchain.governance.callbacks.logger" + + +@pytest.fixture +def evaluator() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def handler(evaluator: MagicMock) -> GovernanceCallbackHandler: + return GovernanceCallbackHandler( + evaluator=evaluator, + agent_name="test-agent", + session_id="test-session", + ) + + +class TestSubclassesBaseCallbackHandler: + def test_is_base_callback_handler(self, handler: GovernanceCallbackHandler) -> None: + # The handler must be a real LangChain BaseCallbackHandler so + # LangChain's dispatch / tracer wiring treats it natively. + assert isinstance(handler, BaseCallbackHandler) + + def test_ignore_flags_override_parent_properties( + self, handler: GovernanceCallbackHandler + ) -> None: + # Chain notifications skipped — the governance host owns + # BEFORE_AGENT / AFTER_AGENT and would otherwise double-fire. + assert handler.ignore_chain is True + assert handler.ignore_retriever is True + assert handler.ignore_retry is True + assert handler.ignore_custom_event is True + # LLM / chat model / tool / agent events stay on. + assert handler.ignore_llm is False + assert handler.ignore_chat_model is False + assert handler.ignore_agent is False + + +class TestCallbackHandlerLLM: + def test_on_llm_start_invokes_evaluator_with_latest_prompt( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """Only the latest prompt feeds BEFORE_MODEL — prior prompts in a + batched call would re-fire rules on content the LLM has + already responded to in earlier batches.""" + handler.on_llm_start({"name": "m"}, ["a", "b"]) + evaluator.evaluate_before_model.assert_called_once() + kwargs = evaluator.evaluate_before_model.call_args.kwargs + assert kwargs["model_input"] == "b" + assert kwargs["agent_name"] == "test-agent" + assert kwargs["runtime_id"] == "test-session" + # ``trace_id`` is intentionally NOT passed — correlation is + # owned by the layer below the evaluator. The handler is + # env-free. + assert "trace_id" not in kwargs + + def test_on_llm_start_increments_counter( + self, handler: GovernanceCallbackHandler + ) -> None: + handler.on_llm_start({}, ["p"]) + handler.on_llm_start({}, ["p"]) + assert handler._session_state["llm_calls"] == 2 + + def test_on_llm_start_empty_prompts( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_llm_start({}, []) + assert evaluator.evaluate_before_model.call_args.kwargs["model_input"] == "" + + def test_on_llm_start_propagates_block( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + evaluator.evaluate_before_model.side_effect = GovernanceBlockException( + "blocked" + ) + with pytest.raises(GovernanceBlockException): + handler.on_llm_start({}, ["p"]) + + def test_on_llm_start_swallows_other_exceptions( + self, + handler: GovernanceCallbackHandler, + evaluator: MagicMock, + ) -> None: + evaluator.evaluate_before_model.side_effect = RuntimeError("nope") + with patch(LOGGER_PATH) as mock_logger: + handler.on_llm_start({}, ["p"]) # must not raise + mock_logger.warning.assert_called_once() + assert "on_llm_start" in mock_logger.warning.call_args.args[0] + + def test_on_chat_model_start_latest_message_only( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """Only the LAST message in the prompt is scanned. + + Without this scoping, a violation in turn 3's user message + would keep re-firing on every subsequent LLM call because + that text stays in the prompt for context. + """ + handler.on_chat_model_start( + {}, + [[SimpleNamespace(content="hello"), SimpleNamespace(content="world")]], + ) + model_input = evaluator.evaluate_before_model.call_args.kwargs["model_input"] + assert model_input == "world" + assert "hello" not in model_input + + def test_on_chat_model_start_dict_messages_latest_only( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """Dict-shaped (LangGraph state) messages: latest is extracted.""" + handler.on_chat_model_start( + {}, + [[{"content": "from dict"}, {"role": "user", "content": "another"}]], + ) + model_input = evaluator.evaluate_before_model.call_args.kwargs["model_input"] + assert model_input == "another" + assert "from dict" not in model_input + + def test_on_chat_model_start_dict_message_missing_content( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_chat_model_start({}, [[{"role": "user"}]]) + assert evaluator.evaluate_before_model.call_args.kwargs["model_input"] == "" + + def test_on_chat_model_start_list_of_blocks_content( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """Multi-block content (text + function_call) is extracted cleanly. + + Regression for the prior ``str(msg.content)`` path which produced + ``[{'type': ..., 'text': ...}]`` dict-repr noise instead of + clean text. Field-precise rules can't navigate that shape. + """ + latest = SimpleNamespace( + content=[ + {"type": "text", "text": "Here's the answer:"}, + { + "type": "function_call", + "name": "end_execution", + "arguments": '{"content":"Cost: $1,200"}', + "id": "fc_abc", + }, + ] + ) + handler.on_chat_model_start({}, [[SimpleNamespace(content="old"), latest]]) + model_input = evaluator.evaluate_before_model.call_args.kwargs["model_input"] + assert "Here's the answer:" in model_input + assert "Cost: $1,200" in model_input + # No dict-syntax noise from str(list). + assert "{'type'" not in model_input + + def test_on_chat_model_start_empty_messages( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_chat_model_start({}, []) + assert evaluator.evaluate_before_model.call_args.kwargs["model_input"] == "" + + def test_on_chat_model_start_empty_inner_batch( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_chat_model_start({}, [[]]) + assert evaluator.evaluate_before_model.call_args.kwargs["model_input"] == "" + + def test_on_chat_model_start_caps_model_input( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """``model_input`` is bounded so a runaway prompt can't dominate scan time.""" + huge = SimpleNamespace(content="x" * (_BEFORE_MODEL_TEXT_CAP + 1000)) + handler.on_chat_model_start({}, [[huge]]) + model_input = evaluator.evaluate_before_model.call_args.kwargs["model_input"] + assert len(model_input) == _BEFORE_MODEL_TEXT_CAP + + def test_on_chat_model_start_block_list_stops_at_remaining_budget( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """The block walk exits early once the per-call cap is exhausted.""" + first = "a" * _BEFORE_MODEL_TEXT_CAP # consumes the entire budget + latest = SimpleNamespace( + content=[ + {"type": "text", "text": first}, + {"type": "text", "text": "MUST_NOT_APPEAR"}, + ] + ) + handler.on_chat_model_start({}, [[latest]]) + model_input = evaluator.evaluate_before_model.call_args.kwargs["model_input"] + assert "MUST_NOT_APPEAR" not in model_input + assert len(model_input) == _BEFORE_MODEL_TEXT_CAP + + def test_on_chat_model_start_block_list_skips_non_dict_entries( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """Non-dict entries inside a content list are silently skipped.""" + latest = SimpleNamespace( + content=[ + "ignored-string-block", + {"type": "text", "text": "kept"}, + 42, + None, + ] + ) + handler.on_chat_model_start({}, [[latest]]) + model_input = evaluator.evaluate_before_model.call_args.kwargs["model_input"] + assert model_input == "kept" + + def test_on_chat_model_start_propagates_block( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + evaluator.evaluate_before_model.side_effect = GovernanceBlockException("x") + with pytest.raises(GovernanceBlockException): + handler.on_chat_model_start({}, [[SimpleNamespace(content="x")]]) + + def test_on_chat_model_start_swallows_other_exceptions( + self, + handler: GovernanceCallbackHandler, + evaluator: MagicMock, + ) -> None: + evaluator.evaluate_before_model.side_effect = RuntimeError("oops") + with patch(LOGGER_PATH) as mock_logger: + handler.on_chat_model_start({}, [[SimpleNamespace(content="x")]]) + mock_logger.warning.assert_called_once() + assert "on_chat_model_start" in mock_logger.warning.call_args.args[0] + + def test_on_llm_end_extracts_plain_text( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + response = LLMResult(generations=[[Generation(text="output")]]) + handler.on_llm_end(response) + kwargs = evaluator.evaluate_after_model.call_args.kwargs + assert kwargs["model_output"] == "output" + + def test_on_llm_end_response_without_generations( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_llm_end(LLMResult(generations=[])) + assert evaluator.evaluate_after_model.call_args.kwargs["model_output"] == "" + + def test_on_llm_end_propagates_block( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + evaluator.evaluate_after_model.side_effect = GovernanceBlockException("x") + with pytest.raises(GovernanceBlockException): + handler.on_llm_end(LLMResult(generations=[])) + + def test_on_llm_end_caps_model_output( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """A runaway / batched response is capped so the AFTER_MODEL + scan budget matches BEFORE_MODEL and the runtime side's cap. + """ + # Many large generations across batched gen_lists. + big = "y" * 50_000 + response = LLMResult( + generations=[ + [Generation(text=big)], + [Generation(text=big), Generation(text=big)], + ] + ) + handler.on_llm_end(response) + model_output = evaluator.evaluate_after_model.call_args.kwargs["model_output"] + assert len(model_output) == _BEFORE_MODEL_TEXT_CAP + + def test_on_llm_end_skips_empty_generation_text( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """Generations with no extractable text don't bloat the output.""" + response = LLMResult( + generations=[[Generation(text=""), Generation(text="kept")]] + ) + handler.on_llm_end(response) + assert evaluator.evaluate_after_model.call_args.kwargs["model_output"] == "kept" + + def test_on_llm_end_swallows_other_exceptions( + self, + handler: GovernanceCallbackHandler, + evaluator: MagicMock, + ) -> None: + evaluator.evaluate_after_model.side_effect = RuntimeError("nope") + with patch(LOGGER_PATH) as mock_logger: + handler.on_llm_end(LLMResult(generations=[])) + mock_logger.warning.assert_called_once() + assert "on_llm_end" in mock_logger.warning.call_args.args[0] + + def test_on_llm_error_logs( + self, + handler: GovernanceCallbackHandler, + ) -> None: + with patch(LOGGER_PATH) as mock_logger: + handler.on_llm_error(RuntimeError("boom")) + mock_logger.warning.assert_called_once() + assert "LLM error" in mock_logger.warning.call_args.args[0] + + +class TestExtractGenerationText: + def test_returns_text_for_plain_generation(self) -> None: + gen = Generation(text="hello") + assert GovernanceCallbackHandler._extract_generation_text(gen) == "hello" + + def test_chat_generation_string_content(self) -> None: + gen = ChatGeneration(message=AIMessage(content="rich")) + assert GovernanceCallbackHandler._extract_generation_text(gen) == "rich" + + def test_returns_empty_when_generation_text_empty(self) -> None: + assert ( + GovernanceCallbackHandler._extract_generation_text(Generation(text="")) + == "" + ) + + def test_extracts_from_block_list_content(self) -> None: + gen = ChatGeneration( + message=AIMessage( + content=[ + {"type": "text", "text": "alpha"}, + {"type": "tool_use", "input": {"q": "beta"}}, + ] + ) + ) + out = GovernanceCallbackHandler._extract_generation_text(gen) + assert "alpha" in out + assert "beta" in out + + def test_block_list_skips_non_dict_entries(self) -> None: + gen = ChatGeneration( + message=AIMessage( + content=["string-entry", {"type": "text", "text": "kept"}] + ) + ) + assert GovernanceCallbackHandler._extract_generation_text(gen) == "kept" + + def test_chat_generation_with_empty_block_list_falls_back_to_text(self) -> None: + """When all blocks yield no text, fall back to ``gen.text``.""" + gen = ChatGeneration(message=AIMessage(content=[])) + assert GovernanceCallbackHandler._extract_generation_text(gen) == "" + + +class TestExtractBlockText: + def test_plain_text_block(self) -> None: + assert ( + GovernanceCallbackHandler._extract_block_text( + {"type": "text", "text": "hello"} + ) + == "hello" + ) + + def test_function_call_arguments_block(self) -> None: + assert ( + GovernanceCallbackHandler._extract_block_text( + {"type": "function_call", "arguments": '{"a":1}'} + ) + == '{"a":1}' + ) + + def test_thinking_block(self) -> None: + assert ( + GovernanceCallbackHandler._extract_block_text( + {"type": "thinking", "thinking": "step by step"} + ) + == "step by step" + ) + + def test_tool_use_input_extracts_string_values(self) -> None: + result = GovernanceCallbackHandler._extract_block_text( + {"type": "tool_use", "input": {"query": "search", "id": "ignored"}} + ) + assert "search" in result + assert "ignored" in result # both are strings; metadata filtering is by key + + def test_input_ignores_non_string_values(self) -> None: + result = GovernanceCallbackHandler._extract_block_text( + {"input": {"a": 123, "b": ["nested"], "c": "kept"}} + ) + assert result == "kept" + + def test_metadata_only_block_returns_empty(self) -> None: + assert ( + GovernanceCallbackHandler._extract_block_text( + {"type": "tool_use", "id": "abc", "name": "search", "status": "ok"} + ) + == "" + ) + + def test_combined_fields_all_collected(self) -> None: + result = GovernanceCallbackHandler._extract_block_text( + { + "type": "tool_use", + "text": "T", + "arguments": "A", + "thinking": "Th", + "input": {"k": "I"}, + } + ) + for token in ("T", "A", "Th", "I"): + assert token in result + + def test_empty_block(self) -> None: + assert GovernanceCallbackHandler._extract_block_text({}) == "" + + +class TestCallbackHandlerTools: + def test_on_tool_start_with_inputs( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_tool_start({"name": "search"}, "fallback", inputs={"q": "v"}) + kwargs = evaluator.evaluate_tool_call.call_args.kwargs + assert kwargs["tool_name"] == "search" + assert kwargs["tool_args"] == {"q": "v"} + assert kwargs["session_state"] is handler._session_state + + def test_on_tool_start_without_inputs_uses_input_str( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_tool_start({"name": "calc"}, "1+2") + kwargs = evaluator.evaluate_tool_call.call_args.kwargs + assert kwargs["tool_args"] == {"input": "1+2"} + + def test_on_tool_start_unknown_name_when_missing( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_tool_start({}, "x") + assert evaluator.evaluate_tool_call.call_args.kwargs["tool_name"] == "unknown" + + def test_on_tool_start_increments_counter( + self, handler: GovernanceCallbackHandler + ) -> None: + handler.on_tool_start({}, "x") + handler.on_tool_start({}, "y") + assert handler._session_state["tool_calls"] == 2 + + def test_on_tool_start_propagates_block( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + evaluator.evaluate_tool_call.side_effect = GovernanceBlockException("no") + with pytest.raises(GovernanceBlockException): + handler.on_tool_start({}, "x") + + def test_on_tool_start_swallows_other_exceptions( + self, + handler: GovernanceCallbackHandler, + evaluator: MagicMock, + ) -> None: + evaluator.evaluate_tool_call.side_effect = RuntimeError("nope") + with patch(LOGGER_PATH) as mock_logger: + handler.on_tool_start({}, "x") + mock_logger.warning.assert_called_once() + assert "on_tool_start" in mock_logger.warning.call_args.args[0] + + def test_on_tool_end_with_output( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_tool_end({"answer": 42}) + kwargs = evaluator.evaluate_after_tool.call_args.kwargs + assert "42" in kwargs["tool_result"] + assert kwargs["tool_name"] == "unknown" + + def test_on_tool_end_uses_tool_name_from_run_id( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_tool_start({"name": "search"}, "q", run_id="run-1") + handler.on_tool_end("result", run_id="run-1") + assert evaluator.evaluate_after_tool.call_args.kwargs["tool_name"] == "search" + # The run_id mapping is cleaned up so a stale entry isn't reused. + assert "run-1" not in handler._tool_runs + + def test_on_tool_end_unknown_when_run_id_not_recorded( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_tool_end("r", run_id="never-started") + assert evaluator.evaluate_after_tool.call_args.kwargs["tool_name"] == "unknown" + + def test_on_tool_start_handles_none_serialized( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_tool_start(None, "x") # type: ignore[arg-type] + assert evaluator.evaluate_tool_call.call_args.kwargs["tool_name"] == "unknown" + + def test_on_tool_end_with_none_output( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + handler.on_tool_end(None) + assert evaluator.evaluate_after_tool.call_args.kwargs["tool_result"] == "" + + def test_on_tool_end_propagates_block( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + evaluator.evaluate_after_tool.side_effect = GovernanceBlockException("x") + with pytest.raises(GovernanceBlockException): + handler.on_tool_end("out") + + def test_on_tool_end_swallows_other_exceptions( + self, + handler: GovernanceCallbackHandler, + evaluator: MagicMock, + ) -> None: + evaluator.evaluate_after_tool.side_effect = RuntimeError("err") + with patch(LOGGER_PATH) as mock_logger: + handler.on_tool_end("out") + mock_logger.warning.assert_called_once() + assert "on_tool_end" in mock_logger.warning.call_args.args[0] + + def test_on_tool_error_logs( + self, + handler: GovernanceCallbackHandler, + ) -> None: + with patch(LOGGER_PATH) as mock_logger: + handler.on_tool_error(RuntimeError("broke")) + mock_logger.warning.assert_called_once() + assert "Tool error" in mock_logger.warning.call_args.args[0] + + def test_on_tool_error_pops_run_id_mapping( + self, handler: GovernanceCallbackHandler + ) -> None: + """``on_tool_error`` cleans up ``_tool_runs`` so failed tool calls + don't accumulate over the lifetime of a governed session. + """ + handler.on_tool_start({"name": "search"}, "q", run_id="run-err") + assert handler._tool_runs.get("run-err") == "search" + handler.on_tool_error(RuntimeError("boom"), run_id="run-err") + assert "run-err" not in handler._tool_runs + + def test_on_tool_error_without_run_id_does_not_crash( + self, handler: GovernanceCallbackHandler + ) -> None: + # No run_id kwargs — should still log and not raise. + handler.on_tool_error(RuntimeError("boom")) + assert handler._tool_runs == {} + + def test_on_tool_start_block_pops_run_id_mapping( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """If BEFORE_TOOL evaluation BLOCKS, the recorded mapping is + dropped — the tool never runs and ``on_tool_end`` will not fire. + Leaving the entry would leak across blocked turns. + """ + evaluator.evaluate_tool_call.side_effect = GovernanceBlockException("nope") + with pytest.raises(GovernanceBlockException): + handler.on_tool_start({"name": "search"}, "q", run_id="run-blocked") + assert "run-blocked" not in handler._tool_runs + + def test_on_tool_start_swallowed_error_preserves_mapping( + self, handler: GovernanceCallbackHandler, evaluator: MagicMock + ) -> None: + """When the evaluator raises a non-block exception, we swallow + and the tool still runs — the mapping must survive so + ``on_tool_end`` can resolve the tool name. + """ + evaluator.evaluate_tool_call.side_effect = RuntimeError("flaky") + with patch(LOGGER_PATH): + handler.on_tool_start({"name": "search"}, "q", run_id="run-flaky") + assert handler._tool_runs.get("run-flaky") == "search" + + +class TestCallbackHandlerInit: + def test_session_state_initialized(self, evaluator: MagicMock) -> None: + h = GovernanceCallbackHandler( + evaluator=evaluator, agent_name="a", session_id="s" + ) + assert h._session_state == {"tool_calls": 0, "llm_calls": 0} + assert h._agent_name == "a" + assert h._session_id == "s" diff --git a/tests/runtime/test_factory_governance.py b/tests/runtime/test_factory_governance.py new file mode 100644 index 000000000..f13d16bfa --- /dev/null +++ b/tests/runtime/test_factory_governance.py @@ -0,0 +1,116 @@ +"""Factory-level governance wiring: evaluator -> callbacks plumbing.""" + +from __future__ import annotations + +import os +import tempfile +from typing import Any, TypedDict +from unittest.mock import MagicMock + +import pytest +from langgraph.graph import END, START, StateGraph +from uipath.core.adapters import EvaluatorProtocol +from uipath.runtime import UiPathRuntimeContext + +from uipath_langchain.governance import GovernanceCallbackHandler +from uipath_langchain.runtime.factory import UiPathLangGraphRuntimeFactory + + +class _State(TypedDict): + v: int + + +def _build_graph() -> StateGraph[Any, Any, Any]: + g = StateGraph(_State) + g.add_node("noop", lambda s: s) + g.add_edge(START, "noop") + g.add_edge("noop", END) + return g + + +@pytest.fixture +def context() -> UiPathRuntimeContext: + tmpdir = tempfile.mkdtemp() + ctx = UiPathRuntimeContext( + runtime_dir=tmpdir, + state_file=os.path.join(tmpdir, "state.db"), + ) + return ctx + + +@pytest.fixture +def factory(context: UiPathRuntimeContext) -> UiPathLangGraphRuntimeFactory: + return UiPathLangGraphRuntimeFactory(context) + + +class TestEvaluatorWiring: + """Passing ``evaluator`` to ``new_runtime`` should attach a + :class:`GovernanceCallbackHandler` to the underlying LangGraph + runtime's callback list. This is the entire surface change — the + previous adapter / register-on-import path is gone. + """ + + async def test_no_evaluator_means_no_callbacks( + self, factory: UiPathLangGraphRuntimeFactory + ) -> None: + compiled = _build_graph().compile() + await factory._get_memory() + runtime = await factory._create_runtime_instance( + compiled_graph=compiled, + runtime_id="rt-1", + entrypoint="ep", + ) + # The resumable runtime wraps the langgraph runtime as ``delegate``. + assert runtime.delegate.callbacks == [] # type: ignore[attr-defined] + await factory.dispose() + + async def test_evaluator_attaches_governance_handler( + self, factory: UiPathLangGraphRuntimeFactory + ) -> None: + evaluator: EvaluatorProtocol = MagicMock(spec=EvaluatorProtocol) + compiled = _build_graph().compile() + await factory._get_memory() # ensure memory is initialized + runtime = await factory._create_runtime_instance( + compiled_graph=compiled, + runtime_id="rt-1", + entrypoint="ep", + evaluator=evaluator, + ) + callbacks = runtime.delegate.callbacks # type: ignore[attr-defined] + assert len(callbacks) == 1 + handler = callbacks[0] + assert isinstance(handler, GovernanceCallbackHandler) + # Identity / session_id / agent_name come from the factory args. + assert handler._evaluator is evaluator + assert handler._agent_name == "ep" + assert handler._session_id == "rt-1" + await factory.dispose() + + async def test_handler_built_per_runtime_instance( + self, factory: UiPathLangGraphRuntimeFactory + ) -> None: + """Two factory calls with the same evaluator yield two distinct + handler instances — each runtime gets its own session_state, so + concurrent sessions don't share counters.""" + evaluator: EvaluatorProtocol = MagicMock(spec=EvaluatorProtocol) + compiled = _build_graph().compile() + await factory._get_memory() + first = await factory._create_runtime_instance( + compiled_graph=compiled, + runtime_id="rt-a", + entrypoint="ep", + evaluator=evaluator, + ) + second = await factory._create_runtime_instance( + compiled_graph=compiled, + runtime_id="rt-b", + entrypoint="ep", + evaluator=evaluator, + ) + h1 = first.delegate.callbacks[0] # type: ignore[attr-defined] + h2 = second.delegate.callbacks[0] # type: ignore[attr-defined] + assert h1 is not h2 + assert h1._session_id == "rt-a" + assert h2._session_id == "rt-b" + assert h1._session_state is not h2._session_state + await factory.dispose() diff --git a/uv.lock b/uv.lock index 208a31def..d7af92a5b 100644 --- a/uv.lock +++ b/uv.lock @@ -628,6 +628,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -4395,7 +4432,7 @@ wheels = [ [[package]] name = "uipath" -version = "2.11.18" +version = "2.12.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "applicationinsights" }, @@ -4418,9 +4455,9 @@ dependencies = [ { name = "uipath-platform" }, { name = "uipath-runtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/b6/034ae9a0b58541ca7d719cd8a7c92a5738527ac2d28bd8e3c5b9b4fc2e56/uipath-2.11.18.tar.gz", hash = "sha256:449dcf94b2f8643db6c172b11fc7c52d75e818c6768953ad3d82e9a41f840684", size = 4466993, upload-time = "2026-06-30T16:59:16.946Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/ad/988407115f57d96b240129ca6fc81dc0ebb13c48c66fe29596bc506cdb22/uipath-2.12.4.tar.gz", hash = "sha256:aff02b54aa5d1a4297e4bf4af0f85cdf917c9d8ad7e8220653b821e956c6ee5c", size = 4470227, upload-time = "2026-07-01T16:19:16.851Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/b5/4c2dfc05799173b08d180ebc7052b0c4de316fdd0cb2881d8f7c63f5691c/uipath-2.11.18-py3-none-any.whl", hash = "sha256:d193066f22b7bf91189230714c738ec1b69f15f820d811a2304ed0ed74173762", size = 408128, upload-time = "2026-06-30T16:59:14.121Z" }, + { url = "https://files.pythonhosted.org/packages/b3/db/5fbfe8965b5c14673f9a20d2b4bd8212b41dfb2f6dbaf04774136223dc6c/uipath-2.12.4-py3-none-any.whl", hash = "sha256:d2b022f91f5046c8e1560fb002dc0d8c534ed44569d5939223404e248437829b", size = 408359, upload-time = "2026-07-01T16:19:15.018Z" }, ] [[package]] @@ -4439,7 +4476,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.13.18" +version = "0.13.19" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, @@ -4516,8 +4553,8 @@ requires-dist = [ { name = "pillow", specifier = ">=12.1.1" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-dotenv", specifier = ">=1.0.1" }, - { name = "uipath", specifier = ">=2.11.14,<2.12.0" }, - { name = "uipath-core", specifier = ">=0.5.20,<0.6.0" }, + { name = "uipath", specifier = ">=2.12.4,<2.13.0" }, + { name = "uipath-core", specifier = ">=0.5.28,<0.6.0" }, { name = "uipath-langchain-client", extras = ["all"], marker = "extra == 'all'", specifier = ">=1.14.1,<1.15.0" }, { name = "uipath-langchain-client", extras = ["anthropic"], marker = "extra == 'anthropic'", specifier = ">=1.14.1,<1.15.0" }, { name = "uipath-langchain-client", extras = ["bedrock"], marker = "extra == 'bedrock'", specifier = ">=1.14.1,<1.15.0" }, @@ -4525,8 +4562,8 @@ requires-dist = [ { name = "uipath-langchain-client", extras = ["google"], marker = "extra == 'vertex'", specifier = ">=1.14.1,<1.15.0" }, { name = "uipath-langchain-client", extras = ["openai"], specifier = ">=1.14.1,<1.15.0" }, { name = "uipath-langchain-client", extras = ["vertexai"], marker = "extra == 'vertex'", specifier = ">=1.14.1,<1.15.0" }, - { name = "uipath-platform", specifier = ">=0.1.86,<0.2.0" }, - { name = "uipath-runtime", specifier = ">=0.11.4,<0.12.0" }, + { name = "uipath-platform", specifier = ">=0.1.89,<0.2.0" }, + { name = "uipath-runtime", specifier = ">=0.11.6,<0.12.0" }, ] provides-extras = ["anthropic", "vertex", "bedrock", "fireworks", "all"] @@ -4609,7 +4646,7 @@ wheels = [ [[package]] name = "uipath-platform" -version = "0.1.86" +version = "0.1.89" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -4619,21 +4656,23 @@ dependencies = [ { name = "truststore" }, { name = "uipath-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/7e/7a47d484b6e9e18d5a752cd3c5e657178800e5561bbaec4c97acfc5ff359/uipath_platform-0.1.86.tar.gz", hash = "sha256:4c05484783d579a5c0830f0537172241ceaecbbba2726f8e992c0ffdfe288328", size = 396523, upload-time = "2026-07-01T08:45:36.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/36/8c7a6fa80251c9c2236225361a711db6b208079d40209c1a4330951d6b01/uipath_platform-0.1.89.tar.gz", hash = "sha256:802cf6faf6fb9bf124bcc7eb3d6bd9cd9d24f24a339b23fe58eafe463cf3cb3c", size = 408956, upload-time = "2026-07-01T16:17:13.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/57/91247f622bc5aec1dff3566f8f3099d12caea58a0c3968a6f1c64af885dd/uipath_platform-0.1.86-py3-none-any.whl", hash = "sha256:4ac4529fc3b61b7fb17bd28c1b52309029017ac672425473a2271314da929234", size = 262817, upload-time = "2026-07-01T08:45:34.377Z" }, + { url = "https://files.pythonhosted.org/packages/3a/41/1b4190006658020b90068d44834c76a1ed03ac736bae6f7fa5cf649a5b25/uipath_platform-0.1.89-py3-none-any.whl", hash = "sha256:730f31f2ac4992f68dbe37270aacb9a73ed84c7a35921e53670665652c950c47", size = 268153, upload-time = "2026-07-01T16:17:11.889Z" }, ] [[package]] name = "uipath-runtime" -version = "0.11.5" +version = "0.11.6" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "chardet" }, { name = "uipath-core" }, + { name = "vadersentiment" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/f1/28ca53eee0176a5f2a4985f82a1d184fe01c21d84043557723e2c22e242f/uipath_runtime-0.11.5.tar.gz", hash = "sha256:a1f04c4199875ab72055082edd30c3546fd3ed80d1d70ed2c042b4b4047f8935", size = 152819, upload-time = "2026-06-29T16:08:00.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/bfe5fedbb50dc8bee83c4e94ce52de8b4ba593384bb7632f52f4aba9d80f/uipath_runtime-0.11.6.tar.gz", hash = "sha256:8d5ba5ad15956df5a478e3be2d0119781e582746cbee12e6bce9b0d4a8e14623", size = 231808, upload-time = "2026-07-01T18:13:58.216Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/58/bd6c42a11b4eb7fd98f8a5b1b7e19e3b15d301c6894154320312086ff5c9/uipath_runtime-0.11.5-py3-none-any.whl", hash = "sha256:e665e10e3beaeba3dae48e354927604fba460568f73694c74e76d018a18d44af", size = 49864, upload-time = "2026-06-29T16:07:58.773Z" }, + { url = "https://files.pythonhosted.org/packages/50/54/8650f261a5d09d77fc4e9b6da7d4fb7c01b7547545db61be41c3cfdb5e9d/uipath_runtime-0.11.6-py3-none-any.whl", hash = "sha256:565b8aa96f97d229d283673dd3fde34078b785984fe5147d995e9ae2115f0f2a", size = 91104, upload-time = "2026-07-01T18:13:56.584Z" }, ] [[package]] @@ -4745,6 +4784,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, ] +[[package]] +name = "vadersentiment" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" }, +] + [[package]] name = "validators" version = "0.35.0"