steerable-plugin-sdk 0.6.29__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: steerable-plugin-sdk
3
+ Version: 0.6.29
4
+ Summary: Pure-Python authoring and RPC SDK for Steerable tool plugins
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: steerable-agent-harness<1.0.0,>=0.1.0
8
+ Requires-Dist: steerable-agent-protocol<1.0.0,>=0.1.0
9
+
10
+ # Steerable Plugin SDK
11
+
12
+ Pure-Python plugin authoring surface for Steerable tools. This package does
13
+ not depend on `steerable-agent-runtime` or its native CoreLoop wheel.
14
+
15
+ ```python
16
+ from steerable_plugin_sdk import tool
17
+
18
+
19
+ def register(router):
20
+ @tool(router=router, description="Greet by name")
21
+ async def greet(name: str) -> str:
22
+ return f"hello {name}"
23
+ ```
24
+
25
+ Packaged plugins publish `register` through the `steerable.tools` entry-point
26
+ group. `LocalPluginRouter` is the plugin-host registry; `PluginHostRpcClient`
27
+ and `RpcRouterProxy` bridge its descriptors and invocations to a sidecar
28
+ router.
@@ -0,0 +1,19 @@
1
+ # Steerable Plugin SDK
2
+
3
+ Pure-Python plugin authoring surface for Steerable tools. This package does
4
+ not depend on `steerable-agent-runtime` or its native CoreLoop wheel.
5
+
6
+ ```python
7
+ from steerable_plugin_sdk import tool
8
+
9
+
10
+ def register(router):
11
+ @tool(router=router, description="Greet by name")
12
+ async def greet(name: str) -> str:
13
+ return f"hello {name}"
14
+ ```
15
+
16
+ Packaged plugins publish `register` through the `steerable.tools` entry-point
17
+ group. `LocalPluginRouter` is the plugin-host registry; `PluginHostRpcClient`
18
+ and `RpcRouterProxy` bridge its descriptors and invocations to a sidecar
19
+ router.
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "steerable-plugin-sdk"
3
+ version = "0.6.29"
4
+ description = "Pure-Python authoring and RPC SDK for Steerable tool plugins"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "steerable-agent-harness>=0.1.0,<1.0.0",
9
+ "steerable-agent-protocol>=0.1.0,<1.0.0",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["setuptools>=68", "wheel"]
14
+ build-backend = "setuptools.build_meta"
15
+
16
+ [tool.setuptools]
17
+ package-dir = {"" = "src"}
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.uv.sources]
23
+ steerable-agent-harness = { workspace = true }
24
+ steerable-agent-protocol = { workspace = true }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,27 @@
1
+ """Pure-Python Steerable plugin authoring and plugin-host RPC SDK."""
2
+
3
+ from .rpc import PLUGIN_HOST_PROTOCOL_VERSION, PluginHostRpcClient, RpcRouterProxy
4
+ from .schema import derive_schema
5
+ from .tools import (
6
+ STEERABLE_TOOLS_ENTRY_POINT_GROUP,
7
+ LocalPluginRouter,
8
+ RegisteredTool,
9
+ ToolDescriptor,
10
+ ToolExposure,
11
+ ToolRegistrationError,
12
+ tool,
13
+ )
14
+
15
+ __all__ = [
16
+ "PLUGIN_HOST_PROTOCOL_VERSION",
17
+ "STEERABLE_TOOLS_ENTRY_POINT_GROUP",
18
+ "LocalPluginRouter",
19
+ "PluginHostRpcClient",
20
+ "RegisteredTool",
21
+ "RpcRouterProxy",
22
+ "ToolDescriptor",
23
+ "ToolExposure",
24
+ "ToolRegistrationError",
25
+ "derive_schema",
26
+ "tool",
27
+ ]
@@ -0,0 +1,124 @@
1
+ """Plugin-host JSON-RPC client and sidecar router projection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any, Protocol
7
+
8
+ from steerable_agent_protocol.generated import ToolResult
9
+
10
+ from .tools import ToolDescriptor
11
+
12
+ PLUGIN_HOST_PROTOCOL_VERSION = "0.1.0"
13
+
14
+
15
+ class RpcTransport(Protocol):
16
+ """Request transport supplied by the plugin-host process owner."""
17
+
18
+ async def request(self, method: str, params: dict[str, Any]) -> Any:
19
+ """Issue one JSON-RPC request and return its result."""
20
+
21
+
22
+ class RemoteRegistrationRouter(Protocol):
23
+ """Runtime router surface needed to project remote plugin tools."""
24
+
25
+ def register_remote(
26
+ self,
27
+ name: str,
28
+ invoker: Callable[[str, dict[str, Any]], Awaitable[Any]],
29
+ **kwargs: Any,
30
+ ) -> Any:
31
+ """Register one remotely invoked tool."""
32
+
33
+ def unregister(self, name: str) -> None:
34
+ """Remove one projected tool."""
35
+
36
+
37
+ class PluginHostRpcClient:
38
+ """Typed client for the frozen plugin-host RPC methods."""
39
+
40
+ def __init__(self, transport: RpcTransport) -> None:
41
+ self._transport = transport
42
+
43
+ async def ping(self) -> dict[str, Any]:
44
+ """Negotiate the plugin-host protocol version."""
45
+ return await self._transport.request(
46
+ "plugin.host.ping",
47
+ {"protocolVersion": PLUGIN_HOST_PROTOCOL_VERSION},
48
+ )
49
+
50
+ async def describe_tools(self) -> list[ToolDescriptor]:
51
+ """Read every tool currently enabled in the plugin host."""
52
+ result = await self._transport.request("plugin.tools.describe", {})
53
+ return [
54
+ ToolDescriptor.from_wire(value)
55
+ for value in result.get("tools", [])
56
+ ]
57
+
58
+ async def invoke(
59
+ self,
60
+ name: str,
61
+ arguments: dict[str, Any],
62
+ context: dict[str, Any] | None = None,
63
+ ) -> ToolResult:
64
+ """Invoke one plugin tool."""
65
+ result = await self._transport.request(
66
+ "plugin.tool.invoke",
67
+ {"name": name, "arguments": arguments, "context": context or {}},
68
+ )
69
+ return ToolResult(**result)
70
+
71
+ async def list_plugins(self) -> dict[str, Any]:
72
+ """Return plugin lifecycle records."""
73
+ return await self._transport.request("plugin.list", {})
74
+
75
+ async def enable(self, name: str) -> dict[str, Any]:
76
+ """Enable one plugin."""
77
+ return await self._lifecycle("plugin.enable", name)
78
+
79
+ async def disable(self, name: str) -> dict[str, Any]:
80
+ """Disable one plugin."""
81
+ return await self._lifecycle("plugin.disable", name)
82
+
83
+ async def reload(self, name: str) -> dict[str, Any]:
84
+ """Reload one plugin."""
85
+ return await self._lifecycle("plugin.reload", name)
86
+
87
+ async def _lifecycle(self, method: str, name: str) -> dict[str, Any]:
88
+ return await self._transport.request(method, {"name": name})
89
+
90
+
91
+ class RpcRouterProxy:
92
+ """Project plugin-host descriptors onto a runtime remote-tool router."""
93
+
94
+ def __init__(
95
+ self,
96
+ client: PluginHostRpcClient,
97
+ router: RemoteRegistrationRouter,
98
+ ) -> None:
99
+ self._client = client
100
+ self._router = router
101
+ self._names: list[str] = []
102
+
103
+ async def sync_tools(self) -> list[str]:
104
+ """Replace projected registrations with the host's current catalog."""
105
+ for name in self._names:
106
+ self._router.unregister(name)
107
+ self._names = []
108
+ for descriptor in await self._client.describe_tools():
109
+ self._router.register_remote(
110
+ descriptor.name,
111
+ self._client.invoke,
112
+ mode=descriptor.mode,
113
+ description=descriptor.description,
114
+ schema=descriptor.schema,
115
+ require_consent=descriptor.require_consent,
116
+ concurrency_safe=descriptor.concurrency_safe,
117
+ exposure=descriptor.exposure,
118
+ metadata={
119
+ "plugin": descriptor.plugin,
120
+ "pluginHost": True,
121
+ },
122
+ )
123
+ self._names.append(descriptor.name)
124
+ return list(self._names)
@@ -0,0 +1,86 @@
1
+ """Conservative JSON Schema derivation for plugin tool handlers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import types
7
+ import typing
8
+ from collections.abc import Callable
9
+ from typing import Any, get_args, get_origin
10
+
11
+ _SCALAR_TYPES: dict[type, str] = {
12
+ str: "string",
13
+ int: "integer",
14
+ float: "number",
15
+ bool: "boolean",
16
+ }
17
+
18
+
19
+ def _annotation_to_schema(annotation: Any) -> dict[str, Any]:
20
+ if annotation is inspect.Parameter.empty or annotation is Any:
21
+ return {}
22
+ origin = get_origin(annotation)
23
+ if origin is typing.Union or origin is types.UnionType:
24
+ members = list(get_args(annotation))
25
+ nullable = type(None) in members
26
+ non_null = [member for member in members if member is not type(None)]
27
+ if len(non_null) == 1:
28
+ inner = _annotation_to_schema(non_null[0])
29
+ inner_type = inner.get("type")
30
+ if nullable and isinstance(inner_type, str):
31
+ return {**inner, "type": [inner_type, "null"]}
32
+ return inner if inner else ({"type": "null"} if nullable else {})
33
+ types_seen = [
34
+ _SCALAR_TYPES[member]
35
+ for member in non_null
36
+ if member in _SCALAR_TYPES
37
+ ]
38
+ if nullable:
39
+ types_seen.append("null")
40
+ return {"type": types_seen} if types_seen else {}
41
+ if origin is list:
42
+ args = get_args(annotation)
43
+ items = _annotation_to_schema(args[0]) if args else {}
44
+ return {"type": "array", **({"items": items} if items else {})}
45
+ if origin is dict:
46
+ return {"type": "object"}
47
+ if origin is typing.Literal:
48
+ values = list(get_args(annotation))
49
+ schema: dict[str, Any] = {"enum": values}
50
+ if values and all(isinstance(value, type(values[0])) for value in values):
51
+ literal_type = _SCALAR_TYPES.get(type(values[0]))
52
+ if literal_type:
53
+ schema["type"] = literal_type
54
+ return schema
55
+ if annotation in _SCALAR_TYPES:
56
+ return {"type": _SCALAR_TYPES[annotation]}
57
+ if annotation is list:
58
+ return {"type": "array"}
59
+ if annotation is dict:
60
+ return {"type": "object"}
61
+ return {}
62
+
63
+
64
+ def derive_schema(handler: Callable[..., Any]) -> dict[str, Any]:
65
+ """Build an object schema from the model-supplied handler parameters."""
66
+ try:
67
+ hints = typing.get_type_hints(handler)
68
+ except (NameError, TypeError, AttributeError, SyntaxError, ValueError):
69
+ hints = {}
70
+ properties: dict[str, Any] = {}
71
+ required: list[str] = []
72
+ for parameter in inspect.signature(handler).parameters.values():
73
+ if parameter.kind in (
74
+ inspect.Parameter.VAR_POSITIONAL,
75
+ inspect.Parameter.VAR_KEYWORD,
76
+ ) or parameter.name == "context":
77
+ continue
78
+ properties[parameter.name] = _annotation_to_schema(
79
+ hints.get(parameter.name, parameter.annotation)
80
+ )
81
+ if parameter.default is inspect.Parameter.empty:
82
+ required.append(parameter.name)
83
+ schema: dict[str, Any] = {"type": "object", "properties": properties}
84
+ if required:
85
+ schema["required"] = required
86
+ return schema
@@ -0,0 +1,218 @@
1
+ """Plugin tool declaration and local plugin-host dispatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Literal, Protocol
9
+
10
+ from steerable_agent_harness.policy import ToolMode, decide_tool_mode
11
+ from steerable_agent_protocol.generated import ToolResult
12
+
13
+ from .schema import derive_schema
14
+
15
+ ToolHandler = Callable[..., Any] | Callable[..., Awaitable[Any]]
16
+ ToolExposure = Literal["direct", "deferred", "hidden"]
17
+ STEERABLE_TOOLS_ENTRY_POINT_GROUP = "steerable.tools"
18
+
19
+
20
+ class ToolRegistrationError(RuntimeError):
21
+ """A plugin attempted an invalid or duplicate tool registration."""
22
+
23
+
24
+ class RegistrationRouter(Protocol):
25
+ """Router methods consumed by the SDK's decorator."""
26
+
27
+ def register(self, handler: ToolHandler, **kwargs: Any) -> Any:
28
+ """Register one handler and return the host's registration record."""
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class ToolDescriptor:
33
+ """Wire-safe metadata advertised by a plugin host."""
34
+
35
+ name: str
36
+ description: str
37
+ schema: dict[str, Any]
38
+ mode: ToolMode
39
+ exposure: ToolExposure
40
+ require_consent: bool
41
+ concurrency_safe: bool
42
+ plugin: str | None = None
43
+
44
+ def to_wire(self) -> dict[str, Any]:
45
+ """Return the camelCase plugin-host representation."""
46
+ return {
47
+ "name": self.name,
48
+ "description": self.description,
49
+ "schema": self.schema,
50
+ "mode": self.mode,
51
+ "exposure": self.exposure,
52
+ "requireConsent": self.require_consent,
53
+ "concurrencySafe": self.concurrency_safe,
54
+ "plugin": self.plugin,
55
+ }
56
+
57
+ @classmethod
58
+ def from_wire(cls, value: dict[str, Any]) -> ToolDescriptor:
59
+ """Decode one plugin-host descriptor."""
60
+ return cls(
61
+ name=str(value["name"]),
62
+ description=str(value.get("description") or ""),
63
+ schema=dict(value.get("schema") or {}),
64
+ mode=value.get("mode") or decide_tool_mode(str(value["name"])),
65
+ exposure=value.get("exposure") or "direct",
66
+ require_consent=bool(value.get("requireConsent", False)),
67
+ concurrency_safe=bool(value.get("concurrencySafe", False)),
68
+ plugin=str(value["plugin"]) if value.get("plugin") else None,
69
+ )
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class RegisteredTool:
74
+ """One plugin-host registration and its local handler."""
75
+
76
+ descriptor: ToolDescriptor
77
+ handler: ToolHandler = field(repr=False)
78
+
79
+
80
+ class LocalPluginRouter:
81
+ """Pure-Python registry used inside the future plugin-host process."""
82
+
83
+ def __init__(self) -> None:
84
+ self._tools: dict[str, RegisteredTool] = {}
85
+
86
+ def register(
87
+ self,
88
+ handler: ToolHandler,
89
+ *,
90
+ name: str | None = None,
91
+ mode: ToolMode | None = None,
92
+ description: str | None = None,
93
+ schema: dict[str, Any] | None = None,
94
+ require_consent: bool | None = None,
95
+ concurrency_safe: bool | None = None,
96
+ exposure: ToolExposure = "direct",
97
+ plugin: str | None = None,
98
+ **_ignored: Any,
99
+ ) -> RegisteredTool:
100
+ """Register one plugin handler."""
101
+ resolved_name = name or getattr(handler, "__name__", None)
102
+ if not resolved_name:
103
+ raise ToolRegistrationError("tool handler must have a name")
104
+ if resolved_name in self._tools:
105
+ raise ToolRegistrationError(f"tool already registered: {resolved_name}")
106
+ resolved_mode = mode or decide_tool_mode(resolved_name)
107
+ descriptor = ToolDescriptor(
108
+ name=resolved_name,
109
+ description=description or (inspect.getdoc(handler) or "").strip(),
110
+ schema=schema if schema is not None else derive_schema(handler),
111
+ mode=resolved_mode,
112
+ exposure=exposure,
113
+ require_consent=(
114
+ require_consent
115
+ if require_consent is not None
116
+ else resolved_mode == "destructive"
117
+ ),
118
+ concurrency_safe=(
119
+ concurrency_safe
120
+ if concurrency_safe is not None
121
+ else resolved_mode == "read"
122
+ ),
123
+ plugin=plugin,
124
+ )
125
+ registered = RegisteredTool(descriptor=descriptor, handler=handler)
126
+ self._tools[resolved_name] = registered
127
+ return registered
128
+
129
+ def unregister(self, name: str) -> None:
130
+ """Remove a handler if present."""
131
+ self._tools.pop(name, None)
132
+
133
+ def descriptors(self) -> list[ToolDescriptor]:
134
+ """Return registrations in insertion order."""
135
+ return [registered.descriptor for registered in self._tools.values()]
136
+
137
+ async def dispatch(
138
+ self,
139
+ name: str,
140
+ arguments: dict[str, Any],
141
+ context: dict[str, Any] | None = None,
142
+ ) -> ToolResult:
143
+ """Invoke one local plugin handler and normalize its result."""
144
+ registered = self._tools.get(name)
145
+ if registered is None:
146
+ return ToolResult(
147
+ success=False,
148
+ error=f"unknown plugin tool: {name}",
149
+ terminal=False,
150
+ needsFollowup=True,
151
+ )
152
+ handler = registered.handler
153
+ signature = inspect.signature(handler)
154
+ accepts_kwargs = any(
155
+ parameter.kind is inspect.Parameter.VAR_KEYWORD
156
+ for parameter in signature.parameters.values()
157
+ )
158
+ kwargs = {
159
+ parameter.name: (
160
+ (context or {})
161
+ if parameter.name == "context"
162
+ else arguments[parameter.name]
163
+ )
164
+ for parameter in signature.parameters.values()
165
+ if parameter.kind
166
+ not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
167
+ and (parameter.name == "context" or parameter.name in arguments)
168
+ }
169
+ if accepts_kwargs:
170
+ for key, value in arguments.items():
171
+ kwargs.setdefault(key, value)
172
+ try:
173
+ result = handler(**kwargs)
174
+ if inspect.isawaitable(result):
175
+ result = await result
176
+ except Exception as exc:
177
+ return ToolResult(
178
+ success=False,
179
+ error=str(exc),
180
+ terminal=False,
181
+ needsFollowup=True,
182
+ )
183
+ if isinstance(result, ToolResult):
184
+ return result
185
+ if isinstance(result, dict) and "success" in result:
186
+ return ToolResult(**result)
187
+ return ToolResult(success=True, data={"value": result})
188
+
189
+
190
+ def tool(
191
+ *,
192
+ name: str | None = None,
193
+ mode: ToolMode | None = None,
194
+ description: str | None = None,
195
+ schema: dict[str, Any] | None = None,
196
+ require_consent: bool | None = None,
197
+ concurrency_safe: bool | None = None,
198
+ exposure: ToolExposure = "direct",
199
+ router: RegistrationRouter | None = None,
200
+ ) -> Callable[[ToolHandler], ToolHandler]:
201
+ """Decorate and optionally register a plugin handler."""
202
+
203
+ def decorate(handler: ToolHandler) -> ToolHandler:
204
+ metadata = {
205
+ "name": name,
206
+ "mode": mode,
207
+ "description": description,
208
+ "schema": schema,
209
+ "require_consent": require_consent,
210
+ "concurrency_safe": concurrency_safe,
211
+ "exposure": exposure,
212
+ }
213
+ handler.__steerable_tool_meta__ = metadata
214
+ if router is not None:
215
+ router.register(handler, **metadata)
216
+ return handler
217
+
218
+ return decorate
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: steerable-plugin-sdk
3
+ Version: 0.6.29
4
+ Summary: Pure-Python authoring and RPC SDK for Steerable tool plugins
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: steerable-agent-harness<1.0.0,>=0.1.0
8
+ Requires-Dist: steerable-agent-protocol<1.0.0,>=0.1.0
9
+
10
+ # Steerable Plugin SDK
11
+
12
+ Pure-Python plugin authoring surface for Steerable tools. This package does
13
+ not depend on `steerable-agent-runtime` or its native CoreLoop wheel.
14
+
15
+ ```python
16
+ from steerable_plugin_sdk import tool
17
+
18
+
19
+ def register(router):
20
+ @tool(router=router, description="Greet by name")
21
+ async def greet(name: str) -> str:
22
+ return f"hello {name}"
23
+ ```
24
+
25
+ Packaged plugins publish `register` through the `steerable.tools` entry-point
26
+ group. `LocalPluginRouter` is the plugin-host registry; `PluginHostRpcClient`
27
+ and `RpcRouterProxy` bridge its descriptors and invocations to a sidecar
28
+ router.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/steerable_plugin_sdk/__init__.py
4
+ src/steerable_plugin_sdk/rpc.py
5
+ src/steerable_plugin_sdk/schema.py
6
+ src/steerable_plugin_sdk/tools.py
7
+ src/steerable_plugin_sdk.egg-info/PKG-INFO
8
+ src/steerable_plugin_sdk.egg-info/SOURCES.txt
9
+ src/steerable_plugin_sdk.egg-info/dependency_links.txt
10
+ src/steerable_plugin_sdk.egg-info/requires.txt
11
+ src/steerable_plugin_sdk.egg-info/top_level.txt
12
+ tests/test_plugin_sdk.py
@@ -0,0 +1,2 @@
1
+ steerable-agent-harness<1.0.0,>=0.1.0
2
+ steerable-agent-protocol<1.0.0,>=0.1.0
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from typing import Literal
6
+
7
+ import pytest
8
+ from steerable_plugin_sdk import (
9
+ LocalPluginRouter,
10
+ PluginHostRpcClient,
11
+ RpcRouterProxy,
12
+ ToolDescriptor,
13
+ derive_schema,
14
+ tool,
15
+ )
16
+
17
+
18
+ def test_schema_derives_without_importing_runtime() -> None:
19
+ def search(query: str, limit: int = 8, mode: Literal["fast", "deep"] = "fast"):
20
+ pass
21
+
22
+ assert derive_schema(search) == {
23
+ "type": "object",
24
+ "properties": {
25
+ "query": {"type": "string"},
26
+ "limit": {"type": "integer"},
27
+ "mode": {"enum": ["fast", "deep"], "type": "string"},
28
+ },
29
+ "required": ["query"],
30
+ }
31
+ check = subprocess.run(
32
+ [
33
+ sys.executable,
34
+ "-c",
35
+ (
36
+ "import sys; import steerable_plugin_sdk; "
37
+ "assert 'steerable_agent_runtime' not in sys.modules"
38
+ ),
39
+ ],
40
+ check=False,
41
+ capture_output=True,
42
+ text=True,
43
+ )
44
+ assert check.returncode == 0, check.stderr
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_author_decorator_and_local_dispatch() -> None:
49
+ router = LocalPluginRouter()
50
+
51
+ @tool(router=router, description="Greet by name")
52
+ async def greet(name: str, context: dict) -> str:
53
+ return f"{context['prefix']} {name}"
54
+
55
+ descriptors = router.descriptors()
56
+ assert [descriptor.name for descriptor in descriptors] == ["greet"]
57
+ assert descriptors[0].schema["required"] == ["name"]
58
+ result = await router.dispatch("greet", {"name": "Ada"}, {"prefix": "hello"})
59
+ assert result.success is True
60
+ assert result.data == {"value": "hello Ada"}
61
+
62
+
63
+ class _Transport:
64
+ def __init__(self) -> None:
65
+ self.requests: list[tuple[str, dict]] = []
66
+
67
+ async def request(self, method: str, params: dict):
68
+ self.requests.append((method, params))
69
+ if method == "plugin.tools.describe":
70
+ return {
71
+ "tools": [
72
+ ToolDescriptor(
73
+ name="remote_greet",
74
+ description="greet",
75
+ schema={
76
+ "type": "object",
77
+ "properties": {"name": {"type": "string"}},
78
+ "required": ["name"],
79
+ },
80
+ mode="read",
81
+ exposure="direct",
82
+ require_consent=False,
83
+ concurrency_safe=True,
84
+ plugin="example",
85
+ ).to_wire()
86
+ ]
87
+ }
88
+ if method == "plugin.tool.invoke":
89
+ return {
90
+ "success": True,
91
+ "data": {"value": f"hello {params['arguments']['name']}"},
92
+ }
93
+ raise AssertionError(method)
94
+
95
+
96
+ class _RuntimeRouter:
97
+ def __init__(self) -> None:
98
+ self.tools: dict[str, tuple] = {}
99
+
100
+ def register_remote(self, name, invoker, **kwargs):
101
+ self.tools[name] = (invoker, kwargs)
102
+
103
+ def unregister(self, name):
104
+ self.tools.pop(name, None)
105
+
106
+
107
+ @pytest.mark.asyncio
108
+ async def test_rpc_proxy_projects_and_invokes_remote_tools() -> None:
109
+ transport = _Transport()
110
+ client = PluginHostRpcClient(transport)
111
+ runtime = _RuntimeRouter()
112
+ proxy = RpcRouterProxy(client, runtime)
113
+
114
+ assert await proxy.sync_tools() == ["remote_greet"]
115
+ invoker, metadata = runtime.tools["remote_greet"]
116
+ assert metadata["metadata"] == {
117
+ "plugin": "example",
118
+ "pluginHost": True,
119
+ }
120
+ result = await invoker("remote_greet", {"name": "Ada"})
121
+ assert result.success is True
122
+ assert result.data == {"value": "hello Ada"}