mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-24 01:22:23 +08:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@ -292,3 +292,4 @@ sso-config.yaml
|
||||
AGENTS.md
|
||||
CLAUDE.local.md
|
||||
langflow.log.*
|
||||
tmp_toolguard/
|
||||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@ -24,7 +24,9 @@
|
||||
"--reload-include",
|
||||
"./src/lfx/*",
|
||||
"--reload-exclude",
|
||||
"*.db*"
|
||||
"*.db*",
|
||||
"--reload-exclude",
|
||||
"./tmp_toolguard/*"
|
||||
],
|
||||
"jinja": true,
|
||||
"justMyCode": false,
|
||||
|
||||
50
docs/docs/Components/policies.mdx
Normal file
50
docs/docs/Components/policies.mdx
Normal 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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]",
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
"""Unit tests for policies components."""
|
||||
|
||||
# Made with Bob
|
||||
@ -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}})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
@ -2163,5 +2163,15 @@
|
||||
"0.3.0": "8b5ca1f38f6e",
|
||||
"0.3.1": "8b5ca1f38f6e"
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"versions": {
|
||||
"0.3.0": "fe3303671fea"
|
||||
}
|
||||
},
|
||||
"policies": {
|
||||
"versions": {
|
||||
"0.3.0": "9bf5e6f39e2d"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
# PoliciesComponent has been moved to lfx.components.models_and_agents
|
||||
# Supporting utilities remain here for the component to use
|
||||
|
||||
__all__ = []
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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]
|
||||
@ -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
|
||||
@ -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
253
uv.lock
generated
@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user