trace2mcp / tests /test_runtime.py
War Enterprise
Release Trace2MCP 0.2.0
561f5a2 verified
Raw History Blame Contribute Delete
7.45 kB
import asyncio
import pytest
from trace2mcp.compiler import compile_trace
from trace2mcp.models import (
PolicyViolationError,
Reference,
Trace,
TraceEvent,
Workflow,
WorkflowNode,
)
from trace2mcp.policy import ToolContract
from trace2mcp.runtime import (
ToolNotFoundError,
ToolRegistry,
WorkflowExecutionError,
execute_workflow,
)
def read_event(event_id, tool, arguments, result):
return TraceEvent(event_id, tool, arguments, result, side_effect="read")
def test_runtime_resolves_refs_and_returns_sink_output():
workflow = compile_trace(
Trace(
inputs={"value": 4},
events=[
read_event("double", "double", {"number": 4}, {"number": 8}),
read_event("format", "format", {"number": 8}, "answer=8"),
],
)
)
registry = ToolRegistry(
{
"double": lambda number: {"number": number * 2},
"format": lambda number: f"answer={number}",
}
)
result = asyncio.run(execute_workflow(workflow, registry, inputs={"value": 5}))
assert result.outputs == {"double": {"number": 10}, "format": "answer=10"}
assert result.output == "answer=10"
assert result.metrics.nodes_executed == 2
assert result.metrics.layer_count == 2
assert all(metric.status == "success" for metric in result.metrics.node_metrics)
def test_independent_nodes_execute_concurrently_in_one_layer():
workflow = Workflow(
name="parallel",
inputs={},
nodes=[
WorkflowNode("a", "wait", {"value": "a"}),
WorkflowNode("b", "wait", {"value": "b"}),
],
)
arrived = 0
both_arrived = asyncio.Event()
async def wait(value):
nonlocal arrived
arrived += 1
if arrived == 2:
both_arrived.set()
await asyncio.wait_for(both_arrived.wait(), timeout=0.5)
return value
result = asyncio.run(execute_workflow(workflow, {"wait": wait}))
assert result.output == {"a": "a", "b": "b"}
assert result.metrics.max_parallelism == 2
assert result.metrics.layer_count == 1
def test_registry_decorator_and_missing_tool_errors_are_clear():
registry = ToolRegistry()
@registry.register("echo")
async def echo(value):
return value
assert registry.names() == ("echo",)
workflow = Workflow("missing", {}, [WorkflowNode("a", "absent", {})])
with pytest.raises(ToolNotFoundError):
asyncio.run(execute_workflow(workflow, registry))
def test_unresolvable_reference_is_wrapped_with_node_context():
workflow = Workflow(
"bad-ref",
{},
[
WorkflowNode(
"a",
"echo",
{"value": Reference("input", ("missing",))},
)
],
)
with pytest.raises(PolicyViolationError, match="UNRESOLVED_INPUT_REFERENCE"):
asyncio.run(execute_workflow(workflow, {"echo": lambda value: value}))
def test_runtime_denies_unsafe_workflow_before_calling_handler():
calls = []
def destructive():
calls.append("called")
workflow = Workflow(
"unsafe",
{},
[WorkflowNode("delete", "delete", {}, side_effect="delete")],
safety="unsafe",
)
registry = ToolRegistry(
{"delete": destructive},
contracts={"delete": ToolContract("delete", side_effect="delete")},
)
with pytest.raises(PolicyViolationError, match="denied"):
asyncio.run(execute_workflow(workflow, registry))
assert calls == []
def test_registered_contract_overrides_untrusted_trace_label():
calls = []
workflow = Workflow(
"spoofed",
{},
[WorkflowNode("send", "gmail.send", {}, side_effect="read")],
)
registry = ToolRegistry(
{"gmail.send": lambda: calls.append("sent")},
contracts={
"gmail.send": ToolContract(
"gmail.send",
side_effect="external_side_effect",
idempotency="non_idempotent",
)
},
)
with pytest.raises(PolicyViolationError, match="approval"):
asyncio.run(execute_workflow(workflow, registry))
assert calls == []
result = asyncio.run(execute_workflow(workflow, registry, approvals={"send"}))
assert calls == ["sent"]
assert result.output is None
def test_runtime_respects_bounded_concurrency():
active = 0
peak = 0
async def work(value):
nonlocal active, peak
active += 1
peak = max(peak, active)
await asyncio.sleep(0.01)
active -= 1
return value
workflow = Workflow(
"bounded",
{},
[WorkflowNode(f"n{index}", "work", {"value": index}) for index in range(20)],
)
result = asyncio.run(execute_workflow(workflow, {"work": work}, max_concurrency=3))
assert result.metrics.nodes_executed == 20
assert peak == 3
def test_retry_requires_reviewed_idempotent_contract():
attempts = 0
async def flaky():
nonlocal attempts
attempts += 1
if attempts < 3:
raise RuntimeError("temporary")
return "ok"
workflow = Workflow(
"retry",
{},
[WorkflowNode("flaky", "flaky", {}, idempotency="idempotent", max_attempts=3)],
)
registry = ToolRegistry(
{"flaky": flaky},
contracts={"flaky": ToolContract("flaky", side_effect="read", idempotency="idempotent")},
)
assert asyncio.run(execute_workflow(workflow, registry)).output == "ok"
assert attempts == 3
unsafe = Workflow(
"unsafe-retry",
{},
[WorkflowNode("send", "send", {}, max_attempts=2)],
)
unsafe_registry = ToolRegistry(
{"send": lambda: None},
contracts={
"send": ToolContract(
"send",
side_effect="external_side_effect",
idempotency="non_idempotent",
)
},
)
with pytest.raises(PolicyViolationError, match="requests retry"):
asyncio.run(execute_workflow(unsafe, unsafe_registry, approvals={"send"}))
def test_async_siblings_are_cancelled_and_awaited_after_failure():
completed = False
async def fail():
await asyncio.sleep(0.01)
raise RuntimeError("boom")
async def slow():
nonlocal completed
await asyncio.sleep(0.2)
completed = True
workflow = Workflow(
"cancel",
{},
[WorkflowNode("fail", "fail", {}), WorkflowNode("slow", "slow", {})],
)
async def scenario():
with pytest.raises(WorkflowExecutionError):
await execute_workflow(workflow, {"fail": fail, "slow": slow})
await asyncio.sleep(0.25)
asyncio.run(scenario())
assert completed is False
def test_step_and_global_timeouts_stop_async_work():
async def slow():
await asyncio.sleep(1)
step_timeout = Workflow(
"step-timeout",
{},
[WorkflowNode("slow", "slow", {}, timeout_seconds=0.01)],
)
with pytest.raises(WorkflowExecutionError):
asyncio.run(execute_workflow(step_timeout, {"slow": slow}))
global_timeout = Workflow(
"global-timeout",
{},
[WorkflowNode("slow", "slow", {})],
)
with pytest.raises(TimeoutError):
asyncio.run(execute_workflow(global_timeout, {"slow": slow}, timeout_seconds=0.01))