mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-04-25 00:16:23 +02:00
Introduces `workspace` as the isolation boundary for config, flows,
library, and knowledge data. Removes `user` as a schema-level field
throughout the code, API specs, and tests; workspace provides the
same separation more cleanly at the trusted flow.workspace layer
rather than through client-supplied message fields.
Design
------
- IAM tech spec (docs/tech-specs/iam.md) documents current state,
proposed auth/access model, and migration direction.
- Data ownership model (docs/tech-specs/data-ownership-model.md)
captures the workspace/collection/flow hierarchy.
Schema + messaging
------------------
- Drop `user` field from AgentRequest/Step, GraphRagQuery,
DocumentRagQuery, Triples/Graph/Document/Row EmbeddingsRequest,
Sparql/Rows/Structured QueryRequest, ToolServiceRequest.
- Keep collection/workspace routing via flow.workspace at the
service layer.
- Translators updated to not serialise/deserialise user.
API specs
---------
- OpenAPI schemas and path examples cleaned of user fields.
- Websocket async-api messages updated.
- Removed the unused parameters/User.yaml.
Services + base
---------------
- Librarian, collection manager, knowledge, config: all operations
scoped by workspace. Config client API takes workspace as first
positional arg.
- `flow.workspace` set at flow start time by the infrastructure;
no longer pass-through from clients.
- Tool service drops user-personalisation passthrough.
CLI + SDK
---------
- tg-init-workspace and workspace-aware import/export.
- All tg-* commands drop user args; accept --workspace.
- Python API/SDK (flow, socket_client, async_*, explainability,
library) drop user kwargs from every method signature.
MCP server
----------
- All tool endpoints drop user parameters; socket_manager no longer
keyed per user.
Flow service
------------
- Closure-based topic cleanup on flow stop: only delete topics
whose blueprint template was parameterised AND no remaining
live flow (across all workspaces) still resolves to that topic.
Three scopes fall out naturally from template analysis:
* {id} -> per-flow, deleted on stop
* {blueprint} -> per-blueprint, kept while any flow of the
same blueprint exists
* {workspace} -> per-workspace, kept while any flow in the
workspace exists
* literal -> global, never deleted (e.g. tg.request.librarian)
Fixes a bug where stopping a flow silently destroyed the global
librarian exchange, wedging all library operations until manual
restart.
RabbitMQ backend
----------------
- heartbeat=60, blocked_connection_timeout=300. Catches silently
dead connections (broker restart, orphaned channels, network
partitions) within ~2 heartbeat windows, so the consumer
reconnects and re-binds its queue rather than sitting forever
on a zombie connection.
Tests
-----
- Full test refresh: unit, integration, contract, provenance.
- Dropped user-field assertions and constructor kwargs across
~100 test files.
- Renamed user-collection isolation tests to workspace-collection.
545 lines
18 KiB
Python
545 lines
18 KiB
Python
"""
|
|
Tests for tool service lifecycle, invoke contract, streaming responses,
|
|
and error propagation.
|
|
|
|
Tests the actual DynamicToolService, ToolService, and ToolServiceClient
|
|
classes rather than plain dicts.
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from trustgraph.schema import (
|
|
ToolServiceRequest, ToolServiceResponse, Error,
|
|
ToolRequest, ToolResponse,
|
|
)
|
|
from trustgraph.exceptions import TooManyRequests
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DynamicToolService tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDynamicToolServiceInvokeContract:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_invoke_raises_not_implemented(self):
|
|
"""Base class invoke() should raise NotImplementedError."""
|
|
from trustgraph.base.dynamic_tool_service import DynamicToolService
|
|
|
|
svc = DynamicToolService.__new__(DynamicToolService)
|
|
|
|
with pytest.raises(NotImplementedError):
|
|
await svc.invoke({}, {})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_request_calls_invoke_with_parsed_args(self):
|
|
"""on_request should JSON-parse config/arguments and pass to invoke."""
|
|
from trustgraph.base.dynamic_tool_service import DynamicToolService
|
|
|
|
svc = DynamicToolService.__new__(DynamicToolService)
|
|
svc.id = "test-svc"
|
|
svc.producer = AsyncMock()
|
|
|
|
calls = []
|
|
|
|
async def tracking_invoke(config, arguments):
|
|
calls.append({"config": config, "arguments": arguments})
|
|
return "ok"
|
|
|
|
svc.invoke = tracking_invoke
|
|
|
|
# Ensure the class-level metric exists
|
|
if not hasattr(DynamicToolService, "tool_service_metric"):
|
|
DynamicToolService.tool_service_metric = MagicMock()
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolServiceRequest(
|
|
config='{"style": "pun"}',
|
|
arguments='{"topic": "cats"}',
|
|
)
|
|
msg.properties.return_value = {"id": "req-1"}
|
|
|
|
await svc.on_request(msg, MagicMock(), None)
|
|
|
|
assert len(calls) == 1
|
|
assert calls[0]["config"] == {"style": "pun"}
|
|
assert calls[0]["arguments"] == {"topic": "cats"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_request_string_response_sent_directly(self):
|
|
"""String return from invoke → response field is the string."""
|
|
from trustgraph.base.dynamic_tool_service import DynamicToolService
|
|
|
|
svc = DynamicToolService.__new__(DynamicToolService)
|
|
svc.id = "test-svc"
|
|
svc.producer = AsyncMock()
|
|
|
|
async def string_invoke(config, arguments):
|
|
return "hello world"
|
|
|
|
svc.invoke = string_invoke
|
|
|
|
if not hasattr(DynamicToolService, "tool_service_metric"):
|
|
DynamicToolService.tool_service_metric = MagicMock()
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolServiceRequest(config="{}", arguments="{}")
|
|
msg.properties.return_value = {"id": "r1"}
|
|
|
|
await svc.on_request(msg, MagicMock(), None)
|
|
|
|
sent = svc.producer.send.call_args[0][0]
|
|
assert isinstance(sent, ToolServiceResponse)
|
|
assert sent.response == "hello world"
|
|
assert sent.end_of_stream is True
|
|
assert sent.error is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_request_dict_response_json_encoded(self):
|
|
"""Dict return from invoke → response field is JSON-encoded."""
|
|
from trustgraph.base.dynamic_tool_service import DynamicToolService
|
|
|
|
svc = DynamicToolService.__new__(DynamicToolService)
|
|
svc.id = "test-svc"
|
|
svc.producer = AsyncMock()
|
|
|
|
async def dict_invoke(config, arguments):
|
|
return {"result": 42}
|
|
|
|
svc.invoke = dict_invoke
|
|
|
|
if not hasattr(DynamicToolService, "tool_service_metric"):
|
|
DynamicToolService.tool_service_metric = MagicMock()
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolServiceRequest(config="{}", arguments="{}")
|
|
msg.properties.return_value = {"id": "r2"}
|
|
|
|
await svc.on_request(msg, MagicMock(), None)
|
|
|
|
sent = svc.producer.send.call_args[0][0]
|
|
assert json.loads(sent.response) == {"result": 42}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_request_error_sends_error_response(self):
|
|
"""Exception in invoke → error response sent."""
|
|
from trustgraph.base.dynamic_tool_service import DynamicToolService
|
|
|
|
svc = DynamicToolService.__new__(DynamicToolService)
|
|
svc.id = "test-svc"
|
|
svc.producer = AsyncMock()
|
|
|
|
async def failing_invoke(config, arguments):
|
|
raise ValueError("bad input")
|
|
|
|
svc.invoke = failing_invoke
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolServiceRequest(config="{}", arguments="{}")
|
|
msg.properties.return_value = {"id": "r3"}
|
|
|
|
await svc.on_request(msg, MagicMock(), None)
|
|
|
|
sent = svc.producer.send.call_args[0][0]
|
|
assert sent.error is not None
|
|
assert sent.error.type == "tool-service-error"
|
|
assert "bad input" in sent.error.message
|
|
assert sent.response == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_request_too_many_requests_propagates(self):
|
|
"""TooManyRequests should propagate (not caught as error response)."""
|
|
from trustgraph.base.dynamic_tool_service import DynamicToolService
|
|
|
|
svc = DynamicToolService.__new__(DynamicToolService)
|
|
svc.id = "test-svc"
|
|
svc.producer = AsyncMock()
|
|
|
|
async def rate_limited_invoke(config, arguments):
|
|
raise TooManyRequests("rate limited")
|
|
|
|
svc.invoke = rate_limited_invoke
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolServiceRequest(config="{}", arguments="{}")
|
|
msg.properties.return_value = {"id": "r4"}
|
|
|
|
with pytest.raises(TooManyRequests):
|
|
await svc.on_request(msg, MagicMock(), None)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_on_request_preserves_message_id(self):
|
|
"""Response should include the original message id in properties."""
|
|
from trustgraph.base.dynamic_tool_service import DynamicToolService
|
|
|
|
svc = DynamicToolService.__new__(DynamicToolService)
|
|
svc.id = "test-svc"
|
|
svc.producer = AsyncMock()
|
|
|
|
async def ok_invoke(config, arguments):
|
|
return "ok"
|
|
|
|
svc.invoke = ok_invoke
|
|
|
|
if not hasattr(DynamicToolService, "tool_service_metric"):
|
|
DynamicToolService.tool_service_metric = MagicMock()
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolServiceRequest(config="{}", arguments="{}")
|
|
msg.properties.return_value = {"id": "unique-42"}
|
|
|
|
await svc.on_request(msg, MagicMock(), None)
|
|
|
|
props = svc.producer.send.call_args[1]["properties"]
|
|
assert props["id"] == "unique-42"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ToolService (flow-based) tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestToolServiceOnRequest:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_string_response_sent_as_text(self):
|
|
"""String return from invoke_tool → ToolResponse.text is set."""
|
|
from trustgraph.base.tool_service import ToolService
|
|
|
|
svc = ToolService.__new__(ToolService)
|
|
svc.id = "test-tool"
|
|
|
|
async def mock_invoke(workspace, name, params):
|
|
return "tool result"
|
|
|
|
svc.invoke_tool = mock_invoke
|
|
|
|
if not hasattr(ToolService, "tool_invocation_metric"):
|
|
ToolService.tool_invocation_metric = MagicMock()
|
|
|
|
mock_response_pub = AsyncMock()
|
|
flow = MagicMock()
|
|
flow.name = "test-flow"
|
|
|
|
def flow_callable(name):
|
|
if name == "response":
|
|
return mock_response_pub
|
|
return MagicMock()
|
|
|
|
flow_callable.producer = {"response": mock_response_pub}
|
|
flow_callable.name = "test-flow"
|
|
flow_callable.workspace = "default"
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolRequest(name="my-tool", parameters='{"key": "val"}')
|
|
msg.properties.return_value = {"id": "t1"}
|
|
|
|
await svc.on_request(msg, MagicMock(), flow_callable)
|
|
|
|
sent = mock_response_pub.send.call_args[0][0]
|
|
assert isinstance(sent, ToolResponse)
|
|
assert sent.text == "tool result"
|
|
assert sent.object is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dict_response_sent_as_json_object(self):
|
|
"""Dict return from invoke_tool → ToolResponse.object is JSON."""
|
|
from trustgraph.base.tool_service import ToolService
|
|
|
|
svc = ToolService.__new__(ToolService)
|
|
svc.id = "test-tool"
|
|
|
|
async def mock_invoke(workspace, name, params):
|
|
return {"data": [1, 2, 3]}
|
|
|
|
svc.invoke_tool = mock_invoke
|
|
|
|
if not hasattr(ToolService, "tool_invocation_metric"):
|
|
ToolService.tool_invocation_metric = MagicMock()
|
|
|
|
mock_response_pub = AsyncMock()
|
|
flow = MagicMock()
|
|
|
|
def flow_callable(name):
|
|
if name == "response":
|
|
return mock_response_pub
|
|
return MagicMock()
|
|
|
|
flow_callable.producer = {"response": mock_response_pub}
|
|
flow_callable.name = "test-flow"
|
|
flow_callable.workspace = "default"
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolRequest(name="my-tool", parameters="{}")
|
|
msg.properties.return_value = {"id": "t2"}
|
|
|
|
await svc.on_request(msg, MagicMock(), flow_callable)
|
|
|
|
sent = mock_response_pub.send.call_args[0][0]
|
|
assert sent.text is None
|
|
assert json.loads(sent.object) == {"data": [1, 2, 3]}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_sends_error_response(self):
|
|
"""Exception in invoke_tool → error response via flow producer."""
|
|
from trustgraph.base.tool_service import ToolService
|
|
|
|
svc = ToolService.__new__(ToolService)
|
|
svc.id = "test-tool"
|
|
|
|
async def failing_invoke(workspace, name, params):
|
|
raise RuntimeError("tool broke")
|
|
|
|
svc.invoke_tool = failing_invoke
|
|
|
|
mock_response_pub = AsyncMock()
|
|
flow = MagicMock()
|
|
|
|
def flow_callable(name):
|
|
return MagicMock()
|
|
|
|
flow_callable.producer = {"response": mock_response_pub}
|
|
flow_callable.name = "test-flow"
|
|
flow_callable.workspace = "default"
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolRequest(name="my-tool", parameters="{}")
|
|
msg.properties.return_value = {"id": "t3"}
|
|
|
|
await svc.on_request(msg, MagicMock(), flow_callable)
|
|
|
|
sent = mock_response_pub.send.call_args[0][0]
|
|
assert sent.error is not None
|
|
assert sent.error.type == "tool-error"
|
|
assert "tool broke" in sent.error.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_too_many_requests_propagates(self):
|
|
"""TooManyRequests should propagate from ToolService.on_request."""
|
|
from trustgraph.base.tool_service import ToolService
|
|
|
|
svc = ToolService.__new__(ToolService)
|
|
svc.id = "test-tool"
|
|
|
|
async def rate_limited(workspace, name, params):
|
|
raise TooManyRequests("slow down")
|
|
|
|
svc.invoke_tool = rate_limited
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolRequest(name="my-tool", parameters="{}")
|
|
msg.properties.return_value = {"id": "t4"}
|
|
|
|
flow = MagicMock()
|
|
flow.producer = {"response": AsyncMock()}
|
|
flow.name = "test-flow"
|
|
flow.workspace = "default"
|
|
|
|
with pytest.raises(TooManyRequests):
|
|
await svc.on_request(msg, MagicMock(), flow)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parameters_json_parsed(self):
|
|
"""Parameters should be JSON-parsed before passing to invoke_tool."""
|
|
from trustgraph.base.tool_service import ToolService
|
|
|
|
svc = ToolService.__new__(ToolService)
|
|
svc.id = "test-tool"
|
|
|
|
received = {}
|
|
|
|
async def capture_invoke(workspace, name, params):
|
|
received["workspace"] = workspace
|
|
received["name"] = name
|
|
received["params"] = params
|
|
return "ok"
|
|
|
|
svc.invoke_tool = capture_invoke
|
|
|
|
if not hasattr(ToolService, "tool_invocation_metric"):
|
|
ToolService.tool_invocation_metric = MagicMock()
|
|
|
|
mock_pub = AsyncMock()
|
|
flow = lambda name: mock_pub
|
|
flow.producer = {"response": mock_pub}
|
|
flow.name = "f"
|
|
flow.workspace = "default"
|
|
|
|
msg = MagicMock()
|
|
msg.value.return_value = ToolRequest(
|
|
name="search",
|
|
parameters='{"query": "test", "limit": 10}',
|
|
)
|
|
msg.properties.return_value = {"id": "t5"}
|
|
|
|
await svc.on_request(msg, MagicMock(), flow)
|
|
|
|
assert received["name"] == "search"
|
|
assert received["params"] == {"query": "test", "limit": 10}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ToolServiceClient tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestToolServiceClientCall:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_sends_request_and_returns_response(self):
|
|
"""call() should send ToolServiceRequest and return response string."""
|
|
from trustgraph.base.tool_service_client import ToolServiceClient
|
|
|
|
client = ToolServiceClient.__new__(ToolServiceClient)
|
|
client.request = AsyncMock(return_value=ToolServiceResponse(
|
|
error=None, response="joke result", end_of_stream=True,
|
|
))
|
|
|
|
result = await client.call(
|
|
config={"style": "pun"},
|
|
arguments={"topic": "cats"},
|
|
)
|
|
|
|
assert result == "joke result"
|
|
|
|
req = client.request.call_args[0][0]
|
|
assert isinstance(req, ToolServiceRequest)
|
|
assert json.loads(req.config) == {"style": "pun"}
|
|
assert json.loads(req.arguments) == {"topic": "cats"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_raises_on_error(self):
|
|
"""call() should raise RuntimeError when response has error."""
|
|
from trustgraph.base.tool_service_client import ToolServiceClient
|
|
|
|
client = ToolServiceClient.__new__(ToolServiceClient)
|
|
client.request = AsyncMock(return_value=ToolServiceResponse(
|
|
error=Error(type="tool-service-error", message="service down"),
|
|
response="",
|
|
))
|
|
|
|
with pytest.raises(RuntimeError, match="service down"):
|
|
await client.call(config={}, arguments={})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_empty_config_sends_empty_json(self):
|
|
"""Empty config/arguments should be sent as '{}'."""
|
|
from trustgraph.base.tool_service_client import ToolServiceClient
|
|
|
|
client = ToolServiceClient.__new__(ToolServiceClient)
|
|
client.request = AsyncMock(return_value=ToolServiceResponse(
|
|
error=None, response="ok",
|
|
))
|
|
|
|
await client.call(config=None, arguments=None)
|
|
|
|
req = client.request.call_args[0][0]
|
|
assert req.config == "{}"
|
|
assert req.arguments == "{}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_passes_timeout(self):
|
|
"""call() should forward timeout to underlying request."""
|
|
from trustgraph.base.tool_service_client import ToolServiceClient
|
|
|
|
client = ToolServiceClient.__new__(ToolServiceClient)
|
|
client.request = AsyncMock(return_value=ToolServiceResponse(
|
|
error=None, response="ok",
|
|
))
|
|
|
|
await client.call(config={}, arguments={}, timeout=30)
|
|
|
|
_, kwargs = client.request.call_args
|
|
assert kwargs["timeout"] == 30
|
|
|
|
|
|
class TestToolServiceClientStreaming:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_streaming_collects_chunks(self):
|
|
"""call_streaming should accumulate chunks and return full result."""
|
|
from trustgraph.base.tool_service_client import ToolServiceClient
|
|
|
|
client = ToolServiceClient.__new__(ToolServiceClient)
|
|
|
|
# Simulate streaming: request() calls recipient with each chunk
|
|
chunks = [
|
|
ToolServiceResponse(error=None, response="chunk1", end_of_stream=False),
|
|
ToolServiceResponse(error=None, response="chunk2", end_of_stream=True),
|
|
]
|
|
|
|
async def mock_request(req, timeout=600, recipient=None):
|
|
for chunk in chunks:
|
|
done = await recipient(chunk)
|
|
if done:
|
|
break
|
|
|
|
client.request = mock_request
|
|
|
|
received = []
|
|
|
|
async def callback(text):
|
|
received.append(text)
|
|
|
|
result = await client.call_streaming(
|
|
config={}, arguments={}, callback=callback,
|
|
)
|
|
|
|
assert result == "chunk1chunk2"
|
|
assert received == ["chunk1", "chunk2"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_streaming_raises_on_error(self):
|
|
"""call_streaming should raise RuntimeError on error chunk."""
|
|
from trustgraph.base.tool_service_client import ToolServiceClient
|
|
|
|
client = ToolServiceClient.__new__(ToolServiceClient)
|
|
|
|
async def mock_request(req, timeout=600, recipient=None):
|
|
error_resp = ToolServiceResponse(
|
|
error=Error(type="tool-service-error", message="stream failed"),
|
|
response="",
|
|
end_of_stream=True,
|
|
)
|
|
await recipient(error_resp)
|
|
|
|
client.request = mock_request
|
|
|
|
with pytest.raises(RuntimeError, match="stream failed"):
|
|
await client.call_streaming(
|
|
config={}, arguments={},
|
|
callback=AsyncMock(),
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call_streaming_skips_empty_response(self):
|
|
"""Empty response chunks should not be added to result."""
|
|
from trustgraph.base.tool_service_client import ToolServiceClient
|
|
|
|
client = ToolServiceClient.__new__(ToolServiceClient)
|
|
|
|
chunks = [
|
|
ToolServiceResponse(error=None, response="", end_of_stream=False),
|
|
ToolServiceResponse(error=None, response="data", end_of_stream=True),
|
|
]
|
|
|
|
async def mock_request(req, timeout=600, recipient=None):
|
|
for chunk in chunks:
|
|
done = await recipient(chunk)
|
|
if done:
|
|
break
|
|
|
|
client.request = mock_request
|
|
|
|
received = []
|
|
|
|
async def callback(text):
|
|
received.append(text)
|
|
|
|
result = await client.call_streaming(
|
|
config={}, arguments={}, callback=callback,
|
|
)
|
|
|
|
# Empty response is falsy, so callback shouldn't be called for it
|
|
assert result == "data"
|
|
assert received == ["data"]
|