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 <DAVIDBO@il.ibm.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Eric Hare
2026-04-08 14:36:23 -07:00
committed by GitHub
parent eff08eed1f
commit 64f84ab0ea
22 changed files with 2352 additions and 30 deletions

1
.gitignore vendored
View File

@ -292,3 +292,4 @@ sso-config.yaml
AGENTS.md
CLAUDE.local.md
langflow.log.*
tmp_toolguard/

4
.vscode/launch.json vendored
View File

@ -24,7 +24,9 @@
"--reload-include",
"./src/lfx/*",
"--reload-exclude",
"*.db*"
"*.db*",
"--reload-exclude",
"./tmp_toolguard/*"
],
"jinja": true,
"justMyCode": false,

View File

@ -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

View File

@ -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]

View File

@ -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]",

View File

@ -0,0 +1,3 @@
"""Unit tests for policies components."""
# Made with Bob

View File

@ -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}})

View File

@ -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

View File

@ -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

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -2163,5 +2163,15 @@
"0.3.0": "8b5ca1f38f6e",
"0.3.1": "8b5ca1f38f6e"
}
},
"policies": {
"versions": {
"0.3.0": "fe3303671fea"
}
},
"policies": {
"versions": {
"0.3.0": "9bf5e6f39e2d"
}
}
}

View File

@ -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)

View File

@ -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",
]

View File

@ -0,0 +1,4 @@
# PoliciesComponent has been moved to lfx.components.models_and_agents
# Supporting utilities remain here for the component to use
__all__ = []

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

253
uv.lock generated
View File

@ -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]]