mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-04-26 00:46:22 +02:00
* Bump setup.py versions for 1.1 * PoC MCP server (#419) * Very initial MCP server PoC for TrustGraph * Put service on port 8000 * Add MCP container and packages to buildout * Update docs for API/CLI changes in 1.0 (#421) * Update some API basics for the 0.23/1.0 API change * Add MCP container push (#425) * Add command args to the MCP server (#426) * Host and port parameters * Added websocket arg * More docs * MCP client support (#427) - MCP client service - Tool request/response schema - API gateway support for mcp-tool - Message translation for tool request & response - Make mcp-tool using configuration service for information about where the MCP services are. * Feature/react call mcp (#428) Key Features - MCP Tool Integration: Added core MCP tool support with ToolClientSpec and ToolClient classes - API Enhancement: New mcp_tool method for flow-specific tool invocation - CLI Tooling: New tg-invoke-mcp-tool command for testing MCP integration - React Agent Enhancement: Fixed and improved multi-tool invocation capabilities - Tool Management: Enhanced CLI for tool configuration and management Changes - Added MCP tool invocation to API with flow-specific integration - Implemented ToolClientSpec and ToolClient for tool call handling - Updated agent-manager-react to invoke MCP tools with configurable types - Enhanced CLI with new commands and improved help text - Added comprehensive documentation for new CLI commands - Improved tool configuration management Testing - Added tg-invoke-mcp-tool CLI command for isolated MCP integration testing - Enhanced agent capability to invoke multiple tools simultaneously * Test suite executed from CI pipeline (#433) * Test strategy & test cases * Unit tests * Integration tests * Extending test coverage (#434) * Contract tests * Testing embeedings * Agent unit tests * Knowledge pipeline tests * Turn on contract tests * Increase storage test coverage (#435) * Fixing storage and adding tests * PR pipeline only runs quick tests * Empty configuration is returned as empty list, previously was not in response (#436) * Update config util to take files as well as command-line text (#437) * Updated CLI invocation and config model for tools and mcp (#438) * Updated CLI invocation and config model for tools and mcp * CLI anomalies * Tweaked the MCP tool implementation for new model * Update agent implementation to match the new model * Fix agent tools, now all tested * Fixed integration tests * Fix MCP delete tool params * Update Python deps to 1.2 * Update to enable knowledge extraction using the agent framework (#439) * Implement KG extraction agent (kg-extract-agent) * Using ReAct framework (agent-manager-react) * ReAct manager had an issue when emitting JSON, which conflicts which ReAct manager's own JSON messages, so refactored ReAct manager to use traditional ReAct messages, non-JSON structure. * Minor refactor to take the prompt template client out of prompt-template so it can be more readily used by other modules. kg-extract-agent uses this framework. * Migrate from setup.py to pyproject.toml (#440) * Converted setup.py to pyproject.toml * Modern package infrastructure as recommended by py docs * Install missing build deps (#441) * Install missing build deps (#442) * Implement logging strategy (#444) * Logging strategy and convert all prints() to logging invocations * Fix/startup failure (#445) * Fix loggin startup problems * Fix logging startup problems (#446) * Fix logging startup problems (#447) * Fixed Mistral OCR to use current API (#448) * Fixed Mistral OCR to use current API * Added PDF decoder tests * Fix Mistral OCR ident to be standard pdf-decoder (#450) * Fix Mistral OCR ident to be standard pdf-decoder * Correct test * Schema structure refactor (#451) * Write schema refactor spec * Implemented schema refactor spec * Structure data mvp (#452) * Structured data tech spec * Architecture principles * New schemas * Updated schemas and specs * Object extractor * Add .coveragerc * New tests * Cassandra object storage * Trying to object extraction working, issues exist * Validate librarian collection (#453) * Fix token chunker, broken API invocation (#454) * Fix token chunker, broken API invocation (#455) * Knowledge load utility CLI (#456) * Knowledge loader * More tests
426 lines
No EOL
14 KiB
Python
426 lines
No EOL
14 KiB
Python
"""
|
|
Edge case and error handling tests for PromptManager
|
|
|
|
These tests focus on boundary conditions, error scenarios, and
|
|
unusual but valid use cases for the PromptManager.
|
|
"""
|
|
|
|
import pytest
|
|
import json
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
|
|
from trustgraph.template.prompt_manager import PromptManager, PromptConfiguration, Prompt
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPromptManagerEdgeCases:
|
|
"""Edge case tests for PromptManager"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_very_large_json_response(self):
|
|
"""Test handling of very large JSON responses"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["large_json"]),
|
|
"template.large_json": json.dumps({
|
|
"prompt": "Generate large dataset",
|
|
"response-type": "json"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
# Create a large JSON structure
|
|
large_data = {
|
|
f"item_{i}": {
|
|
"name": f"Item {i}",
|
|
"data": list(range(100)),
|
|
"nested": {
|
|
"level1": {
|
|
"level2": f"Deep value {i}"
|
|
}
|
|
}
|
|
}
|
|
for i in range(100)
|
|
}
|
|
|
|
mock_llm = AsyncMock()
|
|
mock_llm.return_value = json.dumps(large_data)
|
|
|
|
result = await pm.invoke("large_json", {}, mock_llm)
|
|
|
|
assert isinstance(result, dict)
|
|
assert len(result) == 100
|
|
assert "item_50" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unicode_and_special_characters(self):
|
|
"""Test handling of unicode and special characters"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["unicode"]),
|
|
"template.unicode": json.dumps({
|
|
"prompt": "Process text: {{ text }}",
|
|
"response-type": "text"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
special_text = "Hello 世界! 🌍 Привет мир! مرحبا بالعالم"
|
|
|
|
mock_llm = AsyncMock()
|
|
mock_llm.return_value = f"Processed: {special_text}"
|
|
|
|
result = await pm.invoke("unicode", {"text": special_text}, mock_llm)
|
|
|
|
assert special_text in result
|
|
assert "🌍" in result
|
|
assert "世界" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_json_in_text_response(self):
|
|
"""Test text response containing JSON-like structures"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["text_with_json"]),
|
|
"template.text_with_json": json.dumps({
|
|
"prompt": "Explain this data",
|
|
"response-type": "text" # Text response, not JSON
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
mock_llm = AsyncMock()
|
|
mock_llm.return_value = """
|
|
The data structure is:
|
|
{
|
|
"key": "value",
|
|
"nested": {
|
|
"array": [1, 2, 3]
|
|
}
|
|
}
|
|
This represents a nested object.
|
|
"""
|
|
|
|
result = await pm.invoke("text_with_json", {}, mock_llm)
|
|
|
|
assert isinstance(result, str) # Should remain as text
|
|
assert '"key": "value"' in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_json_blocks_in_response(self):
|
|
"""Test response with multiple JSON blocks"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["multi_json"]),
|
|
"template.multi_json": json.dumps({
|
|
"prompt": "Generate examples",
|
|
"response-type": "json"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
mock_llm = AsyncMock()
|
|
mock_llm.return_value = """
|
|
Here's the first example:
|
|
```json
|
|
{"first": true, "value": 1}
|
|
```
|
|
|
|
And here's another:
|
|
```json
|
|
{"second": true, "value": 2}
|
|
```
|
|
"""
|
|
|
|
# Should extract the first valid JSON block
|
|
result = await pm.invoke("multi_json", {}, mock_llm)
|
|
|
|
assert result == {"first": True, "value": 1}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_json_with_comments(self):
|
|
"""Test JSON response with comment-like content"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["json_comments"]),
|
|
"template.json_comments": json.dumps({
|
|
"prompt": "Generate config",
|
|
"response-type": "json"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
mock_llm = AsyncMock()
|
|
# JSON with comment-like content that should be extracted
|
|
mock_llm.return_value = """
|
|
// This is a configuration file
|
|
{
|
|
"setting": "value", // Important setting
|
|
"number": 42
|
|
}
|
|
/* End of config */
|
|
"""
|
|
|
|
# Standard JSON parser won't handle comments
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await pm.invoke("json_comments", {}, mock_llm)
|
|
|
|
assert "JSON parse fail" in str(exc_info.value)
|
|
|
|
def test_template_with_basic_substitution(self):
|
|
"""Test template with basic variable substitution"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["basic_template"]),
|
|
"template.basic_template": json.dumps({
|
|
"prompt": """
|
|
Normal: {{ variable }}
|
|
Another: {{ another }}
|
|
""",
|
|
"response-type": "text"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
result = pm.render(
|
|
"basic_template",
|
|
{"variable": "processed", "another": "also processed"}
|
|
)
|
|
|
|
assert "processed" in result
|
|
assert "also processed" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_json_response_variations(self):
|
|
"""Test various empty JSON response formats"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["empty_json"]),
|
|
"template.empty_json": json.dumps({
|
|
"prompt": "Generate empty data",
|
|
"response-type": "json"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
empty_variations = [
|
|
"{}",
|
|
"[]",
|
|
"null",
|
|
'""',
|
|
"0",
|
|
"false"
|
|
]
|
|
|
|
for empty_value in empty_variations:
|
|
mock_llm = AsyncMock()
|
|
mock_llm.return_value = empty_value
|
|
|
|
result = await pm.invoke("empty_json", {}, mock_llm)
|
|
assert result == json.loads(empty_value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_json_recovery(self):
|
|
"""Test recovery from slightly malformed JSON"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["malformed"]),
|
|
"template.malformed": json.dumps({
|
|
"prompt": "Generate data",
|
|
"response-type": "json"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
# Missing closing brace - should fail
|
|
mock_llm = AsyncMock()
|
|
mock_llm.return_value = '{"key": "value"'
|
|
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await pm.invoke("malformed", {}, mock_llm)
|
|
|
|
assert "JSON parse fail" in str(exc_info.value)
|
|
|
|
def test_template_infinite_loop_protection(self):
|
|
"""Test protection against infinite template loops"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["recursive"]),
|
|
"template.recursive": json.dumps({
|
|
"prompt": "{{ recursive_var }}",
|
|
"response-type": "text"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
pm.prompts["recursive"].terms = {"recursive_var": "This includes {{ recursive_var }}"}
|
|
|
|
# This should not cause infinite recursion
|
|
result = pm.render("recursive", {})
|
|
|
|
# The exact behavior depends on the template engine
|
|
assert isinstance(result, str)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extremely_long_template(self):
|
|
"""Test handling of extremely long templates"""
|
|
# Create a very long template
|
|
long_template = "Start\n" + "\n".join([
|
|
f"Line {i}: " + "{{ var_" + str(i) + " }}"
|
|
for i in range(1000)
|
|
]) + "\nEnd"
|
|
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["long"]),
|
|
"template.long": json.dumps({
|
|
"prompt": long_template,
|
|
"response-type": "text"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
# Create corresponding variables
|
|
variables = {f"var_{i}": f"value_{i}" for i in range(1000)}
|
|
|
|
mock_llm = AsyncMock()
|
|
mock_llm.return_value = "Processed long template"
|
|
|
|
result = await pm.invoke("long", variables, mock_llm)
|
|
|
|
assert result == "Processed long template"
|
|
|
|
# Check that template was rendered correctly
|
|
call_args = mock_llm.call_args[1]
|
|
rendered = call_args["prompt"]
|
|
assert "Line 500: value_500" in rendered
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_json_schema_with_additional_properties(self):
|
|
"""Test JSON schema validation with additional properties"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["strict_schema"]),
|
|
"template.strict_schema": json.dumps({
|
|
"prompt": "Generate user",
|
|
"response-type": "json",
|
|
"schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"}
|
|
},
|
|
"required": ["name"],
|
|
"additionalProperties": False
|
|
}
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
mock_llm = AsyncMock()
|
|
# Response with extra property
|
|
mock_llm.return_value = '{"name": "John", "age": 30}'
|
|
|
|
# Should fail validation due to additionalProperties: false
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
await pm.invoke("strict_schema", {}, mock_llm)
|
|
|
|
assert "Schema validation fail" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_llm_timeout_handling(self):
|
|
"""Test handling of LLM timeouts"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["timeout_test"]),
|
|
"template.timeout_test": json.dumps({
|
|
"prompt": "Test prompt",
|
|
"response-type": "text"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
mock_llm = AsyncMock()
|
|
mock_llm.side_effect = asyncio.TimeoutError("LLM request timed out")
|
|
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
await pm.invoke("timeout_test", {}, mock_llm)
|
|
|
|
def test_template_with_filters_and_tests(self):
|
|
"""Test template with Jinja2 filters and tests"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["filters"]),
|
|
"template.filters": json.dumps({
|
|
"prompt": """
|
|
{% if items %}
|
|
Items:
|
|
{% for item in items %}
|
|
- {{ item }}
|
|
{% endfor %}
|
|
{% else %}
|
|
No items
|
|
{% endif %}
|
|
""",
|
|
"response-type": "text"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
# Test with items
|
|
result = pm.render(
|
|
"filters",
|
|
{"items": ["banana", "apple", "cherry"]}
|
|
)
|
|
|
|
assert "Items:" in result
|
|
assert "- banana" in result
|
|
assert "- apple" in result
|
|
assert "- cherry" in result
|
|
|
|
# Test without items
|
|
result = pm.render("filters", {"items": []})
|
|
assert "No items" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_template_modifications(self):
|
|
"""Test thread safety of template operations"""
|
|
pm = PromptManager()
|
|
config = {
|
|
"system": json.dumps("Test"),
|
|
"template-index": json.dumps(["concurrent"]),
|
|
"template.concurrent": json.dumps({
|
|
"prompt": "User: {{ user }}",
|
|
"response-type": "text"
|
|
})
|
|
}
|
|
pm.load_config(config)
|
|
|
|
mock_llm = AsyncMock()
|
|
mock_llm.side_effect = lambda **kwargs: f"Response for {kwargs['prompt'].split()[1]}"
|
|
|
|
# Simulate concurrent invocations with different users
|
|
import asyncio
|
|
tasks = []
|
|
for i in range(10):
|
|
tasks.append(
|
|
pm.invoke("concurrent", {"user": f"User{i}"}, mock_llm)
|
|
)
|
|
|
|
results = await asyncio.gather(*tasks)
|
|
|
|
# Each result should correspond to its user
|
|
for i, result in enumerate(results):
|
|
assert f"User{i}" in result |