From 64f84ab0eac4ed92c8eb3a227adace49cc63bcfd Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Wed, 8 Apr 2026 14:36:23 -0700 Subject: [PATCH] feat: boazdavid policies component added (#12564) * feat: @boazdavid policies component added * Update component_index.json * [autofix.ci] apply automated fixes * Update component_index.json * Update component_index.json --------- Co-authored-by: DAVID BOAZ Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .gitignore | 1 + .vscode/launch.json | 4 +- docs/docs/Components/policies.mdx | 50 +++ pyproject.toml | 2 + src/backend/base/pyproject.toml | 5 + .../models_and_agents/policies/__init__.py | 3 + .../policies/test_guarded_tool.py | 130 +++++++ .../policies/test_llm_wrapper.py | 300 ++++++++++++++++ .../policies/test_policies_component.py | 286 +++++++++++++++ .../policies/test_tool_invoker.py | 271 ++++++++++++++ src/lfx/src/lfx/_assets/component_index.json | 263 +++++++++++++- .../src/lfx/_assets/stable_hash_history.json | 10 + src/lfx/src/lfx/base/mcp/util.py | 2 +- .../components/models_and_agents/__init__.py | 3 + .../models_and_agents/policies/__init__.py | 4 + .../policies/guard_sync_utils.py | 99 +++++ .../policies/guarded_tool.py | 104 ++++++ .../models_and_agents/policies/llm_wrapper.py | 182 ++++++++++ .../policies/module_utils.py | 22 ++ .../policies/tool_invoker.py | 49 +++ .../models_and_agents/policies_component.py | 339 ++++++++++++++++++ uv.lock | 253 +++++++++++-- 22 files changed, 2352 insertions(+), 30 deletions(-) create mode 100644 docs/docs/Components/policies.mdx create mode 100644 src/backend/tests/unit/components/models_and_agents/policies/__init__.py create mode 100644 src/backend/tests/unit/components/models_and_agents/policies/test_guarded_tool.py create mode 100644 src/backend/tests/unit/components/models_and_agents/policies/test_llm_wrapper.py create mode 100644 src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py create mode 100644 src/backend/tests/unit/components/models_and_agents/policies/test_tool_invoker.py create mode 100644 src/lfx/src/lfx/components/models_and_agents/policies/__init__.py create mode 100644 src/lfx/src/lfx/components/models_and_agents/policies/guard_sync_utils.py create mode 100644 src/lfx/src/lfx/components/models_and_agents/policies/guarded_tool.py create mode 100644 src/lfx/src/lfx/components/models_and_agents/policies/llm_wrapper.py create mode 100644 src/lfx/src/lfx/components/models_and_agents/policies/module_utils.py create mode 100644 src/lfx/src/lfx/components/models_and_agents/policies/tool_invoker.py create mode 100644 src/lfx/src/lfx/components/models_and_agents/policies_component.py diff --git a/.gitignore b/.gitignore index d7ceafc9fc..f397b68dba 100644 --- a/.gitignore +++ b/.gitignore @@ -292,3 +292,4 @@ sso-config.yaml AGENTS.md CLAUDE.local.md langflow.log.* +tmp_toolguard/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 201ec9af31..f420bd007d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,7 +24,9 @@ "--reload-include", "./src/lfx/*", "--reload-exclude", - "*.db*" + "*.db*", + "--reload-exclude", + "./tmp_toolguard/*" ], "jinja": true, "justMyCode": false, diff --git a/docs/docs/Components/policies.mdx b/docs/docs/Components/policies.mdx new file mode 100644 index 0000000000..41f37dae3b --- /dev/null +++ b/docs/docs/Components/policies.mdx @@ -0,0 +1,50 @@ +# Policies Component + +## Overview + +The **Policies Component** is a powerful tool for building tool protection code from textual business policies and instructions. It leverages [ToolGuard](https://github.com/AgentToolkit/toolguard) to automatically generate guard code that validates tool execution against defined business policies. + +This component enables developers to: +- Define business policies in natural language and seamlessly integrate policy enforcement into agent workflows +- Automatically generate validation code for tools based on these policies +- Protect tool execution by enforcing policy compliance at runtime +- Cache generated guard code for improved performance + +The component operates in two modes: +1. **Generate Mode**: Invokes ToolGuard's buildtime process to generate new guard code from policies +2. **Use Cache Mode**: Uses previously generated guard code for faster execution + +## Input Parameters + +| Parameter Name | Type | Description | Required In Mode | +|----------------|------|-------------|------------------| +| `Active` | `bool` | If `true`, invokes ToolGuard code prior to tool execution. If `false`, skips policy validation. | Both | +| `Build Mode` | `Generate` or `Use Cache` | Indicates whether to invoke buildtime (Generate) or use cached code (Use Cache). | Both | +| `ToolGuard Project` | `str` | Name for the ToolGuard project. Used to organize automatically generated ToolGuards code. Default: "my_project" | Both | +| `Tools` | `List[Tool]` | List of tools that the agent can use. These tools will be wrapped with policy guards. | Both | +| `Policies` | `List[str]` | One or more clear, well-defined and self-contained business policies. Accepts multiple policy strings. | Generate | +| `Language Model` | `Model` | Language model provider selection for policy processing. We recommend using Anthropic Claude-Sonnet series for this task: high-performance models designed for code (among others).",| Generate | +| `API key` | `str` | API key for the selected model provider. | Generate | + +## Output + +| Output Name | Type | Description | +|-------------|------|-------------| +| `Guarded Tools` | `List[Tool]` | List of guarded tools with policy enforcement applied. Returns the original tools if policies are not activated. | + +## Usage Notes + +- The component requires at least one policy to be defined when `active` is `true` +- Generated guard code is stored in a working directory structure: `tmp_toolguard/{project_name}/Step_1/` and `tmp_toolguard/{project_name}/Step_2/` +- The component performs a two-step process: + 1. **Step 1**: Generate guard specifications from policies (stored in `Step_1/`) + 2. **Step 2**: Generate executable guard code from specifications (stored in `Step_2/`) +- When switching between `Generate` and `Use Cache` modes, ensure the cache directory contains valid guard code +- The component automatically handles module caching and cleanup + +## Technical Details + +- **Display Name**: Policies +- **Documentation**: [ToolGuard GitHub](https://github.com/AgentToolkit/toolguard) +- **Status**: Beta + diff --git a/pyproject.toml b/pyproject.toml index dd4d21cdcc..c93e4c4cbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ maintainers = [ ] # Define your main dependencies here dependencies = [ + "click>=8.3.2", "langflow-base[complete]~=0.9.0", ] @@ -145,6 +146,7 @@ override-dependencies = [ "dynaconf>=3.2.13", "pillow>=12.0.0", # Force Pillow 12+ to prevent CVE-vulnerable 11.x versions "aiohttp>=3.13.4", + "click>=8.3.2", # Override transitive dependency constraint. from toolguard>mellea>click ] [project.scripts] diff --git a/src/backend/base/pyproject.toml b/src/backend/base/pyproject.toml index 9bfaf87e0a..3b5dc7d9da 100644 --- a/src/backend/base/pyproject.toml +++ b/src/backend/base/pyproject.toml @@ -290,6 +290,9 @@ spider = ["spider-client>=0.0.27,<1.0.0"] # Excluded on macOS x86_64: PyTorch dropped Intel Mac wheel builds after v2.2.2 (see LE-172) altk = ["agent-lifecycle-toolkit>=0.10.1,<1.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"] +# Toolguard Integration +toolguard = ["toolguard>=0.2.14,<1.0.0"] + # Additional LangChain integrations # Excluded on macOS x86_64: transitive torch dependency (via sentence-transformers) has no Intel Mac wheels (see LE-172) langchain-huggingface = ["langchain-huggingface~=1.2.0; sys_platform != 'darwin' or platform_machine != 'x86_64'"] @@ -427,6 +430,8 @@ complete = [ "langflow-base[spider]", # ALTK "langflow-base[altk]", + # Toolguard + "langflow-base[toolguard]", # Additional LangChain integrations "langflow-base[langchain-huggingface]", "langflow-base[langchain-unstructured]", diff --git a/src/backend/tests/unit/components/models_and_agents/policies/__init__.py b/src/backend/tests/unit/components/models_and_agents/policies/__init__.py new file mode 100644 index 0000000000..f0781c5bd1 --- /dev/null +++ b/src/backend/tests/unit/components/models_and_agents/policies/__init__.py @@ -0,0 +1,3 @@ +"""Unit tests for policies components.""" + +# Made with Bob diff --git a/src/backend/tests/unit/components/models_and_agents/policies/test_guarded_tool.py b/src/backend/tests/unit/components/models_and_agents/policies/test_guarded_tool.py new file mode 100644 index 0000000000..7ee2cbd3fb --- /dev/null +++ b/src/backend/tests/unit/components/models_and_agents/policies/test_guarded_tool.py @@ -0,0 +1,130 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from langchain_core.tools import StructuredTool +from lfx.components.models_and_agents.policies.guarded_tool import GuardedTool +from pydantic import BaseModel +from toolguard.runtime import PolicyViolationException + + +class Person(BaseModel): + name: str + age: int + + +def my_function(person: Person) -> str: + """Format a person's information as a string. + + Args: + person: A Person object containing name and age. + + Returns: + A formatted string with the person's name and age. + """ + return f"{person.name} is {person.age} years old" + + +@pytest.mark.asyncio +async def test_guarded_tool_successful_execution(): + """Test GuardedTool with successful policy validation.""" + lc_tool = StructuredTool.from_function(my_function) + + # Mock the toolguard context manager and guard_toolcall + mock_toolguard = MagicMock() + mock_toolguard.guard_toolcall = AsyncMock() + mock_toolguard.__enter__ = MagicMock(return_value=mock_toolguard) + mock_toolguard.__exit__ = MagicMock(return_value=None) + + guarded_tool = GuardedTool(lc_tool, [lc_tool], mock_toolguard) + + # Test with dict input + result = await guarded_tool.arun({"person": {"name": "Alice", "age": 30}}) + + # Verify guard_toolcall was called + mock_toolguard.guard_toolcall.assert_called_once() + assert "Alice is 30 years old" in result + + +@pytest.mark.asyncio +async def test_guarded_tool_policy_violation(): + """Test GuardedTool when policy is violated.""" + lc_tool = StructuredTool.from_function(my_function) + + # Mock the toolguard to raise PolicyViolationException + mock_toolguard = MagicMock() + mock_toolguard.guard_toolcall = AsyncMock(side_effect=PolicyViolationException("Age must be under 25")) + mock_toolguard.__enter__ = MagicMock(return_value=mock_toolguard) + mock_toolguard.__exit__ = MagicMock(return_value=None) + + guarded_tool = GuardedTool(lc_tool, [lc_tool], mock_toolguard) + + # Test with dict input that violates policy + result = await guarded_tool.arun({"person": {"name": "Bob", "age": 30}}) + + # Verify the error response structure + assert result["ok"] is False + assert result["error"]["type"] == "PolicyViolationException" + assert result["error"]["code"] == "FAILURE" + assert "Age must be under 25" in result["error"]["message"] + assert result["error"]["retryable"] is True + + +@pytest.mark.asyncio +async def test_guarded_tool_parse_input_string(): + """Test parse_input with string input.""" + lc_tool = StructuredTool.from_function(my_function) + + # Mock the toolguard + mock_toolguard = MagicMock() + mock_toolguard.__enter__ = MagicMock(return_value=mock_toolguard) + mock_toolguard.__exit__ = MagicMock(return_value=None) + + guarded_tool = GuardedTool(lc_tool, [lc_tool], mock_toolguard) + + # Test JSON string + result = guarded_tool.parse_input('{"name": "Charlie", "age": 25}') + assert result == {"name": "Charlie", "age": 25} + + # Test non-JSON string (should wrap in dict) + result = guarded_tool.parse_input("plain text") + assert result == {"input": "plain text"} + + +@pytest.mark.asyncio +async def test_guarded_tool_parse_input_toolcall(): + """Test parse_input with ToolCall dict format.""" + lc_tool = StructuredTool.from_function(my_function) + + # Mock the toolguard + mock_toolguard = MagicMock() + mock_toolguard.__enter__ = MagicMock(return_value=mock_toolguard) + mock_toolguard.__exit__ = MagicMock(return_value=None) + + guarded_tool = GuardedTool(lc_tool, [lc_tool], mock_toolguard) + + # Test with args as JSON string + result = guarded_tool.parse_input({"args": '{"name": "Dave", "age": 35}'}) + assert result == {"name": "Dave", "age": 35} + + # Test with args as dict + result = guarded_tool.parse_input({"args": {"name": "Eve", "age": 40}}) + assert result == {"name": "Eve", "age": 40} + + # Test with args as non-JSON string + result = guarded_tool.parse_input({"args": "invalid json"}) + assert result == {"input": "invalid json"} + + +def test_guarded_tool_run_not_implemented(): + """Test that sync run() raises NotImplementedError.""" + lc_tool = StructuredTool.from_function(my_function) + + # Mock the toolguard + mock_toolguard = MagicMock() + mock_toolguard.__enter__ = MagicMock(return_value=mock_toolguard) + mock_toolguard.__exit__ = MagicMock(return_value=None) + + guarded_tool = GuardedTool(lc_tool, [lc_tool], mock_toolguard) + + with pytest.raises(NotImplementedError): + guarded_tool.run({"person": {"name": "Test", "age": 20}}) diff --git a/src/backend/tests/unit/components/models_and_agents/policies/test_llm_wrapper.py b/src/backend/tests/unit/components/models_and_agents/policies/test_llm_wrapper.py new file mode 100644 index 0000000000..366ca17d78 --- /dev/null +++ b/src/backend/tests/unit/components/models_and_agents/policies/test_llm_wrapper.py @@ -0,0 +1,300 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration, LLMResult +from lfx.components.models_and_agents.policies.llm_wrapper import LangchainModelWrapper + + +@pytest.fixture +def mock_langchain_model(): + """Create a mock BaseChatModel for testing.""" + model = MagicMock() + model.max_tokens = None + model.agenerate = AsyncMock() + return model + + +@pytest.fixture +def wrapper(mock_langchain_model): + """Create a LangchainModelWrapper instance with mocked model.""" + return LangchainModelWrapper(mock_langchain_model) + + +def create_llm_result(content: str, finish_reason: str = "stop") -> LLMResult: + """Helper to create a mock LLMResult.""" + message = AIMessage(content=content) + generation = ChatGeneration(message=message, generation_info={"finish_reason": finish_reason}) + return LLMResult(generations=[[generation]]) + + +class TestInitialization: + """Tests for LangchainModelWrapper initialization.""" + + @pytest.mark.asyncio + async def test_init_sets_max_tokens(self, mock_langchain_model): + """Test that __init__ sets max_tokens to DEFAULT_MAX_TOKENS if it's None.""" + assert mock_langchain_model.max_tokens is None + + wrapper = LangchainModelWrapper(mock_langchain_model) + + assert getattr(wrapper.langchain_model, "max_tokens", None) == LangchainModelWrapper.DEFAULT_MAX_OUT_TOKENS + + @pytest.mark.asyncio + async def test_init_preserves_existing_max_tokens(self): + """Test that __init__ doesn't override existing max_tokens.""" + model = MagicMock() + model.max_tokens = 5000 + model.agenerate = AsyncMock() + + wrapper = LangchainModelWrapper(model) + + assert getattr(wrapper.langchain_model, "max_tokens", None) == 5000 + + @pytest.mark.asyncio + async def test_init_without_max_tokens_attribute(self): + """Test that __init__ handles models without max_tokens attribute.""" + model = MagicMock(spec=["agenerate"]) # No max_tokens attribute + model.agenerate = AsyncMock() + + # Should not raise an error + wrapper = LangchainModelWrapper(model) + + assert wrapper.langchain_model == model + + +class TestRoleConversion: + """Tests for role conversion logic.""" + + def test_convert_role_user_to_human(self, wrapper): + """Test that 'user' role converts to 'human'.""" + assert wrapper._convert_role("user") == "human" + + def test_convert_role_assistant_to_ai(self, wrapper): + """Test that 'assistant' role converts to 'ai'.""" + assert wrapper._convert_role("assistant") == "ai" + + def test_convert_role_system_to_system(self, wrapper): + """Test that 'system' role stays as 'system'.""" + assert wrapper._convert_role("system") == "system" + + def test_convert_role_unknown_defaults_to_system(self, wrapper): + """Test that unknown roles default to 'system'.""" + assert wrapper._convert_role("unknown") == "system" + assert wrapper._convert_role("") == "system" + + +class TestMessageValidation: + """Tests for message validation.""" + + def test_validate_messages_valid(self, wrapper): + """Test validation passes for valid messages.""" + messages = [{"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there"}] + # Should not raise + wrapper._validate_messages(messages) + + def test_validate_messages_not_list(self, wrapper): + """Test validation fails if messages is not a list.""" + with pytest.raises(TypeError, match="Messages must be a list"): + wrapper._validate_messages("not a list") + + def test_validate_messages_item_not_dict(self, wrapper): + """Test validation fails if message item is not a dict.""" + messages = [{"role": "user", "content": "Hello"}, "not a dict"] + with pytest.raises(TypeError, match="Message at index 1 must be a dict"): + wrapper._validate_messages(messages) + + def test_validate_messages_missing_role(self, wrapper): + """Test validation fails if message missing 'role'.""" + messages = [{"content": "Hello"}] + with pytest.raises(ValueError, match="missing 'role' field"): + wrapper._validate_messages(messages) + + def test_validate_messages_missing_content(self, wrapper): + """Test validation fails if message missing 'content'.""" + messages = [{"role": "user"}] + with pytest.raises(ValueError, match="missing 'content' field"): + wrapper._validate_messages(messages) + + +class TestContentExtraction: + """Tests for content extraction logic.""" + + def test_extract_content_string(self, wrapper): + """Test extracting string content.""" + assert wrapper._extract_content("Hello") == "Hello" + + def test_extract_content_none(self, wrapper): + """Test extracting None returns empty string.""" + assert wrapper._extract_content(None) == "" + + def test_extract_content_list(self, wrapper): + """Test extracting list joins with space.""" + assert wrapper._extract_content(["Hello", "world"]) == "Hello world" + + def test_extract_content_tuple(self, wrapper): + """Test extracting tuple joins with space.""" + assert wrapper._extract_content(("Hello", "world")) == "Hello world" + + def test_extract_content_number(self, wrapper): + """Test extracting number converts to string.""" + assert wrapper._extract_content(42) == "42" + + def test_extract_content_mixed_list(self, wrapper): + """Test extracting list with mixed types.""" + assert wrapper._extract_content(["Hello", 42, None]) == "Hello 42 None" + + +class TestGenerate: + """Tests for the generate method.""" + + @pytest.mark.asyncio + async def test_generate_simple_message(self, wrapper, mock_langchain_model): + """Test generate with a simple message that completes successfully.""" + messages = [{"role": "user", "content": "Hello, how are you?"}] + + mock_langchain_model.agenerate.return_value = create_llm_result("I'm doing well, thank you!") + + result = await wrapper.generate(messages) + + assert result == "I'm doing well, thank you!" + assert mock_langchain_model.agenerate.call_count == 1 + + @pytest.mark.asyncio + async def test_generate_multiple_messages(self, wrapper, mock_langchain_model): + """Test generate with multiple messages.""" + messages = [ + {"role": "system", "content": "You are helpful"}, + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there"}, + {"role": "user", "content": "How are you?"}, + ] + + mock_langchain_model.agenerate.return_value = create_llm_result("I'm good!") + + result = await wrapper.generate(messages) + + assert result == "I'm good!" + + @pytest.mark.asyncio + async def test_generate_with_max_tokens_reached(self, wrapper, mock_langchain_model): + """Test generate handles max tokens reached by continuing generation.""" + messages = [{"role": "user", "content": "Write a long story"}] + + # First call: max tokens reached + first_result = create_llm_result("Once upon a time, there was", finish_reason="length") + + # Second call: completion + second_result = create_llm_result(" a brave knight who saved the kingdom.", finish_reason="stop") + + mock_langchain_model.agenerate.side_effect = [first_result, second_result] + + result = await wrapper.generate(messages) + + assert result == "Once upon a time, there was a brave knight who saved the kingdom." + assert mock_langchain_model.agenerate.call_count == 2 + + @pytest.mark.asyncio + async def test_generate_max_continuations_exceeded(self, wrapper, mock_langchain_model): + """Test that max continuations limit prevents infinite recursion.""" + messages = [{"role": "user", "content": "Test"}] + + # Always return length finish reason + mock_langchain_model.agenerate.return_value = create_llm_result("Part", finish_reason="length") + + with pytest.raises(RuntimeError, match="Maximum continuation depth"): + await wrapper.generate(messages) + + @pytest.mark.asyncio + async def test_generate_invalid_messages(self, wrapper): + """Test generate fails with invalid messages.""" + with pytest.raises(TypeError, match="Messages must be a list"): + await wrapper.generate("not a list") + + @pytest.mark.asyncio + async def test_generate_api_failure(self, wrapper, mock_langchain_model): + """Test generate handles API failures gracefully.""" + messages = [{"role": "user", "content": "Test"}] + + mock_langchain_model.agenerate.side_effect = Exception("API Error") + + with pytest.raises(RuntimeError, match="Language model API call failed"): + await wrapper.generate(messages) + + @pytest.mark.asyncio + async def test_generate_empty_response(self, wrapper, mock_langchain_model): + """Test generate handles empty response.""" + messages = [{"role": "user", "content": "Test"}] + + mock_langchain_model.agenerate.return_value = LLMResult(generations=[[]]) + + with pytest.raises(ValueError, match="Empty response from language model"): + await wrapper.generate(messages) + + @pytest.mark.asyncio + async def test_generate_with_tuple_content(self, wrapper, mock_langchain_model): + """Test generate handles tuple content correctly.""" + messages = [{"role": "user", "content": ("Line 1", "Line 2")}] + + mock_langchain_model.agenerate.return_value = create_llm_result("Response") + + result = await wrapper.generate(messages) + + assert result == "Response" + # Verify the tuple was converted to string + call_args = mock_langchain_model.agenerate.call_args + called_messages = call_args.kwargs["messages"][0] + assert "Line 1 Line 2" in str(called_messages[0].content) + + @pytest.mark.asyncio + async def test_generate_with_none_content(self, wrapper, mock_langchain_model): + """Test generate handles None content.""" + messages = [{"role": "user", "content": None}] + + mock_langchain_model.agenerate.return_value = create_llm_result("Response") + + result = await wrapper.generate(messages) + + assert result == "Response" + + @pytest.mark.asyncio + async def test_generate_preserves_message_order(self, wrapper, mock_langchain_model): + """Test that message order is preserved during conversion.""" + messages = [ + {"role": "system", "content": "First"}, + {"role": "user", "content": "Second"}, + {"role": "assistant", "content": "Third"}, + ] + + mock_langchain_model.agenerate.return_value = create_llm_result("Response") + + await wrapper.generate(messages) + + call_args = mock_langchain_model.agenerate.call_args + called_messages = call_args.kwargs["messages"][0] + + assert called_messages[0].content == "First" + assert called_messages[1].content == "Second" + assert called_messages[2].content == "Third" + + @pytest.mark.asyncio + async def test_generate_continuation_includes_previous_messages(self, wrapper, mock_langchain_model): + """Test that continuation includes all previous messages.""" + messages = [{"role": "user", "content": "Original message"}] + + first_result = create_llm_result("Incomplete", finish_reason="length") + second_result = create_llm_result(" Complete", finish_reason="stop") + + mock_langchain_model.agenerate.side_effect = [first_result, second_result] + + await wrapper.generate(messages) + + # Check second call includes original message + response + continuation prompt + second_call_args = mock_langchain_model.agenerate.call_args_list[1] + called_messages = second_call_args.kwargs["messages"][0] + + # Should have at least 3 messages: original + assistant response + continuation + assert len(called_messages) >= 3 + + +# Made with Bob diff --git a/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py b/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py new file mode 100644 index 0000000000..9ae278a3f5 --- /dev/null +++ b/src/backend/tests/unit/components/models_and_agents/policies/test_policies_component.py @@ -0,0 +1,286 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from lfx.components.models_and_agents.policies_component import ( + MODE_GENERATE, + MODE_GUARD, + STEP2, + PoliciesComponent, +) + + +@pytest.fixture +def mock_tool(): + """Create a mock tool for testing.""" + tool = MagicMock() + tool.name = "test_tool" + tool.description = "A test tool" + tool.tags = [] + tool.metadata = {} + return tool + + +@pytest.fixture +def mock_component(mock_tool): + """Create a PoliciesComponent instance with mocked dependencies.""" + with patch.object(PoliciesComponent, "user_id", new_callable=lambda: property(lambda _: "test_user_123")): + component = PoliciesComponent() + component.project = "test_project" + component.in_tools = [mock_tool] + component.policies = ["Policy 1: Do not allow negative numbers", "Policy 2: Validate input"] + component.model = [{"name": "gpt-5.1", "provider": "OpenAI"}] + component.api_key = "test_api_key" # pragma: allowlist secret + component.enabled = True + component.mode = MODE_GUARD + return component + + +@pytest.mark.asyncio +async def test_cache_mode_success(mock_component, mock_tool): + """Test PoliciesComponent in cache mode with valid cached guards.""" + code_dir = mock_component.work_dir / STEP2 + + # Mock the cache directory exists and toolguard loading + with ( + patch.object(Path, "exists", return_value=True), + patch("lfx.components.models_and_agents.policies_component.load_toolguards") as mock_load_guards, + patch.object(mock_component, "make_toolguard_result") as mock_make_result, + patch("lfx.components.models_and_agents.policies_component.load_toolguards_from_memory") as mock_load_memory, + patch("lfx.components.models_and_agents.policies_component.GuardedTool") as mock_guarded_tool, + ): + mock_tg_result = MagicMock() + mock_make_result.return_value = mock_tg_result + mock_tg_runtime = MagicMock() + mock_load_memory.return_value = mock_tg_runtime + mock_guarded_instance = MagicMock() + mock_guarded_tool.return_value = mock_guarded_instance + + result = await mock_component.guard_tools() + + # Verify load_toolguards was called during validation + mock_load_guards.assert_called_once_with(code_dir) + + # Verify make_toolguard_result was called + mock_make_result.assert_called_once() + + # Verify load_toolguards_from_memory was called with the result + mock_load_memory.assert_called_once_with(mock_tg_result) + + # Verify GuardedTool was created for each tool + assert mock_guarded_tool.call_count == len(mock_component.in_tools) + mock_guarded_tool.assert_called_with(mock_tool, mock_component.in_tools, mock_tg_runtime) + + # Verify result contains guarded tools + assert len(result) == 1 + assert result[0] == mock_guarded_instance + + +@pytest.mark.asyncio +async def test_cache_mode_directory_not_found(mock_component): + """Test PoliciesComponent in cache mode when cache directory doesn't exist.""" + # Mock the cache directory does not exist + with ( + patch.object(Path, "exists", return_value=False), + pytest.raises(ValueError, match="Cache directory not found"), + ): + await mock_component.guard_tools() + + +@pytest.mark.asyncio +async def test_cache_mode_file_not_found(mock_component): + """Test PoliciesComponent in cache mode when required files are missing.""" + # Mock the cache directory exists but files are missing + with ( + patch.object(Path, "exists", return_value=True), + patch("lfx.components.models_and_agents.policies_component.load_toolguards") as mock_load_guards, + ): + mock_load_guards.side_effect = FileNotFoundError("Guard file not found") + + with pytest.raises(ValueError, match="Required guard code files missing"): + await mock_component.guard_tools() + + +@pytest.mark.asyncio +async def test_cache_mode_corrupted_cache(mock_component): + """Test PoliciesComponent in cache mode when cached code is corrupted.""" + # Mock the cache directory exists but code is corrupted + with ( + patch.object(Path, "exists", return_value=True), + patch("lfx.components.models_and_agents.policies_component.load_toolguards") as mock_load_guards, + ): + mock_load_guards.side_effect = Exception("Invalid Python syntax") + + with pytest.raises(ValueError, match="Failed to load guard code"): + await mock_component.guard_tools() + + +# @pytest.mark.asyncio +# async def test_cache_mode_multiple_tools(mock_component): +# """Test PoliciesComponent in cache mode with multiple tools.""" +# # Add more tools +# tool2 = MagicMock() +# tool2.name = "tool2" +# tool3 = MagicMock() +# tool3.name = "tool3" +# mock_component.in_tools = [mock_component.in_tools[0], tool2, tool3] + +# with ( +# patch.object(Path, "exists", return_value=True), +# patch.object(mock_component, "make_toolguard_result") as mock_make_result, +# patch("lfx.components.models_and_agents.policies_component.load_toolguards_from_memory") as mock_load_memory, +# patch("lfx.components.models_and_agents.policies_component.GuardedTool") as mock_guarded_tool, +# ): +# mock_tg_result = MagicMock() +# mock_make_result.return_value = mock_tg_result +# mock_tg_runtime = MagicMock() +# mock_load_memory.return_value = mock_tg_runtime +# mock_guarded_instances = [MagicMock(), MagicMock(), MagicMock()] +# mock_guarded_tool.side_effect = mock_guarded_instances + +# result = await mock_component.guard_tools() + +# # Verify GuardedTool was created for each tool +# assert mock_guarded_tool.call_count == 3 +# assert len(result) == 3 +# assert result == mock_guarded_instances + + +@pytest.mark.asyncio +async def test_inenabled_returns_original_tools(mock_component, mock_tool): + """Test PoliciesComponent when enabled=False returns original tools.""" + mock_component.enabled = False + + result = await mock_component.guard_tools() + + # Should return original tools without wrapping + assert result == mock_component.in_tools + assert len(result) == 1 + assert result[0] == mock_tool + + +@pytest.mark.asyncio +async def test_generate_mode_validation_errors(mock_component): + """Test PoliciesComponent in generate mode with validation errors.""" + mock_component.mode = MODE_GENERATE + + # Test empty project + mock_component.project = "" + with pytest.raises(ValueError): # noqa: PT011 + await mock_component.guard_tools() + + # Test empty policies + mock_component.project = "test_project" + mock_component.policies = [] + with pytest.raises(ValueError, match="policies cannot be empty"): + await mock_component.guard_tools() + + # Test empty tools + mock_component.policies = ["Policy 1"] + mock_component.in_tools = [] + with pytest.raises(ValueError, match="in_tools cannot be empty"): + await mock_component.guard_tools() + + # Test missing model + mock_component.in_tools = [MagicMock()] + mock_component.model = None + with pytest.raises(ValueError, match="model or api_key cannot be empty"): + await mock_component.guard_tools() + + # # Test non-recommended model + # mock_component.model = [{"name": "gpt-3.5-turbo", "provider": "OpenAI"}] + # mock_component.api_key = "test_key" # pragma: allowlist secret + # with pytest.raises(ValueError, match="is not in recommended models"): + # await mock_component.guard_tools() + + +def test_work_dir_property(): + """Test work_dir property generates correct path.""" + component = PoliciesComponent() + component.project = "test project" + work_dir = component.work_dir + + assert "test_project" in str(work_dir) + assert work_dir.name == "test_project" + + +def test_to_snake_case(): + """Test _to_snake_case static method.""" + assert PoliciesComponent._to_snake_case("My Project") == "my_project" + assert PoliciesComponent._to_snake_case("Test-Project") == "test_project" + assert PoliciesComponent._to_snake_case("User's Project") == "user_s_project" + assert PoliciesComponent._to_snake_case("Project, Name") == "project_name" + assert PoliciesComponent._to_snake_case("UPPERCASE") == "uppercase" + assert PoliciesComponent._to_snake_case("Mixed-Case Project's Name") == "mixed_case_project_s_name" + + # Test path traversal prevention + assert PoliciesComponent._to_snake_case("../../etc/passwd") == "etc_passwd" + assert PoliciesComponent._to_snake_case("../../../root") == "root" + assert PoliciesComponent._to_snake_case("./hidden") == "hidden" + assert PoliciesComponent._to_snake_case("path/to/file") == "path_to_file" + assert PoliciesComponent._to_snake_case("back\\slash\\path") == "back_slash_path" + + # Test special characters are sanitized + assert PoliciesComponent._to_snake_case("test@#$%project") == "test_project" + assert PoliciesComponent._to_snake_case("___multiple___underscores___") == "multiple_underscores" + + # Test empty/invalid input + with pytest.raises(ValueError, match="must contain at least one alphanumeric character"): + PoliciesComponent._to_snake_case("...") + with pytest.raises(ValueError, match="must contain at least one alphanumeric character"): + PoliciesComponent._to_snake_case("___") + with pytest.raises(ValueError, match="must contain at least one alphanumeric character"): + PoliciesComponent._to_snake_case("@#$%") + + +def test_in_recommended_models(mock_component): + """Test in_recommended_models method.""" + assert mock_component.in_recommended_models("gpt-5.1") is True + assert mock_component.in_recommended_models("claude-sonnet-4") is True + assert mock_component.in_recommended_models("gpt-5.1-turbo") is True + assert mock_component.in_recommended_models("claude-sonnet-4-preview") is True + assert mock_component.in_recommended_models("gpt-4") is False + assert mock_component.in_recommended_models("gpt-3.5-turbo") is False + assert mock_component.in_recommended_models("claude-3") is False + + +@pytest.mark.asyncio +async def test_verify_cached_guards_error_messages(mock_component): + """Test that _verify_cached_guards provides helpful error messages.""" + code_dir = mock_component.work_dir / STEP2 + + # Test directory not found error message + with patch.object(Path, "exists", return_value=False): + with pytest.raises(ValueError, match="Cache directory not found") as exc_info: + mock_component._verify_cached_guards(code_dir) + + assert "Generate" in str(exc_info.value) + assert str(code_dir) in str(exc_info.value) + + # Test file not found error message + with ( + patch.object(Path, "exists", return_value=True), + patch("lfx.components.models_and_agents.policies_component.load_toolguards") as mock_load, + ): + mock_load.side_effect = FileNotFoundError("Missing file") + + with pytest.raises(ValueError, match="Required guard code files missing") as exc_info: + mock_component._verify_cached_guards(code_dir) + + assert "Generate" in str(exc_info.value) + + # Test general error message + with ( + patch.object(Path, "exists", return_value=True), + patch("lfx.components.models_and_agents.policies_component.load_toolguards") as mock_load, + ): + mock_load.side_effect = RuntimeError("Unexpected error") + + with pytest.raises(ValueError, match="Failed to load guard code") as exc_info: + mock_component._verify_cached_guards(code_dir) + + assert "corrupted" in str(exc_info.value) + assert "Unexpected error" in str(exc_info.value) + + +# Made with Bob diff --git a/src/backend/tests/unit/components/models_and_agents/policies/test_tool_invoker.py b/src/backend/tests/unit/components/models_and_agents/policies/test_tool_invoker.py new file mode 100644 index 0000000000..ae75d42fb3 --- /dev/null +++ b/src/backend/tests/unit/components/models_and_agents/policies/test_tool_invoker.py @@ -0,0 +1,271 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from lfx.components.models_and_agents.policies.tool_invoker import ToolInvoker +from mcp.types import CallToolResult +from pydantic import BaseModel + + +class Person(BaseModel): + name: str + age: int + + +class Address(BaseModel): + street: str + city: str + zip_code: str + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_dict_result(): + """Test invoking a tool that returns a plain dict.""" + tool = MagicMock() + tool.name = "test_tool" + tool.ainvoke = AsyncMock(return_value={"result": {"result": "success", "data": 42}}) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("test_tool", {"arg1": "value1"}, dict) + + assert result == {"result": "success", "data": 42} + tool.ainvoke.assert_called_once_with(input={"arg1": "value1"}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_value_dict(): + """Test invoking a tool that returns a dict with 'value' key.""" + tool = MagicMock() + tool.name = "test_tool" + tool.ainvoke = AsyncMock(return_value={"value": {"name": "Alice", "age": 30}}) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("test_tool", {"query": "person"}, dict) + + assert result == {"name": "Alice", "age": 30} + tool.ainvoke.assert_called_once_with(input={"query": "person"}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_call_tool_result(): + """Test invoking a tool that returns CallToolResult.""" + tool = MagicMock() + tool.name = "test_tool" + + # Create a mock CallToolResult + mock_result = MagicMock(spec=CallToolResult) + mock_result.structuredContent = {"status": "ok", "count": 5} + tool.ainvoke = AsyncMock(return_value=mock_result) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("test_tool", {"action": "count"}, dict) + + assert result == {"status": "ok", "count": 5} + tool.ainvoke.assert_called_once_with(input={"action": "count"}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_basemodel_return_type(): + """Test invoking a tool with BaseModel return type.""" + tool = MagicMock() + tool.name = "get_person" + tool.ainvoke = AsyncMock(return_value={"name": "Bob", "age": 25}) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("get_person", {"id": 123}, Person) + + assert isinstance(result, Person) + assert result.name == "Bob" + assert result.age == 25 + tool.ainvoke.assert_called_once_with(input={"id": 123}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_basemodel_result(): + """Test invoking a tool that returns a BaseModel instance.""" + tool = MagicMock() + tool.name = "get_address" + + # Tool returns a BaseModel instance + address = Address(street="123 Main St", city="Springfield", zip_code="12345") + tool.ainvoke = AsyncMock(return_value=address) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("get_address", {"user_id": 456}, Address) + + assert isinstance(result, Address) + assert result.street == "123 Main St" + assert result.city == "Springfield" + assert result.zip_code == "12345" + tool.ainvoke.assert_called_once_with(input={"user_id": 456}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_int_return_type(): + """Test invoking a tool with int return type.""" + tool = MagicMock() + tool.name = "count_items" + tool.ainvoke = AsyncMock(return_value=42) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("count_items", {"category": "books"}, int) + + assert result == 42 + assert isinstance(result, int) + tool.ainvoke.assert_called_once_with(input={"category": "books"}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_float_return_type(): + """Test invoking a tool with float return type.""" + tool = MagicMock() + tool.name = "calculate_price" + tool.ainvoke = AsyncMock(return_value=99.99) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("calculate_price", {"item": "widget"}, float) + + assert result == 99.99 + assert isinstance(result, float) + tool.ainvoke.assert_called_once_with(input={"item": "widget"}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_str_return_type(): + """Test invoking a tool with str return type.""" + tool = MagicMock() + tool.name = "get_message" + tool.ainvoke = AsyncMock(return_value="Hello, World!") + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("get_message", {"lang": "en"}, str) + + assert result == "Hello, World!" + assert isinstance(result, str) + tool.ainvoke.assert_called_once_with(input={"lang": "en"}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_bool_return_type(): + """Test invoking a tool with bool return type.""" + tool = MagicMock() + tool.name = "is_valid" + tool.ainvoke = AsyncMock(return_value=True) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("is_valid", {"data": "test"}, bool) + + assert result is True + assert isinstance(result, bool) + tool.ainvoke.assert_called_once_with(input={"data": "test"}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_unknown_tool(): + """Test invoking a tool that doesn't exist.""" + tool = MagicMock() + tool.name = "existing_tool" + + invoker = ToolInvoker([tool]) + + with pytest.raises(ValueError, match="unknown tool nonexistent_tool"): + await invoker.invoke("nonexistent_tool", {"arg": "value"}, dict) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_empty_arguments(): + """Test invoking a tool with empty arguments.""" + tool = MagicMock() + tool.name = "no_args_tool" + tool.ainvoke = AsyncMock(return_value={"status": "ok"}) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("no_args_tool", {}, dict) + + assert result == {"status": "ok"} + tool.ainvoke.assert_called_once_with(input={}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_complex_arguments(): + """Test invoking a tool with complex nested arguments.""" + tool = MagicMock() + tool.name = "complex_tool" + complex_args = { + "user": {"name": "Charlie", "age": 35}, + "settings": {"theme": "dark", "notifications": True}, + "items": [1, 2, 3, 4, 5], + } + tool.ainvoke = AsyncMock(return_value={"processed": True}) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("complex_tool", complex_args, dict) + + assert result == {"processed": True} + tool.ainvoke.assert_called_once_with(input=complex_args) + + +@pytest.mark.asyncio +async def test_tool_invoker_multiple_tools(): + """Test ToolInvoker with multiple tools and invoking different ones.""" + tool1 = MagicMock() + tool1.name = "tool1" + tool1.ainvoke = AsyncMock(return_value={"result": "from_tool1"}) + + tool2 = MagicMock() + tool2.name = "tool2" + tool2.ainvoke = AsyncMock(return_value={"result": "from_tool2"}) + + tool3 = MagicMock() + tool3.name = "tool3" + tool3.ainvoke = AsyncMock(return_value={"result": "from_tool3"}) + + invoker = ToolInvoker([tool1, tool2, tool3]) + + # Invoke each tool + result1 = await invoker.invoke("tool1", {}, dict) + result2 = await invoker.invoke("tool2", {}, dict) + result3 = await invoker.invoke("tool3", {}, dict) + + assert result1 == "from_tool1" + assert result2 == "from_tool2" + assert result3 == "from_tool3" + + tool1.ainvoke.assert_called_once() + tool2.ainvoke.assert_called_once() + tool3.ainvoke.assert_called_once() + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_with_basemodel_in_value_dict(): + """Test invoking a tool that returns BaseModel wrapped in value dict.""" + tool = MagicMock() + tool.name = "get_person_wrapped" + + # Tool returns a dict with 'value' containing a BaseModel + person = Person(name="Diana", age=28) + tool.ainvoke = AsyncMock(return_value={"value": person}) + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("get_person_wrapped", {"id": 789}, Person) + + assert isinstance(result, Person) + assert result.name == "Diana" + assert result.age == 28 + tool.ainvoke.assert_called_once_with(input={"id": 789}) + + +@pytest.mark.asyncio +async def test_tool_invoker_invoke_primitive_type_conversion(): + """Test that primitive types are properly converted from string results.""" + tool = MagicMock() + tool.name = "string_number" + tool.ainvoke = AsyncMock(return_value="123") + + invoker = ToolInvoker([tool]) + result = await invoker.invoke("string_number", {}, int) + + assert result == 123 + assert isinstance(result, int) + + +# Made with Bob diff --git a/src/lfx/src/lfx/_assets/component_index.json b/src/lfx/src/lfx/_assets/component_index.json index fd9e4da27d..e6ee882dcb 100644 --- a/src/lfx/src/lfx/_assets/component_index.json +++ b/src/lfx/src/lfx/_assets/component_index.json @@ -92602,6 +92602,265 @@ } }, "tool_mode": false + }, + "policies": { + "base_classes": [ + "Tool" + ], + "beta": true, + "conditional_paths": [], + "custom_fields": {}, + "description": "Component for building tool protection code from textual business policies and instructions.\nPowered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )", + "display_name": "Policies", + "documentation": "https://github.com/AgentToolkit/toolguard", + "edited": false, + "field_order": [ + "enabled", + "mode", + "project", + "in_tools", + "policies", + "model", + "api_key" + ], + "frozen": false, + "icon": "shield-check", + "legacy": false, + "metadata": { + "code_hash": "3fe07c8c9934", + "dependencies": { + "dependencies": [ + { + "name": "toolguard", + "version": "0.2.14" + }, + { + "name": "lfx", + "version": null + } + ], + "total_dependencies": 2 + }, + "keywords": [ + "model", + "llm", + "language model", + "large language model" + ], + "module": "lfx.components.models_and_agents.policies_component.PoliciesComponent" + }, + "minimized": false, + "output_types": [], + "outputs": [ + { + "allows_loop": false, + "cache": true, + "display_name": "Guarded Tools", + "group_outputs": false, + "method": "guard_tools", + "name": "guarded_tools", + "selected": "Tool", + "tool_mode": true, + "types": [ + "Tool" + ], + "value": "__UNDEFINED__" + } + ], + "pinned": false, + "template": { + "_type": "Component", + "api_key": { + "_input_type": "SecretStrInput", + "advanced": true, + "display_name": "API Key", + "dynamic": false, + "info": "Model Provider API key", + "input_types": [], + "load_from_db": true, + "name": "api_key", + "override_skip": false, + "password": true, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "code": { + "advanced": true, + "dynamic": true, + "fileTypes": [], + "file_path": "", + "info": "", + "list": false, + "load_from_db": false, + "multiline": true, + "name": "code", + "password": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "type": "code", + "value": "import os\nimport re\nimport shutil\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, cast\n\nfrom toolguard.buildtime import (\n PolicySpecOptions,\n ToolGuardsCodeGenerationResult,\n ToolGuardSpec,\n generate_guard_specs,\n generate_guards_code,\n)\nfrom toolguard.extra.langchain_to_oas import langchain_tools_to_openapi\nfrom toolguard.runtime import load_toolguards, load_toolguards_from_memory\nfrom toolguard.runtime.runtime import RESULTS_FILENAME\n\nfrom lfx.base.models import LCModelComponent\nfrom lfx.base.models.unified_models import (\n get_language_model_options,\n get_llm,\n update_model_options_in_build_config,\n)\nfrom lfx.components.models_and_agents.policies.guard_sync_utils import sync_generated_guard_code_inputs\nfrom lfx.components.models_and_agents.policies.guarded_tool import GuardedTool\nfrom lfx.components.models_and_agents.policies.llm_wrapper import LangchainModelWrapper\nfrom lfx.components.models_and_agents.policies.module_utils import unload_module\nfrom lfx.field_typing import LanguageModel, Tool\nfrom lfx.io import (\n BoolInput,\n HandleInput,\n ModelInput,\n MultilineInput,\n Output,\n SecretStrInput,\n StrInput,\n TabInput,\n)\nfrom lfx.log.logger import logger\n\nif TYPE_CHECKING:\n from lfx.inputs.inputs import InputTypes\n\n\nTOOLGUARD_WORK_DIR = Path(os.getenv(\"TOOLGUARD_WORK_DIR\") or \"tmp_toolguard\")\nBUILDTIME_MODELS = [\"gpt-5\", \"claude-sonnet\"] # currently inactive, we recommend but do not enforce\nSTEP1 = \"Step_1\"\nSTEP2 = \"Step_2\"\nMODE_GENERATE = \"🛠️ Generate\"\nMODE_GUARD = \"🛡️ Guard\"\nGENERATED_GUARD_INFO_PREFIX = \"Auto-generated ToolGuard code for \"\n\n\nclass PoliciesComponent(LCModelComponent):\n \"\"\"Component for building tool protection code from textual business policies and instructions.\n\n This component uses ToolGuard to generate and apply policy-based guards to tools,\n ensuring that tool execution complies with defined business policies.\n Powered by ALTK ToolGuard (https://github.com/AgentToolkit/toolguard).\n \"\"\"\n\n display_name = \"Policies\"\n description = \"\"\"Component for building tool protection code from textual business policies and instructions.\nPowered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )\"\"\"\n documentation: str = \"https://github.com/AgentToolkit/toolguard\"\n icon = \"shield-check\"\n name = \"policies\"\n beta = True\n\n inputs = cast(\n \"list[InputTypes]\",\n [\n BoolInput(\n name=\"enabled\",\n display_name=\"Enabled\",\n info=\"If `true` - guards tool calls. If `false`, skip policy validation.\",\n value=True,\n ),\n TabInput(\n name=\"mode\",\n display_name=\"Activity\",\n options=[MODE_GENERATE, MODE_GUARD],\n info=(\n \"Generate new guard code or apply existing guard. \"\n \"Review generated files in the details panel on the right.\"\n ),\n value=MODE_GENERATE,\n real_time_refresh=True,\n tool_mode=True,\n ),\n MultilineInput(\n name=\"project\",\n display_name=\"Policies Project\",\n info=\"Folder name of the generated code\",\n value=\"my_project\",\n # required=True,\n ),\n HandleInput(\n name=\"in_tools\",\n display_name=\"Tools\",\n input_types=[\"Tool\"],\n is_list=True,\n required=True,\n info=\"These are the tools that the agent can use to help with tasks.\",\n ),\n StrInput(\n name=\"policies\",\n display_name=\"Policies\",\n info=\"One or more clear, well-defined and self-contained business policies\",\n is_list=True,\n tool_mode=True,\n placeholder=\"Add business policy...\",\n list_add_label=\"Add Policy\",\n # input_types=[],\n ),\n ModelInput(\n name=\"model\",\n display_name=\"Language Model\",\n info=(\n \"Select LLM for Policies buildtime. We recommend using \"\n \"Anthropic Claude-Sonnet series for this task.\"\n ),\n real_time_refresh=True,\n required=True,\n ),\n SecretStrInput(\n name=\"api_key\",\n display_name=\"API Key\",\n info=\"Model Provider API key\",\n required=False,\n advanced=True,\n ),\n ],\n )\n outputs = [\n Output(\n display_name=\"Guarded Tools\",\n type_=Tool,\n name=\"guarded_tools\",\n method=\"guard_tools\",\n # group_outputs=True,\n ),\n ]\n\n @property\n def work_dir(self) -> Path:\n return TOOLGUARD_WORK_DIR / self._to_snake_case(self.project)\n\n def build_model(self) -> LanguageModel:\n llm_model = get_llm(\n model=self.model,\n user_id=self.user_id,\n api_key=self.api_key,\n stream=False,\n )\n if llm_model is None:\n msg = \"No language model selected. Please choose a model to proceed.\"\n raise ValueError(msg)\n return llm_model\n\n def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None):\n \"\"\"Dynamically update build config with user-filtered model options.\"\"\"\n updated_build_config = update_model_options_in_build_config(\n component=self,\n build_config=build_config,\n cache_key_prefix=\"language_model_options\",\n get_options_func=get_language_model_options,\n field_name=field_name,\n field_value=field_value,\n )\n py_module = self._to_snake_case(self.project)\n return sync_generated_guard_code_inputs(\n build_config=updated_build_config,\n work_dir=self.work_dir,\n step2_subdir=STEP2,\n project_name=py_module,\n )\n\n async def _generate_guard_specs(self) -> list[ToolGuardSpec]:\n logger.debug(\"Starting step 1\")\n logger.debug(f\"model = {self.model}\")\n llm = LangchainModelWrapper(self.build_model())\n out_dir = self.work_dir / STEP1\n if out_dir.exists():\n shutil.rmtree(out_dir)\n policy_text = \"\\n * \".join(self.policies)\n open_api = langchain_tools_to_openapi(self.in_tools)\n\n options = PolicySpecOptions(example_number=4)\n specs = await generate_guard_specs(\n policy_text=policy_text, tools=open_api, llm=llm, work_dir=out_dir, options=options\n )\n logger.debug(\"Step 1 Done\")\n return specs\n\n async def _generate_guard_code(self, specs: list[ToolGuardSpec]) -> ToolGuardsCodeGenerationResult:\n logger.debug(\"Starting step 2\")\n out_dir = self.work_dir / STEP2\n if out_dir.exists():\n shutil.rmtree(out_dir)\n llm = LangchainModelWrapper(self.build_model())\n app_name = self._to_snake_case(self.project)\n open_api = langchain_tools_to_openapi(self.in_tools)\n\n gen_result = await generate_guards_code(\n tools=open_api, tool_specs=specs, work_dir=out_dir, llm=llm, app_name=app_name\n )\n logger.debug(\"Step 2 Done\")\n return gen_result\n\n def in_recommended_models(self, model_name: str):\n return any(recommended in model_name for recommended in BUILDTIME_MODELS)\n\n def validate_before_generate(self) -> None:\n \"\"\"Validate required inputs before generating guard code.\"\"\"\n if not self.project:\n msg = \"Policies: project cannot be empty!\"\n raise ValueError(msg)\n\n if not any(self.policies):\n msg = \"Policies: policies cannot be empty!\"\n raise ValueError(msg)\n\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n if not self.model or not self.api_key:\n msg = \"Policies: model or api_key cannot be empty!\"\n raise ValueError(msg)\n\n # uncomment if willing to enforce certain models for buildtime\n # if not self.in_recommended_models(self.model[0][\"name\"]):\n # msg = f\"Policies: model {self.model[0]['name']} is not in recommended models: {BUILDTIME_MODELS}\"\n # raise ValueError(msg)\n\n async def generate(self):\n specs = await self._generate_guard_specs()\n res = await self._generate_guard_code(specs)\n\n # if there was a previous version of the guard, remove it from python cache\n unload_module(res.domain.app_name)\n\n def _verify_cached_guards(self, code_dir: Path) -> None:\n # Validate cache exists before attempting to load\n if not code_dir.exists():\n msg = (\n f\"Policies: Cache directory not found at '{code_dir}'. \"\n f\"Please run in 'Generate' mode first to create the guard code, \"\n f\"or verify the project name is correct.\"\n )\n raise ValueError(msg)\n\n try:\n load_toolguards(code_dir)\n except FileNotFoundError as exc:\n msg = (\n f\"Policies: Required guard code files missing in '{code_dir}'. \"\n f\"Please run in 'Generate' mode to create the guard code.\"\n )\n raise ValueError(msg) from exc\n except Exception as exc:\n msg = (\n f\"Policies: Failed to load guard code from '{code_dir}'. \"\n f\"The cached code may be invalid or corrupted. \"\n f\"Try running in 'Generate' mode to rebuild the guard code. \"\n f\"Error: {exc!s}\"\n )\n raise ValueError(msg) from exc\n\n def _validate_before_using_cache(self, code_dir: Path) -> None:\n if not self.in_tools:\n msg = \"Policies: in_tools cannot be empty!\"\n raise ValueError(msg)\n\n self._verify_cached_guards(code_dir)\n\n def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult:\n attrs = self.get_vertex().data[\"node\"][\"template\"]\n if not attrs:\n raise ValueError\n\n result_str = attrs[str(RESULTS_FILENAME)][\"value\"]\n result = ToolGuardsCodeGenerationResult.model_validate_json(result_str)\n\n result.domain.app_types.content = attrs.get(str(result.domain.app_types.file_name))[\"value\"]\n result.domain.app_api.content = attrs.get(str(result.domain.app_api.file_name))[\"value\"]\n result.domain.app_api_impl.content = attrs.get(str(result.domain.app_api_impl.file_name))[\"value\"]\n\n for tool in result.tools.values():\n tool.guard_file.content = attrs.get(str(tool.guard_file.file_name))[\"value\"]\n for tool_item in tool.item_guard_files:\n tool_item.content = attrs.get(str(tool_item.file_name))[\"value\"]\n\n return result\n\n async def guard_tools(self) -> list[Tool]:\n if self.enabled:\n mode = getattr(self, \"mode\", MODE_GENERATE)\n if mode == MODE_GENERATE:\n self.log(f\"Start generating guard code at {self.work_dir}\", name=\"info\")\n self.validate_before_generate()\n await self.generate()\n self.log(f\"Policies code generation saved to {self.work_dir}\", name=\"info\")\n self.log(\"Review the generated files in the details panel on the right.\", name=\"info\")\n\n else: # mode == \"guard\"\n self.log(f\"using cache from {self.work_dir}\", name=\"info\")\n code_dir = self.work_dir / STEP2\n self._validate_before_using_cache(code_dir)\n try:\n tg_result = self.make_toolguard_result()\n tg_runtime = load_toolguards_from_memory(tg_result)\n guarded_tools = [GuardedTool(tool, self.in_tools, tg_runtime) for tool in self.in_tools]\n return cast(\"list[Tool]\", guarded_tools)\n except Exception as e:\n logger.exception(e)\n raise\n\n return self.in_tools\n\n @staticmethod\n def _to_snake_case(human_name: str) -> str:\n \"\"\"Convert human-readable name to snake_case, sanitizing path traversal attempts.\"\"\"\n # Convert to lowercase\n result = human_name.lower()\n\n # Replace any non-alphanumeric character (including path traversal chars) with underscore\n result = re.sub(r\"[^a-z0-9]+\", \"_\", result)\n\n # Strip leading/trailing underscores\n result = result.strip(\"_\")\n\n # Ensure the result contains at least one alphanumeric character\n if not result or not re.search(r\"[a-z0-9]\", result):\n msg = \"Project name must contain at least one alphanumeric character\"\n raise ValueError(msg)\n\n return result\n" + }, + "enabled": { + "_input_type": "BoolInput", + "advanced": false, + "display_name": "Enabled", + "dynamic": false, + "info": "If `true` - guards tool calls. If `false`, skip policy validation.", + "list": false, + "list_add_label": "Add More", + "name": "enabled", + "override_skip": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "bool", + "value": true + }, + "in_tools": { + "_input_type": "HandleInput", + "advanced": false, + "display_name": "Tools", + "dynamic": false, + "info": "These are the tools that the agent can use to help with tasks.", + "input_types": [ + "Tool" + ], + "list": true, + "list_add_label": "Add More", + "name": "in_tools", + "override_skip": false, + "placeholder": "", + "required": true, + "show": true, + "title_case": false, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "other", + "value": "" + }, + "mode": { + "_input_type": "TabInput", + "advanced": false, + "display_name": "Activity", + "dynamic": false, + "info": "Generate new guard code or apply existing guard. Review generated files in the details panel on the right.", + "name": "mode", + "options": [ + "🛠️ Generate", + "🛡️ Guard" + ], + "override_skip": false, + "placeholder": "", + "real_time_refresh": true, + "required": false, + "show": true, + "title_case": false, + "tool_mode": true, + "trace_as_metadata": true, + "track_in_telemetry": true, + "type": "tab", + "value": "🛠️ Generate" + }, + "model": { + "_input_type": "ModelInput", + "advanced": false, + "display_name": "Language Model", + "dynamic": false, + "external_options": { + "fields": { + "data": { + "node": { + "display_name": "Connect other models", + "icon": "CornerDownLeft", + "name": "connect_other_models" + } + } + } + }, + "info": "Select LLM for Policies buildtime. We recommend using Anthropic Claude-Sonnet series for this task.", + "input_types": [ + "LanguageModel" + ], + "list": false, + "list_add_label": "Add More", + "model_type": "language", + "name": "model", + "override_skip": false, + "placeholder": "Setup Provider", + "real_time_refresh": true, + "refresh_button": true, + "required": true, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "track_in_telemetry": false, + "type": "model", + "value": "" + }, + "policies": { + "_input_type": "StrInput", + "advanced": false, + "display_name": "Policies", + "dynamic": false, + "info": "One or more clear, well-defined and self-contained business policies", + "list": true, + "list_add_label": "Add Policy", + "load_from_db": false, + "name": "policies", + "override_skip": false, + "placeholder": "Add business policy...", + "required": false, + "show": true, + "title_case": false, + "tool_mode": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "" + }, + "project": { + "_input_type": "MultilineInput", + "advanced": false, + "ai_enabled": false, + "copy_field": false, + "display_name": "Policies Project", + "dynamic": false, + "info": "Folder name of the generated code", + "input_types": [ + "Message" + ], + "list": false, + "list_add_label": "Add More", + "load_from_db": false, + "multiline": true, + "name": "project", + "override_skip": false, + "password": false, + "placeholder": "", + "required": false, + "show": true, + "title_case": false, + "tool_mode": false, + "trace_as_input": true, + "trace_as_metadata": true, + "track_in_telemetry": false, + "type": "str", + "value": "my_project" + } + }, + "tool_mode": false } } ], @@ -117858,9 +118117,9 @@ ] ], "metadata": { - "num_components": 354, + "num_components": 355, "num_modules": 97 }, - "sha256": "dd478eca2f9b7c93dd7b4bcc00601745c604996c4830b0d98733cee4d42d3e8f", + "sha256": "d65027e041140bb2b47d6c4f36a86b4196ae00886a4a6fbc12ce2dabc8582771", "version": "0.4.0" } diff --git a/src/lfx/src/lfx/_assets/stable_hash_history.json b/src/lfx/src/lfx/_assets/stable_hash_history.json index d350d4af78..839138ec1a 100644 --- a/src/lfx/src/lfx/_assets/stable_hash_history.json +++ b/src/lfx/src/lfx/_assets/stable_hash_history.json @@ -2163,5 +2163,15 @@ "0.3.0": "8b5ca1f38f6e", "0.3.1": "8b5ca1f38f6e" } + }, + "policies": { + "versions": { + "0.3.0": "fe3303671fea" + } + }, + "policies": { + "versions": { + "0.3.0": "9bf5e6f39e2d" + } } } \ No newline at end of file diff --git a/src/lfx/src/lfx/base/mcp/util.py b/src/lfx/src/lfx/base/mcp/util.py index 9c17de17f3..6da8ca26ee 100644 --- a/src/lfx/src/lfx/base/mcp/util.py +++ b/src/lfx/src/lfx/base/mcp/util.py @@ -1935,7 +1935,7 @@ async def update_tools( func=create_tool_func(tool.name, args_schema, client), coroutine=create_tool_coroutine(tool.name, args_schema, client), tags=[tool.name], - metadata={"server_name": server_name}, + metadata={"server_name": server_name, "output_schema": getattr(tool, "outputSchema", None)}, ) tool_list.append(tool_obj) diff --git a/src/lfx/src/lfx/components/models_and_agents/__init__.py b/src/lfx/src/lfx/components/models_and_agents/__init__.py index d866d65220..c16fe47aa3 100644 --- a/src/lfx/src/lfx/components/models_and_agents/__init__.py +++ b/src/lfx/src/lfx/components/models_and_agents/__init__.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from lfx.components.models_and_agents.language_model import LanguageModelComponent from lfx.components.models_and_agents.mcp_component import MCPToolsComponent from lfx.components.models_and_agents.memory import MemoryComponent + from lfx.components.models_and_agents.policies_component import PoliciesComponent from lfx.components.models_and_agents.prompt import PromptComponent _dynamic_imports = { @@ -19,6 +20,7 @@ _dynamic_imports = { "MCPToolsComponent": "mcp_component", "MemoryComponent": "memory", "PromptComponent": "prompt", + "PoliciesComponent": "policies_component", } __all__ = [ @@ -27,6 +29,7 @@ __all__ = [ "LanguageModelComponent", "MCPToolsComponent", "MemoryComponent", + "PoliciesComponent", "PromptComponent", ] diff --git a/src/lfx/src/lfx/components/models_and_agents/policies/__init__.py b/src/lfx/src/lfx/components/models_and_agents/policies/__init__.py new file mode 100644 index 0000000000..72fd074e12 --- /dev/null +++ b/src/lfx/src/lfx/components/models_and_agents/policies/__init__.py @@ -0,0 +1,4 @@ +# PoliciesComponent has been moved to lfx.components.models_and_agents +# Supporting utilities remain here for the component to use + +__all__ = [] diff --git a/src/lfx/src/lfx/components/models_and_agents/policies/guard_sync_utils.py b/src/lfx/src/lfx/components/models_and_agents/policies/guard_sync_utils.py new file mode 100644 index 0000000000..60cb95abd2 --- /dev/null +++ b/src/lfx/src/lfx/components/models_and_agents/policies/guard_sync_utils.py @@ -0,0 +1,99 @@ +"""Utility functions for synchronizing generated guard code with component inputs.""" + +from pathlib import Path + +from toolguard.runtime.runtime import RESULTS_FILENAME + +from lfx.io import CodeInput +from lfx.log.logger import logger + +GENERATED_GUARD_INFO_PREFIX = "Auto-generated ToolGuard code for " + + +def is_generated_guard_field(field: dict) -> bool: + """Check if a field represents a generated guard code input. + + Args: + field: Dictionary representing a component field/input + + Returns: + True if the field is a generated guard code field, False otherwise + """ + if not isinstance(field, dict): + return False + return ( + field.get("type") == "code" + and field.get("dynamic") is True + and isinstance(field.get("info"), str) + and field.get("info", "").startswith(GENERATED_GUARD_INFO_PREFIX) + ) + + +def sync_generated_guard_code_inputs( + build_config: dict, + work_dir: Path, + step2_subdir: str, + project_name: str, +) -> dict: + """Synchronize generated guard code files with component code inputs. + + Scans the generated guard code directory and creates/updates CodeInput fields + in the build config for each Python file found. Removes stale fields for files + that no longer exist. + + Args: + build_config: The component's build configuration dictionary + work_dir: Base working directory for the toolguard project + step2_subdir: Subdirectory name containing generated code (e.g., "Step_2") + project_name: Name of the project in snake_case format + + Returns: + Updated build_config dictionary with synchronized code inputs + """ + logger.debug("Syncing generated guard code files...") + generated_field_names = {key for key, value in build_config.items() if is_generated_guard_field(value)} + + step2_dir = work_dir / step2_subdir + logger.debug(f"step2_dir = {step2_dir}") + logger.debug(f"step2_dir.exists() = {step2_dir.exists()}") + logger.debug(f"step2_dir.is_dir() = {step2_dir.is_dir()}") + if not step2_dir.exists() or not step2_dir.is_dir(): + for field_name in generated_field_names: + build_config.pop(field_name, None) + return build_config + + files = sorted(path for path in step2_dir.rglob("*") if path.is_file()) + + def include_file(relative_name) -> bool: + if relative_name.startswith(project_name) and relative_name.endswith(".py"): + return True + return relative_name == str(RESULTS_FILENAME) + + next_generated_names: set[str] = set() + + for file_path in files: + relative_name = file_path.relative_to(step2_dir).as_posix() + if not include_file(relative_name): + continue + logger.debug(f"Processing generated file: {relative_name}") + next_generated_names.add(relative_name) + try: + code_value = file_path.read_text(encoding="utf-8") + except OSError: + code_value = "" + + code_input = CodeInput( + name=relative_name, + display_name=relative_name, + value=code_value, + info=f"{GENERATED_GUARD_INFO_PREFIX}{relative_name}", + dynamic=True, + advanced=True, + ) + build_config[relative_name] = code_input.to_dict() + + stale_field_names = generated_field_names - next_generated_names + for field_name in stale_field_names: + build_config.pop(field_name, None) + + return build_config diff --git a/src/lfx/src/lfx/components/models_and_agents/policies/guarded_tool.py b/src/lfx/src/lfx/components/models_and_agents/policies/guarded_tool.py new file mode 100644 index 0000000000..18f498bdb9 --- /dev/null +++ b/src/lfx/src/lfx/components/models_and_agents/policies/guarded_tool.py @@ -0,0 +1,104 @@ +import json + +from langchain_core.messages import ToolCall +from toolguard.runtime import PolicyViolationException +from toolguard.runtime.runtime import ToolguardRuntime + +from lfx.components.models_and_agents.policies.tool_invoker import ToolInvoker +from lfx.field_typing import Tool +from lfx.log.logger import logger + + +class GuardedTool(Tool): + """A tool wrapper that applies ToolGuard policy validation before execution. + + This component requires async execution as ToolGuard operates asynchronously. + The synchronous `run()` method is not supported and will raise NotImplementedError. + Always use `arun()` or async invocation methods. + """ + + _orig_tool: Tool + _tool_invoker: ToolInvoker + _toolguard: ToolguardRuntime + + def __init__(self, tool: Tool, all_tools: list[Tool], toolguard: ToolguardRuntime): + super().__init__( + name=tool.name, + description=tool.description, + args_schema=getattr(tool, "args_schema", None), + return_direct=getattr(tool, "return_direct", False), + func=self.run, + coroutine=self.arun, + tags=tool.tags, + metadata=tool.metadata, + verbose=True, + ) + self._orig_tool = tool + self._tool_invoker = ToolInvoker(all_tools) + self._toolguard = toolguard + + @property + def args(self) -> dict: + return self._orig_tool.args + + def _parse_string_to_dict(self, value: str) -> dict: + """Parse a string as JSON, or wrap it in a dict if parsing fails.""" + try: + return json.loads(value) + except json.JSONDecodeError: + return {"input": value} + + def parse_input(self, tool_input: str | dict | ToolCall) -> dict: + # Handle string input - try to parse as JSON, fallback to wrapped input + if isinstance(tool_input, str): + return self._parse_string_to_dict(tool_input) + + # Handle ToolCall dict format - extract and parse args + if isinstance(tool_input, dict) and "args" in tool_input: + args = tool_input["args"] + if isinstance(args, str): + return self._parse_string_to_dict(args) + return args if isinstance(args, dict) else {} + + # Return dict as-is or empty dict for None/other types + return tool_input if isinstance(tool_input, dict) else {} + + def run(self, tool_input: str | dict | ToolCall, config=None, **kwargs): + """Synchronous execution is not supported for GuardedTool. + + ToolGuard requires async execution for policy validation. Please use the + async version `arun()` instead, or ensure your execution context supports + async tool invocation. + + Raises: + NotImplementedError: Always raised as sync execution is not supported. + """ + msg = ( + "GuardedTool does not support synchronous execution. " + "ToolGuard requires async execution for policy validation. " + "Please use `arun()` instead or ensure your execution context supports async tool invocation." + ) + raise NotImplementedError(msg) + + async def arun(self, tool_input: str | dict | ToolCall, config=None, **kwargs): + args = self.parse_input(tool_input) + logger.debug(f"running toolguard for {self.name}") + + with self._toolguard: + try: + await self._toolguard.guard_toolcall(self.name, args=args, delegate=self._tool_invoker) + return await self._orig_tool.arun(tool_input=args, config=config, **kwargs) + except PolicyViolationException as ex: + logger.debug(f"exception: {ex.message}") + return { + "ok": False, + "error": { + "type": "PolicyViolationException", + "code": "FAILURE", + "message": ex.message, + "retryable": True, + }, + } + except Exception: + logger.exception("Unhandled exception in class GuardedTool.arun()") + raise diff --git a/src/lfx/src/lfx/components/models_and_agents/policies/llm_wrapper.py b/src/lfx/src/lfx/components/models_and_agents/policies/llm_wrapper.py new file mode 100644 index 0000000000..9902df64f9 --- /dev/null +++ b/src/lfx/src/lfx/components/models_and_agents/policies/llm_wrapper.py @@ -0,0 +1,182 @@ +from typing import Any + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import messages_from_dict +from toolguard.buildtime.llm import LanguageModelBase + + +class LangchainModelWrapper(LanguageModelBase): + """Wrapper for Langchain chat models to work with ToolGuard. + + This wrapper handles: + - Message format conversion between ToolGuard and Langchain + - Automatic continuation when max tokens are reached + - Safe error handling and validation + """ + + MAX_CONTINUATIONS = 5 # Prevent infinite recursion + DEFAULT_MAX_OUT_TOKENS = 16000 + + def __init__(self, langchain_model: BaseChatModel): + """Initialize the wrapper with a Langchain chat model. + + Args: + langchain_model: A Langchain BaseChatModel instance + """ + self.langchain_model = langchain_model + if hasattr(self.langchain_model, "max_tokens") and getattr(self.langchain_model, "max_tokens", None) is None: + self.langchain_model.max_tokens = self.DEFAULT_MAX_OUT_TOKENS + + def _convert_role(self, role: str) -> str: + """Convert ToolGuard role to Langchain message type. + + Args: + role: The role from ToolGuard format ("user", "assistant", "system") + + Returns: + Langchain message type ("human", "ai", "system") + """ + role_mapping = { + "user": "human", + "assistant": "ai", + "system": "system", + } + return role_mapping.get(role, "system") + + def _validate_messages(self, messages: list[dict]) -> None: + """Validate message format. + + Args: + messages: List of message dictionaries + + Raises: + ValueError: If messages are invalid + """ + if not isinstance(messages, list): + msg = f"Messages must be a list, got {type(messages)}" + raise TypeError(msg) + + for i, msg in enumerate(messages): + if not isinstance(msg, dict): + error_msg = f"Message at index {i} must be a dict, got {type(msg)}" + raise TypeError(error_msg) + + if "role" not in msg: + error_msg = f"Message at index {i} missing 'role' field" + raise ValueError(error_msg) + + if "content" not in msg: + error_msg = f"Message at index {i} missing 'content' field" + raise ValueError(error_msg) + + def _extract_content(self, content: Any) -> str: + """Safely extract string content from various types. + + Args: + content: Content that could be str, list, tuple, or None + + Returns: + String representation of the content + """ + if content is None: + return "" + + if isinstance(content, str): + return content + + if isinstance(content, (list, tuple)): + # Join list/tuple elements with space + return " ".join(str(item) for item in content) + + return str(content) + + async def generate(self, messages: list[dict], _recursion_depth: int = 0) -> str: + """Generate a response from the language model. + + Args: + messages: List of message dicts with 'role' and 'content' keys + _recursion_depth: Internal counter to prevent infinite recursion + + Returns: + Generated text response + + Raises: + ValueError: If messages are invalid or response is malformed + RuntimeError: If max continuations exceeded or API call fails + """ + # Validate inputs + self._validate_messages(messages) + + # Check recursion depth + if _recursion_depth >= self.MAX_CONTINUATIONS: + msg = f"Maximum continuation depth ({self.MAX_CONTINUATIONS}) exceeded" + raise RuntimeError(msg) + + # Convert messages to Langchain format + converted_messages = [ + { + "type": self._convert_role(msg.get("role", "system")), + "data": {"content": self._extract_content(msg.get("content"))}, + } + for msg in messages + ] + + try: + lc_messages = messages_from_dict(converted_messages) + except Exception as exc: + msg = f"Failed to convert messages to Langchain format: {exc}" + raise ValueError(msg) from exc + + # Call the language model + try: + response = await self.langchain_model.agenerate( + messages=[lc_messages], + ) + except Exception as exc: + msg = f"Language model API call failed: {exc}" + raise RuntimeError(msg) from exc + + # Safely extract response + if not response.generations or not response.generations[0]: + msg = "Empty response from language model" + raise ValueError(msg) + + choice0 = response.generations[0][0] + + if not hasattr(choice0, "message") or not hasattr(choice0.message, "content"): + msg = "Malformed response from language model" + raise ValueError(msg) + + chunk = self._extract_content(choice0.text) + + # Check if we need to continue due to max tokens + generation_info = getattr(choice0, "generation_info", None) + if generation_info and isinstance(generation_info, dict): + finish_reason = generation_info.get("finish_reason") + + if finish_reason == "length": # max tokens reached + resp_msg = { + "role": "assistant", + "content": chunk, + } + continue_msg = { + "role": "user", + "content": ( + "Continue the previous answer starting exactly from the last incomplete sentence. " + "Do not repeat anything. Do not add any prefix." + ), + } + next_messages = [ + *messages, + resp_msg, + continue_msg, + ] + + # Recursive call with depth tracking + continuation = await self.generate(next_messages, _recursion_depth + 1) + return chunk + continuation + + return chunk + + +# Made with Bob diff --git a/src/lfx/src/lfx/components/models_and_agents/policies/module_utils.py b/src/lfx/src/lfx/components/models_and_agents/policies/module_utils.py new file mode 100644 index 0000000000..7ae2ed4230 --- /dev/null +++ b/src/lfx/src/lfx/components/models_and_agents/policies/module_utils.py @@ -0,0 +1,22 @@ +"""Utility functions for module management in the policies component.""" + +import sys + + +def unload_module(name: str) -> None: + """Remove a module and all its submodules from sys.modules. + + This ensures complete cleanup of dynamically generated modules, + including any nested imports that may have been created. + + Args: + name: The name of the module to unload + """ + # Remove the main module + if name in sys.modules: + del sys.modules[name] + + # Remove all submodules (e.g., module.submodule) + modules_to_remove = [mod_name for mod_name in sys.modules if mod_name.startswith(f"{name}.")] + for mod_name in modules_to_remove: + del sys.modules[mod_name] diff --git a/src/lfx/src/lfx/components/models_and_agents/policies/tool_invoker.py b/src/lfx/src/lfx/components/models_and_agents/policies/tool_invoker.py new file mode 100644 index 0000000000..fd1e47e2f9 --- /dev/null +++ b/src/lfx/src/lfx/components/models_and_agents/policies/tool_invoker.py @@ -0,0 +1,49 @@ +from typing import Any, TypeVar + +from mcp.types import CallToolResult +from pydantic import BaseModel +from toolguard.runtime import IToolInvoker + +from lfx.field_typing.constants import BaseTool +from lfx.log.logger import logger + +T = TypeVar("T") + + +class ToolInvoker(IToolInvoker): + _tools: dict[str, BaseTool] + + def __init__(self, tools: list[BaseTool]) -> None: + self._tools = {tool.name: tool for tool in tools} + + async def invoke(self, toolname: str, arguments: dict[str, Any], return_type: type[T]) -> T: + tool = self._tools.get(toolname) + if tool: + logger.info(f"invoking {toolname} internally") + res = await tool.ainvoke(input=arguments) + + if isinstance(res, CallToolResult): + res_dict = res.structuredContent + elif isinstance(res, dict) and "value" in res: + res_dict = res["value"] + else: + res_dict = res + + # Only try to extract "result" key if res_dict is a dictionary + if isinstance(res_dict, dict): + res_dict = res_dict.get("result", res_dict) + + if isinstance(res_dict, BaseModel): + res_dict = res_dict.model_dump() + + if issubclass(return_type, BaseModel): + return return_type.model_validate(res_dict) + if return_type in (int, float, str, bool): + return return_type(res_dict) + return res_dict + + msg = f"unknown tool {toolname}" + raise ValueError(msg) + + +# Made with Bob diff --git a/src/lfx/src/lfx/components/models_and_agents/policies_component.py b/src/lfx/src/lfx/components/models_and_agents/policies_component.py new file mode 100644 index 0000000000..a35ef4ea91 --- /dev/null +++ b/src/lfx/src/lfx/components/models_and_agents/policies_component.py @@ -0,0 +1,339 @@ +import os +import re +import shutil +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from toolguard.buildtime import ( + PolicySpecOptions, + ToolGuardsCodeGenerationResult, + ToolGuardSpec, + generate_guard_specs, + generate_guards_code, +) +from toolguard.extra.langchain_to_oas import langchain_tools_to_openapi +from toolguard.runtime import load_toolguards, load_toolguards_from_memory +from toolguard.runtime.runtime import RESULTS_FILENAME + +from lfx.base.models import LCModelComponent +from lfx.base.models.unified_models import ( + get_language_model_options, + get_llm, + update_model_options_in_build_config, +) +from lfx.components.models_and_agents.policies.guard_sync_utils import sync_generated_guard_code_inputs +from lfx.components.models_and_agents.policies.guarded_tool import GuardedTool +from lfx.components.models_and_agents.policies.llm_wrapper import LangchainModelWrapper +from lfx.components.models_and_agents.policies.module_utils import unload_module +from lfx.field_typing import LanguageModel, Tool +from lfx.io import ( + BoolInput, + HandleInput, + ModelInput, + MultilineInput, + Output, + SecretStrInput, + StrInput, + TabInput, +) +from lfx.log.logger import logger + +if TYPE_CHECKING: + from lfx.inputs.inputs import InputTypes + + +TOOLGUARD_WORK_DIR = Path(os.getenv("TOOLGUARD_WORK_DIR") or "tmp_toolguard") +BUILDTIME_MODELS = ["gpt-5", "claude-sonnet"] # currently inactive, we recommend but do not enforce +STEP1 = "Step_1" +STEP2 = "Step_2" +MODE_GENERATE = "🛠️ Generate" +MODE_GUARD = "🛡️ Guard" +GENERATED_GUARD_INFO_PREFIX = "Auto-generated ToolGuard code for " + + +class PoliciesComponent(LCModelComponent): + """Component for building tool protection code from textual business policies and instructions. + + This component uses ToolGuard to generate and apply policy-based guards to tools, + ensuring that tool execution complies with defined business policies. + Powered by ALTK ToolGuard (https://github.com/AgentToolkit/toolguard). + """ + + display_name = "Policies" + description = """Component for building tool protection code from textual business policies and instructions. +Powered by [ALTK ToolGuard](https://github.com/AgentToolkit/toolguard )""" + documentation: str = "https://github.com/AgentToolkit/toolguard" + icon = "shield-check" + name = "policies" + beta = True + + inputs = cast( + "list[InputTypes]", + [ + BoolInput( + name="enabled", + display_name="Enabled", + info="If `true` - guards tool calls. If `false`, skip policy validation.", + value=True, + ), + TabInput( + name="mode", + display_name="Activity", + options=[MODE_GENERATE, MODE_GUARD], + info=( + "Generate new guard code or apply existing guard. " + "Review generated files in the details panel on the right." + ), + value=MODE_GENERATE, + real_time_refresh=True, + tool_mode=True, + ), + MultilineInput( + name="project", + display_name="Policies Project", + info="Folder name of the generated code", + value="my_project", + # required=True, + ), + HandleInput( + name="in_tools", + display_name="Tools", + input_types=["Tool"], + is_list=True, + required=True, + info="These are the tools that the agent can use to help with tasks.", + ), + StrInput( + name="policies", + display_name="Policies", + info="One or more clear, well-defined and self-contained business policies", + is_list=True, + tool_mode=True, + placeholder="Add business policy...", + list_add_label="Add Policy", + # input_types=[], + ), + ModelInput( + name="model", + display_name="Language Model", + info=( + "Select LLM for Policies buildtime. We recommend using " + "Anthropic Claude-Sonnet series for this task." + ), + real_time_refresh=True, + required=True, + ), + SecretStrInput( + name="api_key", + display_name="API Key", + info="Model Provider API key", + required=False, + advanced=True, + ), + ], + ) + outputs = [ + Output( + display_name="Guarded Tools", + type_=Tool, + name="guarded_tools", + method="guard_tools", + # group_outputs=True, + ), + ] + + @property + def work_dir(self) -> Path: + return TOOLGUARD_WORK_DIR / self._to_snake_case(self.project) + + def build_model(self) -> LanguageModel: + llm_model = get_llm( + model=self.model, + user_id=self.user_id, + api_key=self.api_key, + stream=False, + ) + if llm_model is None: + msg = "No language model selected. Please choose a model to proceed." + raise ValueError(msg) + return llm_model + + def update_build_config(self, build_config: dict, field_value: str, field_name: str | None = None): + """Dynamically update build config with user-filtered model options.""" + updated_build_config = update_model_options_in_build_config( + component=self, + build_config=build_config, + cache_key_prefix="language_model_options", + get_options_func=get_language_model_options, + field_name=field_name, + field_value=field_value, + ) + py_module = self._to_snake_case(self.project) + return sync_generated_guard_code_inputs( + build_config=updated_build_config, + work_dir=self.work_dir, + step2_subdir=STEP2, + project_name=py_module, + ) + + async def _generate_guard_specs(self) -> list[ToolGuardSpec]: + logger.debug("Starting step 1") + logger.debug(f"model = {self.model}") + llm = LangchainModelWrapper(self.build_model()) + out_dir = self.work_dir / STEP1 + if out_dir.exists(): + shutil.rmtree(out_dir) + policy_text = "\n * ".join(self.policies) + open_api = langchain_tools_to_openapi(self.in_tools) + + options = PolicySpecOptions(example_number=4) + specs = await generate_guard_specs( + policy_text=policy_text, tools=open_api, llm=llm, work_dir=out_dir, options=options + ) + logger.debug("Step 1 Done") + return specs + + async def _generate_guard_code(self, specs: list[ToolGuardSpec]) -> ToolGuardsCodeGenerationResult: + logger.debug("Starting step 2") + out_dir = self.work_dir / STEP2 + if out_dir.exists(): + shutil.rmtree(out_dir) + llm = LangchainModelWrapper(self.build_model()) + app_name = self._to_snake_case(self.project) + open_api = langchain_tools_to_openapi(self.in_tools) + + gen_result = await generate_guards_code( + tools=open_api, tool_specs=specs, work_dir=out_dir, llm=llm, app_name=app_name + ) + logger.debug("Step 2 Done") + return gen_result + + def in_recommended_models(self, model_name: str): + return any(recommended in model_name for recommended in BUILDTIME_MODELS) + + def validate_before_generate(self) -> None: + """Validate required inputs before generating guard code.""" + if not self.project: + msg = "Policies: project cannot be empty!" + raise ValueError(msg) + + if not any(self.policies): + msg = "Policies: policies cannot be empty!" + raise ValueError(msg) + + if not self.in_tools: + msg = "Policies: in_tools cannot be empty!" + raise ValueError(msg) + + if not self.model or not self.api_key: + msg = "Policies: model or api_key cannot be empty!" + raise ValueError(msg) + + # uncomment if willing to enforce certain models for buildtime + # if not self.in_recommended_models(self.model[0]["name"]): + # msg = f"Policies: model {self.model[0]['name']} is not in recommended models: {BUILDTIME_MODELS}" + # raise ValueError(msg) + + async def generate(self): + specs = await self._generate_guard_specs() + res = await self._generate_guard_code(specs) + + # if there was a previous version of the guard, remove it from python cache + unload_module(res.domain.app_name) + + def _verify_cached_guards(self, code_dir: Path) -> None: + # Validate cache exists before attempting to load + if not code_dir.exists(): + msg = ( + f"Policies: Cache directory not found at '{code_dir}'. " + f"Please run in 'Generate' mode first to create the guard code, " + f"or verify the project name is correct." + ) + raise ValueError(msg) + + try: + load_toolguards(code_dir) + except FileNotFoundError as exc: + msg = ( + f"Policies: Required guard code files missing in '{code_dir}'. " + f"Please run in 'Generate' mode to create the guard code." + ) + raise ValueError(msg) from exc + except Exception as exc: + msg = ( + f"Policies: Failed to load guard code from '{code_dir}'. " + f"The cached code may be invalid or corrupted. " + f"Try running in 'Generate' mode to rebuild the guard code. " + f"Error: {exc!s}" + ) + raise ValueError(msg) from exc + + def _validate_before_using_cache(self, code_dir: Path) -> None: + if not self.in_tools: + msg = "Policies: in_tools cannot be empty!" + raise ValueError(msg) + + self._verify_cached_guards(code_dir) + + def make_toolguard_result(self) -> ToolGuardsCodeGenerationResult: + attrs = self.get_vertex().data["node"]["template"] + if not attrs: + raise ValueError + + result_str = attrs[str(RESULTS_FILENAME)]["value"] + result = ToolGuardsCodeGenerationResult.model_validate_json(result_str) + + result.domain.app_types.content = attrs.get(str(result.domain.app_types.file_name))["value"] + result.domain.app_api.content = attrs.get(str(result.domain.app_api.file_name))["value"] + result.domain.app_api_impl.content = attrs.get(str(result.domain.app_api_impl.file_name))["value"] + + for tool in result.tools.values(): + tool.guard_file.content = attrs.get(str(tool.guard_file.file_name))["value"] + for tool_item in tool.item_guard_files: + tool_item.content = attrs.get(str(tool_item.file_name))["value"] + + return result + + async def guard_tools(self) -> list[Tool]: + if self.enabled: + mode = getattr(self, "mode", MODE_GENERATE) + if mode == MODE_GENERATE: + self.log(f"Start generating guard code at {self.work_dir}", name="info") + self.validate_before_generate() + await self.generate() + self.log(f"Policies code generation saved to {self.work_dir}", name="info") + self.log("Review the generated files in the details panel on the right.", name="info") + + else: # mode == "guard" + self.log(f"using cache from {self.work_dir}", name="info") + code_dir = self.work_dir / STEP2 + self._validate_before_using_cache(code_dir) + try: + tg_result = self.make_toolguard_result() + tg_runtime = load_toolguards_from_memory(tg_result) + guarded_tools = [GuardedTool(tool, self.in_tools, tg_runtime) for tool in self.in_tools] + return cast("list[Tool]", guarded_tools) + except Exception as e: + logger.exception(e) + raise + + return self.in_tools + + @staticmethod + def _to_snake_case(human_name: str) -> str: + """Convert human-readable name to snake_case, sanitizing path traversal attempts.""" + # Convert to lowercase + result = human_name.lower() + + # Replace any non-alphanumeric character (including path traversal chars) with underscore + result = re.sub(r"[^a-z0-9]+", "_", result) + + # Strip leading/trailing underscores + result = result.strip("_") + + # Ensure the result contains at least one alphanumeric character + if not result or not re.search(r"[a-z0-9]", result): + msg = "Project name must contain at least one alphanumeric character" + raise ValueError(msg) + + return result diff --git a/uv.lock b/uv.lock index 0112c6efc1..0909ba35d1 100644 --- a/uv.lock +++ b/uv.lock @@ -29,6 +29,7 @@ members = [ ] overrides = [ { name = "aiohttp", specifier = ">=3.13.4" }, + { name = "click", specifier = ">=8.3.2" }, { name = "dynaconf", specifier = ">=3.2.13" }, { name = "gunicorn", specifier = ">=25.3.0" }, { name = "markdown", specifier = ">=3.8.0" }, @@ -63,6 +64,15 @@ http-server = [ { name = "starlette", marker = "platform_machine == 'arm64' or sys_platform != 'darwin'" }, ] +[[package]] +name = "absl-py" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" }, +] + [[package]] name = "accelerate" version = "1.13.0" @@ -390,6 +400,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "ansicolors" +version = "1.1.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/31/7faed52088732704523c259e24c26ce6f2f33fbeff2ff59274560c27628e/ansicolors-1.1.8.zip", hash = "sha256:99f94f5e3348a0bcd43c82e5fc4414013ccc19d70bd939ad71e0133ce9c372e0", size = 23027, upload-time = "2017-06-02T21:22:10.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/18/a56e2fe47b259bb52201093a3a9d4a32014f9d85071ad07e9d60600890ca/ansicolors-1.1.8-py2.py3-none-any.whl", hash = "sha256:00d2dde5a675579325902536738dd27e4fac1fd68f773fe36c21044eb559e187", size = 13847, upload-time = "2017-06-02T21:22:12.67Z" }, +] + [[package]] name = "anthropic" version = "0.91.0" @@ -2088,7 +2107,7 @@ wheels = [ [[package]] name = "datamodel-code-generator" -version = "0.39.0" +version = "0.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "argcomplete" }, @@ -2097,14 +2116,13 @@ dependencies = [ { name = "inflect" }, { name = "isort" }, { name = "jinja2" }, - { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, { name = "tomli", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/d8/9c633eafb1cb7f9ccb607bdbda0601d65fe3441954f2998e4222338a0a17/datamodel_code_generator-0.39.0.tar.gz", hash = "sha256:d08b5f9cce8fff2b57784c8f1d737fa91ad22ac621a6a732946307730f24f887", size = 447299, upload-time = "2025-12-02T19:29:58.164Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/7d/7fc2bb3d8946ca45851da3f23497a2c6e252e92558ccbd89d609cf1e13d4/datamodel_code_generator-0.56.0.tar.gz", hash = "sha256:e7c003fb5421b890aabe12f66ae65b57198b04cfe1da7c40810798020835b3a8", size = 837708, upload-time = "2026-04-04T09:46:19.636Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/74/aef4da1b08d5c3a70eb25a1fc2c6499cc8588c803c2ebe770f2e5fb77dce/datamodel_code_generator-0.39.0-py3-none-any.whl", hash = "sha256:f08cd8cf826d3556f638989af4851037026a93cfb846aa6429b13cd622714775", size = 151348, upload-time = "2025-12-02T19:29:56.632Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3a/7f169ffc7a2d69a4f9158b1ac083f685b7f4a1a8a1db5d1e4abbb4e741b7/datamodel_code_generator-0.56.0-py3-none-any.whl", hash = "sha256:a0559683fbe90cdf2ce9b6637e3adae3e3a8056a8d0516df581d486e2834ead2", size = 256545, upload-time = "2026-04-04T09:46:17.582Z" }, ] [[package]] @@ -3480,10 +3498,10 @@ name = "gassist" version = "0.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "sys_platform != 'darwin'" }, - { name = "flask", marker = "sys_platform != 'darwin'" }, - { name = "flask-cors", marker = "sys_platform != 'darwin'" }, - { name = "tqdm", marker = "sys_platform != 'darwin'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "flask", marker = "sys_platform == 'win32'" }, + { name = "flask-cors", marker = "sys_platform == 'win32'" }, + { name = "tqdm", marker = "sys_platform == 'win32'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/b0/2e/f79632d7300874f7f0e60b61a6ab22455a245e1556116a1729542a77b0da/gassist-0.0.1-py3-none-any.whl", hash = "sha256:bb0fac74b453153a6c74b2db40a14fdde7879cbc10ec692ed170e576c8e2b6aa", size = 23819, upload-time = "2025-05-09T18:22:23.609Z" }, @@ -3974,6 +3992,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/30/44c7eb0a952478dbb5f2f67df806686d6a7e4b19f6204e091c4f49dc7c69/grandalf-0.8-py3-none-any.whl", hash = "sha256:793ca254442f4a79252ea9ff1ab998e852c1e071b863593e5383afee906b4185", size = 41802, upload-time = "2023-01-10T15:16:19.753Z" }, ] +[[package]] +name = "granite-common" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/10/ca8f59c644a3574a443bb85ff807f1ebbe726a6ad75bd471e092ab002f37/granite_common-0.4.1.tar.gz", hash = "sha256:5290e03d43e2962218aaf13c9c43877af6fb7869332a4ea35983c4f6a206d801", size = 714066, upload-time = "2026-02-25T01:10:45.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/ee/c52f5ddb073c111c19f15889646fd65e0be2b4e0b01764237d1ecbcd5bfe/granite_common-0.4.1-py3-none-any.whl", hash = "sha256:e82df48f69a98b46dbff8a36c10a64a13d4400fed2425f2ad9a6981031544062", size = 86633, upload-time = "2026-02-25T01:10:43.845Z" }, +] + [[package]] name = "graph-retriever" version = "0.8.0" @@ -5160,6 +5191,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/2d/79a46330c4b97ee90dd403fb0d267da7b25b24d7db604c5294e5c57d5f7c/json_repair-0.30.3-py3-none-any.whl", hash = "sha256:63bb588162b0958ae93d85356ecbe54c06b8c33f8a4834f93fa2719ea669804e", size = 18951, upload-time = "2024-12-04T19:53:00.612Z" }, ] +[[package]] +name = "json5" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/4b/6f8906aaf67d501e259b0adab4d312945bb7211e8b8d4dcc77c92320edaa/json5-0.14.0.tar.gz", hash = "sha256:b3f492fad9f6cdbced8b7d40b28b9b1c9701c5f561bef0d33b81c2ff433fefcb", size = 52656, upload-time = "2026-03-27T22:50:48.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/42/cf027b4ac873b076189d935b135397675dac80cb29acb13e1ab86ad6c631/json5-0.14.0-py3-none-any.whl", hash = "sha256:56cf861bab076b1178eb8c92e1311d273a9b9acea2ccc82c276abf839ebaef3a", size = 36271, upload-time = "2026-03-27T22:50:47.073Z" }, +] + [[package]] name = "jsonlines" version = "4.0.0" @@ -5913,6 +5953,7 @@ name = "langflow" version = "1.9.0" source = { editable = "." } dependencies = [ + { name = "click" }, { name = "langflow-base", extra = ["complete"] }, ] @@ -5993,6 +6034,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "cassio", marker = "extra == 'cassio'", specifier = ">=0.1.7" }, + { name = "click", specifier = ">=8.3.2" }, { name = "clickhouse-connect", marker = "extra == 'clickhouse-connect'", specifier = "==0.7.19" }, { name = "couchbase", marker = "extra == 'couchbase'", specifier = ">=4.2.1" }, { name = "ctransformers", marker = "extra == 'local'", specifier = ">=0.2.10" }, @@ -6254,6 +6296,7 @@ all = [ { name = "spider-client" }, { name = "sseclient-py" }, { name = "supabase" }, + { name = "toolguard" }, { name = "traceloop-sdk" }, { name = "twelvelabs" }, { name = "upstash-vector" }, @@ -6418,6 +6461,7 @@ complete = [ { name = "spider-client" }, { name = "sseclient-py" }, { name = "supabase" }, + { name = "toolguard" }, { name = "traceloop-sdk" }, { name = "twelvelabs" }, { name = "upstash-vector" }, @@ -6672,6 +6716,9 @@ sseclient = [ supabase = [ { name = "supabase" }, ] +toolguard = [ + { name = "toolguard" }, +] traceloop = [ { name = "traceloop-sdk" }, ] @@ -6929,6 +6976,7 @@ requires-dist = [ { name = "langflow-base", extras = ["spider"], marker = "extra == 'complete'", editable = "src/backend/base" }, { name = "langflow-base", extras = ["sseclient"], marker = "extra == 'complete'", editable = "src/backend/base" }, { name = "langflow-base", extras = ["supabase"], marker = "extra == 'complete'", editable = "src/backend/base" }, + { name = "langflow-base", extras = ["toolguard"], marker = "extra == 'complete'", editable = "src/backend/base" }, { name = "langflow-base", extras = ["traceloop"], marker = "extra == 'complete'", editable = "src/backend/base" }, { name = "langflow-base", extras = ["twelvelabs"], marker = "extra == 'complete'", editable = "src/backend/base" }, { name = "langflow-base", extras = ["upstash"], marker = "extra == 'complete'", editable = "src/backend/base" }, @@ -7018,6 +7066,7 @@ requires-dist = [ { name = "sseclient-py", marker = "extra == 'sseclient'", specifier = "==1.8.0" }, { name = "structlog", specifier = ">=25.4.0,<26.0.0" }, { name = "supabase", marker = "extra == 'supabase'", specifier = ">=2.6.0,<3.0.0" }, + { name = "toolguard", marker = "extra == 'toolguard'", specifier = ">=0.2.14,<1.0.0" }, { name = "traceloop-sdk", marker = "extra == 'traceloop'", specifier = ">=0.43.1,<1.0.0" }, { name = "trustcall", specifier = ">=0.0.38,<1.0.0" }, { name = "twelvelabs", marker = "extra == 'twelvelabs'", specifier = ">=0.4.7,<1.0.0" }, @@ -7036,7 +7085,7 @@ requires-dist = [ { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = ">=1.0.0,<2.0.0" }, { name = "zep-python", marker = "extra == 'zep'", specifier = "==2.0.2" }, ] -provides-extras = ["audio", "postgresql", "local", "couchbase", "cassandra", "clickhouse", "mongodb", "supabase", "redis", "elasticsearch", "faiss", "qdrant", "weaviate", "chroma", "upstash", "pinecone", "milvus", "mongodb-vectors", "astradb", "openai", "anthropic", "cohere", "google", "ollama", "nvidia", "mistral", "sambanova", "groq", "llama-cpp", "sentence-transformers", "ctransformers", "langfuse", "langwatch", "langsmith", "arize", "pypdf", "docx", "pytube", "dspy", "smolagents", "beautifulsoup", "serpapi", "google-api", "wikipedia", "fake-useragent", "duckduckgo", "yfinance", "wolframalpha", "pyarrow", "fastavro", "gitpython", "nltk", "lark", "huggingface", "metaphor", "metal", "markup", "aws", "numexpr", "qianfan", "pgvector", "litellm", "zep", "youtube", "markdown", "kubernetes", "json-repair", "composio", "ragstack", "opensearch", "atlassian", "mem0", "needle", "sseclient", "openinference", "mcp", "ag2", "scrapegraph", "apify", "spider", "altk", "langchain-huggingface", "langchain-unstructured", "graph-retriever", "langchain-mcp-adapters", "opik", "traceloop", "openlayer", "docling", "easyocr", "cleanlab", "twelvelabs", "jigsawstack", "assemblyai", "datasets", "fastparquet", "vlmrun", "cuga", "mlx", "fastmcp", "aioboto3", "astrapy", "gassist", "ibm-watsonx-clients", "complete", "all"] +provides-extras = ["audio", "postgresql", "local", "couchbase", "cassandra", "clickhouse", "mongodb", "supabase", "redis", "elasticsearch", "faiss", "qdrant", "weaviate", "chroma", "upstash", "pinecone", "milvus", "mongodb-vectors", "astradb", "openai", "anthropic", "cohere", "google", "ollama", "nvidia", "mistral", "sambanova", "groq", "llama-cpp", "sentence-transformers", "ctransformers", "langfuse", "langwatch", "langsmith", "arize", "pypdf", "docx", "pytube", "dspy", "smolagents", "beautifulsoup", "serpapi", "google-api", "wikipedia", "fake-useragent", "duckduckgo", "yfinance", "wolframalpha", "pyarrow", "fastavro", "gitpython", "nltk", "lark", "huggingface", "metaphor", "metal", "markup", "aws", "numexpr", "qianfan", "pgvector", "litellm", "zep", "youtube", "markdown", "kubernetes", "json-repair", "composio", "ragstack", "opensearch", "atlassian", "mem0", "needle", "sseclient", "openinference", "mcp", "ag2", "scrapegraph", "apify", "spider", "altk", "toolguard", "langchain-huggingface", "langchain-unstructured", "graph-retriever", "langchain-mcp-adapters", "opik", "traceloop", "openlayer", "docling", "easyocr", "cleanlab", "twelvelabs", "jigsawstack", "assemblyai", "datasets", "fastparquet", "vlmrun", "cuga", "mlx", "fastmcp", "aioboto3", "astrapy", "gassist", "ibm-watsonx-clients", "complete", "all"] [package.metadata.requires-dev] dev = [ @@ -7256,6 +7305,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/92/56a954dd59637dd2ee013581fa3beea0821f17f2c07f818fc51dcc11fd10/latex2mathml-3.79.0-py3-none-any.whl", hash = "sha256:9f10720d4fcf6b22d1b81f6628237832419a7a29783c13aa92fa8d680165e63d", size = 73945, upload-time = "2026-03-12T23:25:09.466Z" }, ] +[[package]] +name = "latex2sympy2-extended" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "sympy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/75/456da2da05f6380ea96e6ea804ab2c03e41fc3ed80052307fe8efe6ea20e/latex2sympy2_extended-1.11.0.tar.gz", hash = "sha256:9695657c81b50abba2636638638618db59f4663ed2a4a12d62cef74a40e28fec", size = 207023, upload-time = "2026-01-10T01:43:21.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/61/f75cd1fa54d8434276126034aed54dd120747de9a8fa013cdd79545ccbeb/latex2sympy2_extended-1.11.0-py3-none-any.whl", hash = "sha256:aebb77d52ce269e25028e4bea89ddb14d242ba36bcf7b636496fb5fd9728d234", size = 209050, upload-time = "2026-01-10T01:43:19.458Z" }, +] + [[package]] name = "lazy-loader" version = "0.5" @@ -7863,6 +7925,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] +[[package]] +name = "math-verify" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "latex2sympy2-extended" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/12/b8d13b581e110ac2f724a2351a8361a70fa36d057eb945d6379e8747c256/math_verify-0.9.0.tar.gz", hash = "sha256:45ac6c61344ba056b9e99a660a4bc8d044ed408f730aed68c60435aa5eec4645", size = 60329, upload-time = "2026-01-10T01:48:33.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/76/6b4969bccc842b6567f7e6ee015684b9428a9b7fcbdf479e73716f43597f/math_verify-0.9.0-py3-none-any.whl", hash = "sha256:3703e7c4885354027fa84409d762a596a2906d1fd4deb78361876bd905a76194", size = 29967, upload-time = "2026-01-10T01:48:31.674Z" }, +] + [[package]] name = "matplotlib-inline" version = "0.2.1" @@ -7932,6 +8006,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mellea" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ansicolors" }, + { name = "click" }, + { name = "fastapi" }, + { name = "granite-common" }, + { name = "huggingface-hub" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "llm-sandbox", extra = ["docker"] }, + { name = "math-verify" }, + { name = "mistletoe" }, + { name = "ollama" }, + { name = "openai" }, + { name = "pillow" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "rouge-score" }, + { name = "typer" }, + { name = "types-requests" }, + { name = "types-tqdm" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/e9/25d87d92064b9781ef3e15d032f6f50deb2ff70b35d3c27f21ff595666ca/mellea-0.3.2.tar.gz", hash = "sha256:b73c5c1da473891e85005042a8e1ac26eae4026f448306884de3c8ba56df1965", size = 3583161, upload-time = "2026-02-26T13:43:30.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/d8/4bcf1174810518d7f9fb27ae8d77a9001bd975ef26d24f734f89df051ddc/mellea-0.3.2-py3-none-any.whl", hash = "sha256:ef9beb4b5b3f8c2099d17df592067646ecac2bc47b8f7f8b19951c3acbe37174", size = 3864719, upload-time = "2026-02-26T13:43:29.118Z" }, +] + [[package]] name = "mem0ai" version = "0.1.34" @@ -7979,7 +8084,7 @@ name = "milvus-lite" version = "2.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tqdm", marker = "platform_machine == 'arm64' or sys_platform != 'darwin'" }, + { name = "tqdm", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/9b/2e/746f5bb1d6facd1e73eb4af6dd5efda11125b0f29d7908a097485ca6cad9/milvus_lite-2.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2e031088bf308afe5f8567850412d618cfb05a65238ed1a6117f60decccc95a", size = 24421451, upload-time = "2025-06-30T04:23:51.747Z" }, @@ -7987,6 +8092,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/82/41d9b80f09b82e066894d9b508af07b7b0fa325ce0322980674de49106a0/milvus_lite-2.5.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25ce13f4b8d46876dd2b7ac8563d7d8306da7ff3999bb0d14b116b30f71d706c", size = 55263911, upload-time = "2025-06-30T04:24:19.434Z" }, ] +[[package]] +name = "mistletoe" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/ae/d33647e2a26a8899224f36afc5e7b7a670af30f1fd87231e9f07ca19d673/mistletoe-1.5.1.tar.gz", hash = "sha256:c5571ce6ca9cfdc7ce9151c3ae79acb418e067812000907616427197648030a3", size = 111769, upload-time = "2025-12-07T16:19:01.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/60/0980fefdc4d12c18c1bbab9d62852f27aded8839233c7b0a9827aaf395f5/mistletoe-1.5.1-py3-none-any.whl", hash = "sha256:d3e97664798261503f685f6a6281b092628367cf3128fc68a015a993b0c4feb3", size = 55331, upload-time = "2025-12-07T16:18:59.65Z" }, +] + [[package]] name = "mlx" version = "0.31.1" @@ -8733,9 +8847,9 @@ name = "ocrmac" version = "1.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "pillow" }, - { name = "pyobjc-framework-vision" }, + { name = "click", marker = "sys_platform == 'darwin'" }, + { name = "pillow", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-vision", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/07/3e15ab404f75875c5e48c47163300eb90b7409044d8711fc3aaf52503f2e/ocrmac-1.0.1.tar.gz", hash = "sha256:507fe5e4cbd67b2d03f6729a52bbc11f9d0b58241134eb958a5daafd4b9d93d9", size = 1454317, upload-time = "2026-01-08T16:44:26.412Z" } wheels = [ @@ -10231,7 +10345,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -11449,7 +11563,7 @@ name = "pyobjc-framework-cocoa" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } wheels = [ @@ -11465,8 +11579,8 @@ name = "pyobjc-framework-coreml" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/30/2d/baa9ea02cbb1c200683cb7273b69b4bee5070e86f2060b77e6a27c2a9d7e/pyobjc_framework_coreml-12.1.tar.gz", hash = "sha256:0d1a4216891a18775c9e0170d908714c18e4f53f9dc79fb0f5263b2aa81609ba", size = 40465, upload-time = "2025-11-14T10:14:02.265Z" } wheels = [ @@ -11482,8 +11596,8 @@ name = "pyobjc-framework-quartz" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } wheels = [ @@ -11499,10 +11613,10 @@ name = "pyobjc-framework-vision" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coreml" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-coreml", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c2/5a/08bb3e278f870443d226c141af14205ff41c0274da1e053b72b11dfc9fb2/pyobjc_framework_vision-12.1.tar.gz", hash = "sha256:a30959100e85dcede3a786c544e621ad6eb65ff6abf85721f805822b8c5fe9b0", size = 59538, upload-time = "2025-11-14T10:23:21.979Z" } wheels = [ @@ -11593,6 +11707,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, ] +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + [[package]] name = "pysher" version = "1.0.8" @@ -11720,6 +11847,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/c0/c32dc39fc172e684fdb3d30169843efb65c067be1e12689af4345731126e/pytest_instafail-0.5.0-py3-none-any.whl", hash = "sha256:6855414487e9e4bb76a118ce952c3c27d3866af15487506c4ded92eb72387819", size = 4176, upload-time = "2023-03-31T17:17:30.065Z" }, ] +[[package]] +name = "pytest-json-report" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "pytest-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/d3/765dae9712fcd68d820338908c1337e077d5fdadccd5cacf95b9b0bea278/pytest-json-report-1.5.0.tar.gz", hash = "sha256:2dde3c647851a19b5f3700729e8310a6e66efb2077d674f27ddea3d34dc615de", size = 21241, upload-time = "2022-03-15T21:03:10.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/35/d07400c715bf8a88aa0c1ee9c9eb6050ca7fe5b39981f0eea773feeb0681/pytest_json_report-1.5.0-py3-none-any.whl", hash = "sha256:9897b68c910b12a2e48dd849f9a284b2c79a732a8a9cb398452ddd23d3c8c325", size = 13222, upload-time = "2022-03-15T21:03:08.65Z" }, +] + +[[package]] +name = "pytest-metadata" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952, upload-time = "2024-02-12T19:38:44.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" }, +] + [[package]] name = "pytest-mock" version = "3.15.1" @@ -12767,6 +12919,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/8b/a1299085b28a2f6135e30370b126e3c5055b61908622f2488ade67641479/rignore-0.7.6-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:d8955b57e42f2a5434670d5aa7b75eaf6e74602ccd8955dddf7045379cd762fb", size = 1129444, upload-time = "2025-11-05T21:41:17.906Z" }, ] +[[package]] +name = "rouge-score" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "absl-py" }, + { name = "nltk" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/c5/9136736c37022a6ad27fea38f3111eb8f02fe75d067f9a985cc358653102/rouge_score-0.1.2.tar.gz", hash = "sha256:c7d4da2683e68c9abf0135ef915d63a46643666f848e558a1b9f7ead17ff0f04", size = 17400, upload-time = "2022-07-22T22:46:22.909Z" } + [[package]] name = "rpds-py" version = "0.30.0" @@ -14199,6 +14364,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "toolguard" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "datamodel-code-generator" }, + { name = "fastmcp" }, + { name = "langchain-core" }, + { name = "litellm" }, + { name = "loguru" }, + { name = "markdown" }, + { name = "mellea" }, + { name = "pydantic" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-json-report" }, + { name = "smolagents" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/b0/7c05b63fb8879bae9eb21ed22812c453ffa899dabd12f381cf527fb86600/toolguard-0.2.14.tar.gz", hash = "sha256:ab11c99693c7a599902221de9df3c429b1e40817e65797713b0b2c09cf754af4", size = 65706, upload-time = "2026-04-08T05:09:44.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/5f/dd2492ea4e9f32899730a2fcb9f17b4150d794b0dee245f3e27f4da0b8be/toolguard-0.2.14-py3-none-any.whl", hash = "sha256:4ecf926169d00ab5a8a5e4c9d5e3e4e6b48b57e11b49fffdab9052d0c71715c5", size = 96176, upload-time = "2026-04-08T05:09:43.477Z" }, +] + [[package]] name = "toonify" version = "1.6.0" @@ -14682,6 +14871,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" }, ] +[[package]] +name = "types-tqdm" +version = "4.67.3.20260408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/42/2e2968e68a694d3dac3a47aa0df06e46be1a6eef498e5bd15f4c54674eb9/types_tqdm-4.67.3.20260408.tar.gz", hash = "sha256:fd849a79891ae7136ed47541aface15c35bd9a13160fa8a93e42e10f60cf4c8d", size = 18119, upload-time = "2026-04-08T04:36:52.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/5d/7dedddc32ab7bc2344ece772b5e0f03ec63a1d47ad259696689713c1cf50/types_tqdm-4.67.3.20260408-py3-none-any.whl", hash = "sha256:3b9ed74ebef04df8f53d470ffdc84348e93496d8acafa08bf79fafce0f2f5b5d", size = 24561, upload-time = "2026-04-08T04:36:51.538Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -14981,7 +15182,7 @@ all = [ [[package]] name = "vlmrun-hub" -version = "0.1.35" +version = "0.1.34" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -14993,9 +15194,9 @@ dependencies = [ { name = "pydantic-yaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/ad/df0f9f4c93aa30c7fe6818c5152054cde2ad5dc606892313b55dfa3c5860/vlmrun_hub-0.1.35.tar.gz", hash = "sha256:a15b0a7b6d1c32f752f65aadb51148c3d69791db9b7e30e69e6cbdc82a067f65", size = 65092, upload-time = "2025-12-15T22:41:51.969Z" } +sdist = { url = "https://files.pythonhosted.org/packages/73/58/f71034ef6d8e4d29cf372fdca6c12d373e98b131cba7ab3a22c2fa897042/vlmrun_hub-0.1.34.tar.gz", hash = "sha256:1bb1b356e51555eb542e3ffd35f28fe6e3d2f5755e5df5cbf3809062e5a44c7c", size = 58974, upload-time = "2025-04-07T16:48:37.316Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/42/cf00ae0daf18b7d30ca48e84bf1b492c644df5ba7afa24c0c0417c78eb9f/vlmrun_hub-0.1.35-py3-none-any.whl", hash = "sha256:d8759ec2ba698ac43cd217fb7ffd2b79f214d998339236e8f37fe9a9e7642e9c", size = 65999, upload-time = "2025-12-15T22:41:50.658Z" }, + { url = "https://files.pythonhosted.org/packages/1a/15/a7ac48035d739b12b396dbebeef73b034d9a8a199808673046c71fe2fd03/vlmrun_hub-0.1.34-py3-none-any.whl", hash = "sha256:79c897dad972e2cce610b788346a7826db4fbabde613e2a0d28d46242a52a9bf", size = 58799, upload-time = "2025-04-07T16:48:35.836Z" }, ] [[package]]