Skip to content

Commit cd1a093

Browse files
feat: add polyglot gRPC loader infrastructure (Milestone 1)
- Add amplifier_module.proto — universal tool contract for any language - Add loader_dispatch.py — routes module loading by amplifier.toml transport type - Add loader_grpc.py — GrpcToolBridge wraps gRPC ToolService as Python tool - Generate Python gRPC stubs from proto - Add 20 tests: unit tests + integration tests with mock gRPC server - All 475 existing tests still pass (zero regressions) 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
1 parent f599f84 commit cd1a093

File tree

10 files changed

+924
-0
lines changed

10 files changed

+924
-0
lines changed
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Test that _session_init.py can route through loader_dispatch."""
2+
3+
import asyncio
4+
5+
6+
def test_dispatch_functions_importable():
7+
"""The dispatch functions are importable from the right locations."""
8+
from amplifier_core.loader_dispatch import _detect_transport
9+
from amplifier_core.loader_dispatch import load_module
10+
11+
assert callable(load_module)
12+
assert callable(_detect_transport)
13+
14+
15+
def test_session_init_still_works():
16+
"""_session_init.initialize_session is still importable and async."""
17+
from amplifier_core._session_init import initialize_session
18+
19+
assert asyncio.iscoroutinefunction(initialize_session)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Integration test: mock gRPC ToolService loaded by the Python session.
2+
3+
Starts a real gRPC server in-process, connects via loader_grpc, and
4+
verifies the full round-trip: GetSpec + Execute.
5+
"""
6+
7+
import json
8+
9+
import pytest
10+
import pytest_asyncio
11+
12+
# Skip if grpcio not installed
13+
grpc = pytest.importorskip("grpc")
14+
grpc_aio = pytest.importorskip("grpc.aio")
15+
16+
17+
@pytest_asyncio.fixture
18+
async def mock_tool_server():
19+
"""Start a mock gRPC ToolService server on a random port."""
20+
from amplifier_core._grpc_gen import amplifier_module_pb2
21+
from amplifier_core._grpc_gen import amplifier_module_pb2_grpc
22+
23+
class MockToolServicer(amplifier_module_pb2_grpc.ToolServiceServicer):
24+
async def GetSpec(self, request, context):
25+
return amplifier_module_pb2.ToolSpec(
26+
name="mock-echo",
27+
description="Echoes input back",
28+
parameters_json='{"type": "object", "properties": {"message": {"type": "string"}}}',
29+
)
30+
31+
async def Execute(self, request, context):
32+
input_data = json.loads(request.input.decode("utf-8"))
33+
output = {"echoed": input_data.get("message", "(empty)")}
34+
return amplifier_module_pb2.ToolExecuteResponse(
35+
success=True,
36+
output=json.dumps(output).encode("utf-8"),
37+
content_type="application/json",
38+
)
39+
40+
server = grpc_aio.server()
41+
amplifier_module_pb2_grpc.add_ToolServiceServicer_to_server(
42+
MockToolServicer(), server
43+
)
44+
port = server.add_insecure_port("[::]:0") # Random available port
45+
await server.start()
46+
yield port
47+
await server.stop(grace=0)
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_grpc_tool_bridge_full_roundtrip(mock_tool_server):
52+
"""Full round-trip: connect -> GetSpec -> Execute -> verify result."""
53+
from amplifier_core._grpc_gen import amplifier_module_pb2
54+
from amplifier_core._grpc_gen import amplifier_module_pb2_grpc
55+
from amplifier_core.loader_grpc import GrpcToolBridge
56+
57+
endpoint = f"localhost:{mock_tool_server}"
58+
channel = grpc_aio.insecure_channel(endpoint)
59+
stub = amplifier_module_pb2_grpc.ToolServiceStub(channel)
60+
61+
# Fetch spec
62+
spec_response = await stub.GetSpec(amplifier_module_pb2.Empty())
63+
assert spec_response.name == "mock-echo"
64+
65+
# Create bridge
66+
bridge = GrpcToolBridge(
67+
name=spec_response.name,
68+
description=spec_response.description,
69+
parameters_json=spec_response.parameters_json,
70+
endpoint=endpoint,
71+
channel=channel,
72+
)
73+
bridge._stub = stub
74+
75+
# Execute
76+
result = await bridge.execute(message="hello world")
77+
assert result["success"] is True
78+
assert result["output"]["echoed"] == "hello world"
79+
80+
# Cleanup
81+
await bridge.cleanup()
82+
83+
84+
@pytest.mark.asyncio
85+
async def test_grpc_tool_bridge_error_handling(mock_tool_server):
86+
"""Bridge handles gRPC errors gracefully."""
87+
from amplifier_core._grpc_gen import amplifier_module_pb2_grpc
88+
from amplifier_core.loader_grpc import GrpcToolBridge
89+
90+
# Connect to wrong port (the server is on mock_tool_server port)
91+
channel = grpc_aio.insecure_channel("localhost:1") # No server here
92+
stub = amplifier_module_pb2_grpc.ToolServiceStub(channel)
93+
94+
bridge = GrpcToolBridge(
95+
name="broken",
96+
description="broken",
97+
parameters_json="{}",
98+
endpoint="localhost:1",
99+
channel=channel,
100+
)
101+
bridge._stub = stub
102+
103+
# Should return error result, not raise
104+
result = await bridge.execute(message="hello")
105+
assert result["success"] is False
106+
assert result["error"] is not None
107+
108+
await channel.close()
109+
110+
111+
@pytest.mark.asyncio
112+
async def test_load_grpc_module_full_flow(mock_tool_server):
113+
"""load_grpc_module connects, fetches spec, and returns a mount function."""
114+
from amplifier_core.loader_grpc import load_grpc_module
115+
116+
meta = {
117+
"module": {"name": "mock-echo", "type": "tool", "transport": "grpc"},
118+
"grpc": {"endpoint": f"localhost:{mock_tool_server}"},
119+
}
120+
121+
# Create a minimal mock coordinator
122+
class MockCoordinator:
123+
def __init__(self):
124+
self.mounted_tools = {}
125+
126+
async def mount(self, mount_point, instance, name=None):
127+
self.mounted_tools[name or instance.name] = instance
128+
129+
coord = MockCoordinator()
130+
mount_fn = await load_grpc_module("mock-echo", {}, meta, coord)
131+
132+
# Mount the tool
133+
cleanup = await mount_fn(coord)
134+
135+
assert "mock-echo" in coord.mounted_tools
136+
assert coord.mounted_tools["mock-echo"].name == "mock-echo"
137+
138+
# Cleanup
139+
if cleanup:
140+
await cleanup()
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for the polyglot loader dispatch module."""
2+
3+
import os
4+
import tempfile
5+
6+
7+
def test_dispatch_module_exists():
8+
"""The loader_dispatch module is importable."""
9+
from amplifier_core import loader_dispatch
10+
11+
assert hasattr(loader_dispatch, "load_module")
12+
13+
14+
def test_dispatch_no_toml_falls_back_to_python():
15+
"""Without amplifier.toml, dispatch falls through to Python loader."""
16+
from amplifier_core.loader_dispatch import _detect_transport
17+
18+
with tempfile.TemporaryDirectory() as tmpdir:
19+
transport = _detect_transport(tmpdir)
20+
assert transport == "python"
21+
22+
23+
def test_dispatch_detects_grpc_transport():
24+
"""amplifier.toml with transport=grpc is detected."""
25+
from amplifier_core.loader_dispatch import _detect_transport
26+
27+
with tempfile.TemporaryDirectory() as tmpdir:
28+
toml_path = os.path.join(tmpdir, "amplifier.toml")
29+
with open(toml_path, "w") as f:
30+
f.write('[module]\nname = "test"\ntype = "tool"\ntransport = "grpc"\n')
31+
transport = _detect_transport(tmpdir)
32+
assert transport == "grpc"
33+
34+
35+
def test_dispatch_detects_python_transport():
36+
"""amplifier.toml with transport=python is detected."""
37+
from amplifier_core.loader_dispatch import _detect_transport
38+
39+
with tempfile.TemporaryDirectory() as tmpdir:
40+
toml_path = os.path.join(tmpdir, "amplifier.toml")
41+
with open(toml_path, "w") as f:
42+
f.write('[module]\nname = "test"\ntype = "tool"\ntransport = "python"\n')
43+
transport = _detect_transport(tmpdir)
44+
assert transport == "python"
45+
46+
47+
def test_dispatch_detects_native_transport():
48+
"""amplifier.toml with transport=native is detected."""
49+
from amplifier_core.loader_dispatch import _detect_transport
50+
51+
with tempfile.TemporaryDirectory() as tmpdir:
52+
toml_path = os.path.join(tmpdir, "amplifier.toml")
53+
with open(toml_path, "w") as f:
54+
f.write('[module]\nname = "test"\ntype = "tool"\ntransport = "native"\n')
55+
transport = _detect_transport(tmpdir)
56+
assert transport == "native"
57+
58+
59+
def test_dispatch_defaults_to_python_when_transport_missing():
60+
"""amplifier.toml without transport key defaults to python."""
61+
from amplifier_core.loader_dispatch import _detect_transport
62+
63+
with tempfile.TemporaryDirectory() as tmpdir:
64+
toml_path = os.path.join(tmpdir, "amplifier.toml")
65+
with open(toml_path, "w") as f:
66+
f.write('[module]\nname = "test"\ntype = "tool"\n')
67+
transport = _detect_transport(tmpdir)
68+
assert transport == "python"
69+
70+
71+
def test_dispatch_reads_grpc_endpoint():
72+
"""amplifier.toml grpc section provides endpoint."""
73+
from amplifier_core.loader_dispatch import _read_module_meta
74+
75+
with tempfile.TemporaryDirectory() as tmpdir:
76+
toml_path = os.path.join(tmpdir, "amplifier.toml")
77+
with open(toml_path, "w") as f:
78+
f.write(
79+
'[module]\nname = "my-tool"\ntype = "tool"\ntransport = "grpc"\n\n[grpc]\nendpoint = "localhost:50052"\n'
80+
)
81+
meta = _read_module_meta(tmpdir)
82+
assert meta["module"]["transport"] == "grpc"
83+
assert meta["grpc"]["endpoint"] == "localhost:50052"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Tests for the gRPC module loader."""
2+
3+
import json
4+
5+
6+
def test_grpc_loader_module_exists():
7+
"""The loader_grpc module is importable."""
8+
from amplifier_core import loader_grpc
9+
10+
assert hasattr(loader_grpc, "GrpcToolBridge")
11+
assert hasattr(loader_grpc, "load_grpc_module")
12+
13+
14+
def test_grpc_tool_bridge_init():
15+
"""GrpcToolBridge can be constructed with spec data."""
16+
from amplifier_core.loader_grpc import GrpcToolBridge
17+
18+
bridge = GrpcToolBridge(
19+
name="test-tool",
20+
description="A test tool",
21+
parameters_json='{"type": "object", "properties": {"query": {"type": "string"}}}',
22+
endpoint="localhost:50052",
23+
channel=None, # No real connection in unit tests
24+
)
25+
assert bridge.name == "test-tool"
26+
assert bridge.description == "A test tool"
27+
28+
29+
def test_grpc_tool_bridge_get_spec():
30+
"""GrpcToolBridge.get_spec() returns a dict with name, description, parameters."""
31+
from amplifier_core.loader_grpc import GrpcToolBridge
32+
33+
bridge = GrpcToolBridge(
34+
name="search",
35+
description="Search the web",
36+
parameters_json='{"type": "object", "properties": {"query": {"type": "string"}}}',
37+
endpoint="localhost:50052",
38+
channel=None,
39+
)
40+
spec = bridge.get_spec()
41+
assert spec["name"] == "search"
42+
assert spec["description"] == "Search the web"
43+
assert "properties" in spec["parameters"]
44+
45+
46+
def test_grpc_tool_bridge_serialize_input():
47+
"""GrpcToolBridge._serialize_input encodes dict to JSON bytes."""
48+
from amplifier_core.loader_grpc import GrpcToolBridge
49+
50+
bridge = GrpcToolBridge(
51+
name="test",
52+
description="test",
53+
parameters_json="{}",
54+
endpoint="localhost:50052",
55+
channel=None,
56+
)
57+
input_dict = {"query": "hello world"}
58+
data, content_type = bridge._serialize_input(input_dict)
59+
assert content_type == "application/json"
60+
assert json.loads(data) == {"query": "hello world"}
61+
62+
63+
def test_grpc_tool_bridge_deserialize_output():
64+
"""GrpcToolBridge._deserialize_output decodes JSON bytes to dict."""
65+
from amplifier_core.loader_grpc import GrpcToolBridge
66+
67+
bridge = GrpcToolBridge(
68+
name="test",
69+
description="test",
70+
parameters_json="{}",
71+
endpoint="localhost:50052",
72+
channel=None,
73+
)
74+
output_bytes = json.dumps({"result": "found it"}).encode("utf-8")
75+
result = bridge._deserialize_output(output_bytes, "application/json")
76+
assert result == {"result": "found it"}
77+
78+
79+
def test_grpc_tool_bridge_deserialize_empty_output():
80+
"""Empty output bytes returns empty dict."""
81+
from amplifier_core.loader_grpc import GrpcToolBridge
82+
83+
bridge = GrpcToolBridge(
84+
name="test",
85+
description="test",
86+
parameters_json="{}",
87+
endpoint="localhost:50052",
88+
channel=None,
89+
)
90+
result = bridge._deserialize_output(b"", "application/json")
91+
assert result == {}
92+
93+
94+
def test_load_grpc_module_reads_endpoint():
95+
"""load_grpc_module extracts endpoint from meta dict."""
96+
from amplifier_core.loader_grpc import _extract_endpoint
97+
98+
meta = {
99+
"module": {"name": "my-tool", "type": "tool", "transport": "grpc"},
100+
"grpc": {"endpoint": "localhost:50099"},
101+
}
102+
endpoint = _extract_endpoint(meta, "my-tool")
103+
assert endpoint == "localhost:50099"
104+
105+
106+
def test_load_grpc_module_default_endpoint():
107+
"""When no endpoint specified, uses default localhost:50051."""
108+
from amplifier_core.loader_grpc import _extract_endpoint
109+
110+
meta = {
111+
"module": {"name": "my-tool", "type": "tool", "transport": "grpc"},
112+
}
113+
endpoint = _extract_endpoint(meta, "my-tool")
114+
assert endpoint == "localhost:50051"

proto/amplifier_module.proto

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// amplifier-core/proto/amplifier_module.proto
2+
syntax = "proto3";
3+
package amplifier.module;
4+
5+
// Universal contract for tool modules in any language.
6+
// Implement this service to create an Amplifier tool in Go, TypeScript, C#, etc.
7+
service ToolService {
8+
// Return the tool's name, description, and JSON Schema parameters.
9+
rpc GetSpec(Empty) returns (ToolSpec);
10+
11+
// Execute the tool with JSON (or MessagePack) input.
12+
rpc Execute(ToolExecuteRequest) returns (ToolExecuteResponse);
13+
}
14+
15+
message Empty {}
16+
17+
message ToolSpec {
18+
string name = 1;
19+
string description = 2;
20+
// JSON Schema describing the tool's input parameters, as a JSON string.
21+
string parameters_json = 3;
22+
}
23+
24+
message ToolExecuteRequest {
25+
// Serialized input payload (default: JSON, future: MessagePack).
26+
bytes input = 1;
27+
// MIME type: "application/json" (default if empty) or "application/msgpack".
28+
string content_type = 2;
29+
}
30+
31+
message ToolExecuteResponse {
32+
bool success = 1;
33+
// Serialized output payload.
34+
bytes output = 2;
35+
// MIME type of output (mirrors request content_type).
36+
string content_type = 3;
37+
// Error message if success is false.
38+
string error = 4;
39+
}

0 commit comments

Comments
 (0)